diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d1ae3bc64f56f..492903139547b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -85,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index c80fe9b87cd6a..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index b155e00d4998a..218d01041561d 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -87,6 +87,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; +import { suggestAppAccessForRemote } from "./suggestAppAccess"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { useRemoteConnectionStatus, @@ -997,6 +998,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s ); const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId); const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false }); + const checkPublishedAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { + reportFailure: false, + reportDefect: false, + }); const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, { reportFailure: false, }); @@ -1095,6 +1100,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s `Could not create the ${publishTarget.definition.label} repository`, errorMessage(Cause.squash(publishResult.cause)), ); + } else { + void suggestAppAccessForRemote( + checkPublishedAppAccess, + environment.environmentId, + publishResult.value.remoteUrl, + ); } }); } @@ -1315,6 +1326,10 @@ export function AddProjectDestinationScreen(props: { const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); + const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, { + reportFailure: false, + reportDefect: false, + }); const navigation = useNavigation(); const environment = useEnvironmentFromParam(props.environmentId); const canWriteSourceControl = useEnvironmentScope( @@ -1376,6 +1391,7 @@ export function AddProjectDestinationScreen(props: { if (AsyncResult.isFailure(startResult)) { setError(errorMessage(Cause.squash(startResult.cause))); } else { + void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl); // Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls. const project = await waitForProject( { environmentId: environment.environmentId, projectId }, diff --git a/apps/mobile/src/features/projects/suggestAppAccess.ts b/apps/mobile/src/features/projects/suggestAppAccess.ts new file mode 100644 index 0000000000000..9340d8dd836ad --- /dev/null +++ b/apps/mobile/src/features/projects/suggestAppAccess.ts @@ -0,0 +1,38 @@ +import type { + EnvironmentId, + SourceControlAppAccess, + SourceControlAppAccessInput, +} from "@t3tools/contracts"; +import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { Alert, Linking } from "react-native"; + +/** + * After a clone, suggests installing the forge app on the repository's owner when the server is + * signed in with it but the app does not reach the repository. + */ +export async function suggestAppAccessForRemote( + check: (value: { + readonly environmentId: EnvironmentId; + readonly input: SourceControlAppAccessInput; + }) => Promise>, + environmentId: EnvironmentId, + remoteUrl: string, +): Promise { + const target = appSignInTargetForRemote(remoteUrl); + if (target === null) return; + const result = await check({ environmentId, input: target.input }); + if (result._tag !== "Success" || result.value.status !== "not-covered") return; + const owner = target.input.repository.split("/")[0] ?? target.input.repository; + Alert.alert( + `Give ${target.appSignIn.appName} access to ${owner}`, + `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, + [ + { text: "Not now", style: "cancel" }, + { + text: "Install", + onPress: () => void Linking.openURL(target.appSignIn.installUrl).catch(() => undefined), + }, + ], + ); +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 164b1dc965c7f..e0ad9c76e9bb4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -82,6 +82,7 @@ import { pullRequestLabelColor } from "./pullRequestList"; import { PullRequestReactions } from "./PullRequestReactions"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; +import { SourceControlAppAccessNotice } from "./SourceControlAppAccessNotice"; import { usePullToRefresh } from "../../state/use-pull-to-refresh"; import { resolveNativeReviewDiffView, @@ -720,6 +721,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps {tab === "overview" && identity ? ( + {pr ? ( 0; + const access = useEnvironmentQuery( + definition !== undefined && appSignIn?.hosts.includes(host) && signedIn + ? sourceControlEnvironment.appAccess({ + environmentId, + input: { provider: definition.kind, host, repository: props.repository }, + }) + : null, + ); + if (appSignIn === undefined || access.data?.status !== "not-covered") return null; + const owner = props.repository.split("/")[0] ?? props.repository; + + return ( + + + {appSignIn.appName} isn't installed on {owner}, so requests here use your own GitHub login + and its lower rate limit. + + void Linking.openURL(appSignIn.installUrl).catch(() => undefined)} + > + Install on {owner} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index c800e6cbfe9ee..4fa4874da13b9 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -45,6 +45,9 @@ vi.mock("../../state/preferences", () => ({ updateMobilePreferencesAtom: {}, })); vi.mock("./components/AutoSettleDaysField", () => ({ AutoSettleDaysField: () => null })); +vi.mock("./components/SourceControlSignInSection", () => ({ + SourceControlSignInSection: () => null, +})); vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); vi.mock("../../state/session", () => ({ diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index ebaf6b45cd26f..91b0b1b044e58 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -28,6 +28,7 @@ import { DatabaseStorageNotice } from "./components/DatabaseStorageNotice"; import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { SourceControlSignInSection } from "./components/SourceControlSignInSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; import { useSettingsWriter } from "./useSettingsWriter"; @@ -382,6 +383,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {props.page === "source-control" ? ( <> + {projectSelected ? null : ( + writableEnvironments.has(environmentId)} + /> + )} ) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +const openUrl = (url: string) => void Linking.openURL(url).catch(() => undefined); + +const appSignInHosts = sourceControlClients.definitions.flatMap((definition) => + definition.credentials?.appSignIn === undefined + ? [] + : [{ definition, appSignIn: definition.credentials.appSignIn }], +); + +/** + * Signing an environment in through a forge's own app, such as T3 Code for GitHub, on servers + * configured for it. + */ +export function SourceControlSignInSection(props: { + readonly environments: readonly SettingsTarget[]; + readonly canWrite: (environmentId: EnvironmentId) => boolean; +}) { + return ( + <> + {appSignInHosts.flatMap(({ definition, appSignIn }) => + props.environments.map((environment) => ( + 1 + ? `${definition.label} · ${environment.label}` + : definition.label + } + definition={definition} + appSignIn={appSignIn} + disabled={!props.canWrite(environment.environmentId)} + /> + )), + )} + + ); +} + +function OfferedAppSignIn(props: { + readonly environment: SettingsTarget; + readonly title: string; + readonly definition: SourceControlDefinition; + readonly appSignIn: SourceControlAppSignIn; + readonly disabled: boolean; +}) { + const discovery = useEnvironmentQuery( + sourceControlEnvironment.discovery({ + environmentId: props.environment.environmentId, + input: {}, + }), + ); + const offered = discovery.data?.sourceControlProviders.some( + (item) => item.kind === props.definition.kind && item.appSignIn === true, + ); + if (offered !== true) return null; + return ( + + + + ); +} + +function AppSignInRow(props: { + readonly environment: SettingsTarget; + readonly label: string; + readonly definition: SourceControlDefinition; + readonly appSignIn: SourceControlAppSignIn; + readonly disabled: boolean; +}) { + const { environment, definition, appSignIn } = props; + const environmentId = environment.environmentId; + const host = appSignIn.hosts[0] ?? ""; + const { signIns } = readHostCredentialSettings( + environment.serverConfig.settings, + definition.kind, + ); + const signedIn = (signIns[host] ?? "").length > 0; + const start = useAtomCommand(sourceControlEnvironment.startSignIn, { reportFailure: false }); + const wait = useAtomCommand(sourceControlEnvironment.waitForSignIn, { reportFailure: false }); + const cancel = useAtomCommand(sourceControlEnvironment.cancelSignIn, { reportFailure: false }); + // The attempt a pending `wait` belongs to; a cancel or a newer attempt makes its outcome stale. + const attempt = useRef(null); + const input = { provider: definition.kind, host }; + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: `sign out of ${appSignIn.appName}`, + }); + const [pending, setPending] = useState(null); + + const showCode = (prompt: SourceControlSignInPrompt) => { + void Clipboard.setStringAsync(prompt.userCode).catch(() => undefined); + Alert.alert( + prompt.userCode, + `Enter this code on GitHub, then approve ${appSignIn.appName}. It is copied. The server finishes signing in even if you leave the app.`, + [ + { + text: "Cancel", + style: "cancel", + onPress: () => { + attempt.current = null; + setPending(null); + void cancel({ environmentId, input }); + }, + }, + { text: "Open GitHub", onPress: () => openUrl(prompt.verificationUri) }, + ], + ); + }; + + const signIn = async () => { + setPending("starting"); + const started = await start({ environmentId, input }); + if (started._tag !== "Success") { + setPending(null); + Alert.alert("Could not start the sign-in", failureMessage(started.cause)); + return; + } + const prompt = started.value; + attempt.current = prompt; + setPending(prompt); + showCode(prompt); + const finished = await wait({ environmentId, input }); + if (attempt.current !== prompt) return; + attempt.current = null; + setPending(null); + if (finished._tag !== "Success") { + Alert.alert("Sign-in did not finish", failureMessage(finished.cause)); + } + }; + + const signOut = () => + void updateSettings({ + environmentId, + // Send the whole map; other hosts' redaction markers preserve their sessions. + input: { + patch: { + sourceControlHosts: { [definition.kind]: { signIns: { ...signIns, [host]: "" } } }, + }, + }, + }); + + const onPress = () => { + if (signedIn) { + Alert.alert( + appSignIn.appName, + "Signed in. GitHub requests use the app's higher rate limit, in repositories where the app is installed.", + [ + { text: "Install on an organization", onPress: () => openUrl(appSignIn.installUrl) }, + { text: "Sign out", style: "destructive", onPress: signOut }, + { text: "Cancel", style: "cancel" }, + ], + ); + } else if (pending !== null && pending !== "starting") { + showCode(pending); + } else if (pending === null) { + void signIn(); + } + }; + + return ( + + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index c7cf2f99153ac..f127f94fdba35 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -138,6 +138,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, + [WS_METHODS.sourceControlAppAccess]: AuthOrchestrationReadScope, [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsListIgnoredFiles]: AuthFilesystemReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4190c75ddb1e5..504e533b37e05 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -134,6 +134,10 @@ const RPC_AGGREGATES = { [WS_METHODS.sourceControlLookupRepository]: "source-control", [WS_METHODS.sourceControlCloneRepository]: "source-control", [WS_METHODS.sourceControlPublishRepository]: "source-control", + [WS_METHODS.sourceControlSignInStart]: "source-control", + [WS_METHODS.sourceControlSignInWait]: "source-control", + [WS_METHODS.sourceControlSignInCancel]: "source-control", + [WS_METHODS.sourceControlAppAccess]: "source-control", [WS_METHODS.projectCloneStart]: "source-control", [WS_METHODS.projectCloneCancel]: "source-control", [WS_METHODS.projectCloneRetry]: "source-control", diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 6c72c6d7d01db..5ee9b6274408a 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1565,7 +1565,7 @@ export const make = Effect.gen(function* () { } const result = yield* api .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, + { cwd: project.project.workspaceRoot, host: project.host, repository: project.repository }, (identity) => identity.accountId === input.expectedAccountId ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) @@ -2066,7 +2066,11 @@ export const make = Effect.gen(function* () { return input.action === "delete-branch" && project.api.withVerifiedCredential !== undefined ? project.api .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, + { + cwd: project.project.workspaceRoot, + host: project.host, + repository: project.repository, + }, () => operation.pipe(Effect.result), ) .pipe( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 27e7a4b2e97bf..741fc73103b7d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -61,6 +61,7 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; @@ -419,6 +420,9 @@ const layerVcs = Layer.empty.pipe( Layer.provideMerge(layerGitWorkflow), Layer.provideMerge(layerReview), Layer.provideMerge(layerSourceControlRepositoryService), + Layer.provideMerge( + SourceControlSignIn.layer.pipe(Layer.provide(layerSourceControlProviderRegistry)), + ), Layer.provideMerge(layerProjectCloneTracker), Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index aed4011bf1ad6..73e79a8cc49f3 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -15,7 +15,7 @@ export const layer = Layer.effect( const git = yield* GitVcsDriver.GitVcsDriver; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; return SourceControlHost.SourceControlHost.of({ - settings: { get: serverSettings.getSettings }, + settings: { get: serverSettings.getSettings, update: serverSettings.updateSettings }, process: { run: process.run }, git: { execute: git.execute, diff --git a/apps/server/src/sourceControl/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts new file mode 100644 index 0000000000000..d6c2ab1a69a33 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -0,0 +1,69 @@ +import { + type SourceControlAppAccess, + type SourceControlAppAccessInput, + SourceControlSignInError, + type SourceControlSignInInput, + type SourceControlSignInPrompt, + type SourceControlSignInResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as SourceControlBuiltInDrivers from "./builtInDrivers.ts"; + +/** Signs the server in to a forge through the forge's own app, for hosts whose driver offers it. */ +export class SourceControlSignIn extends Context.Service< + SourceControlSignIn, + { + readonly start: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly wait: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly cancel: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; + } +>()("t3/sourceControl/SourceControlSignIn") {} + +const make = Effect.gen(function* () { + const drivers = yield* SourceControlBuiltInDrivers.SourceControlDrivers; + + const signInFor = (input: SourceControlSignInInput) => { + const signIn = drivers.find(({ driver }) => driver.kind === input.provider)?.instance.signIn; + return signIn === undefined + ? Effect.fail( + new SourceControlSignInError({ + provider: input.provider, + host: input.host, + reason: "unsupported", + detail: `This server cannot sign in to ${input.provider}. Use a token or the host's CLI.`, + }), + ) + : Effect.succeed(signIn); + }; + + return SourceControlSignIn.of({ + start: Effect.fn("SourceControlSignIn.start")(function* (input) { + return yield* (yield* signInFor(input)).start(input.host); + }), + wait: Effect.fn("SourceControlSignIn.wait")(function* (input) { + return yield* (yield* signInFor(input)).wait(input.host); + }), + cancel: Effect.fn("SourceControlSignIn.cancel")(function* (input) { + return yield* (yield* signInFor(input)).cancel(input.host); + }), + access: Effect.fn("SourceControlSignIn.access")(function* (input) { + const signIn = drivers.find(({ driver }) => driver.kind === input.provider)?.instance.signIn; + return { + status: + signIn === undefined ? "signed-out" : yield* signIn.access(input.host, input.repository), + } satisfies SourceControlAppAccess; + }), + }); +}); + +export const layer = Layer.effect(SourceControlSignIn, make); diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index 30d32d67d4014..61fb57ada454e 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -70,6 +70,7 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }), [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), + [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }), [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }), [WS_METHODS.guestShareCreate]: () => ({ feature: "guest_share.create" }), }; diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index bb76e9b434cff..a1dfa5f4b868c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -86,6 +86,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -454,6 +455,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(SessionStore.SessionStore)({ cookieName: "config-test", legacyCookieName: undefined }), Layer.mock(SourceControlDiscovery.SourceControlDiscovery)({}), Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + Layer.mock(SourceControlSignIn.SourceControlSignIn)({}), Layer.mock(StorageCleanup.StorageCleanup)({}), Layer.mock(DatabaseStorage.DatabaseStorage)({}), Layer.mock(TerminalManager.TerminalManager)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 40ccc02353a91..bb4b87b39dfd3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -244,6 +244,7 @@ import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReact import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -1348,6 +1349,7 @@ export const layerWsRpc = ( const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck; const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const sourceControlSignIn = yield* SourceControlSignIn.SourceControlSignIn; const withPullRequestViewer = pullRequests.withRoutingCredential; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -2649,6 +2651,10 @@ export const layerWsRpc = ( sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => sourceControlRepositories.cloneRepository(input), + [WS_METHODS.sourceControlSignInStart]: (input) => sourceControlSignIn.start(input), + [WS_METHODS.sourceControlSignInWait]: (input) => sourceControlSignIn.wait(input), + [WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input), + [WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input), [WS_METHODS.projectCloneStart]: (input) => projectCloneTracker.start(input, { createProject: (project) => diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 269d9401a9e9c..3ec717cc24a6f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -231,6 +231,7 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -1982,6 +1983,10 @@ function OpenCommandPaletteDialog(props: { return; } setOpen(false); + void suggestAppAccessForRemote( + addProjectCloneFlow.environmentId, + addProjectCloneFlow.remoteUrl, + ); const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); await waitForProject(projectRef, 3_000).catch(() => null); const navigationResult = await settlePromise(() => handleNewThread(projectRef)); diff --git a/apps/web/src/components/SourceControlAppAccessNotice.tsx b/apps/web/src/components/SourceControlAppAccessNotice.tsx new file mode 100644 index 0000000000000..4267b53150207 --- /dev/null +++ b/apps/web/src/components/SourceControlAppAccessNotice.tsx @@ -0,0 +1,68 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { + readHostCredentialSettings, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; +import { ExternalLinkIcon } from "lucide-react"; + +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { cn } from "../lib/utils"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { T3AppIcon } from "./T3AppIcon"; +import { InlineButton } from "./ui/button"; + +/** + * Says when the server's forge app sign-in cannot reach `repository`, so requests there fall back + * to the user's own login. Renders nothing for servers that do not use the app. + */ +export function SourceControlAppAccessNotice(props: { + readonly environmentId: EnvironmentId; + readonly provider: string | undefined; + readonly host: string | undefined; + /** `owner/name`. */ + readonly repository: string; + readonly className?: string; +}) { + const definition = props.provider ? sourceControlClients.find(props.provider) : undefined; + const appSignIn = definition?.credentials?.appSignIn; + const host = (props.host ?? definition?.publicHost ?? "").toLowerCase(); + const signedIn = useEnvironmentSettings(props.environmentId, (settings) => + definition === undefined + ? false + : (readHostCredentialSettings(settings, definition.kind).signIns[host] ?? "").length > 0, + ); + const access = useEnvironmentQuery( + definition !== undefined && appSignIn?.hosts.includes(host) && signedIn + ? sourceControlEnvironment.appAccess({ + environmentId: props.environmentId, + input: { provider: definition.kind, host, repository: props.repository }, + }) + : null, + ); + if (appSignIn === undefined || access.data?.status !== "not-covered") return null; + const owner = props.repository.split("/")[0] ?? props.repository; + + return ( +
+ +

+ {appSignIn.appName} isn't installed on {owner}, so + requests here use your own GitHub login and its lower rate limit. +

+ } + > + Install on {owner} + + +
+ ); +} diff --git a/apps/web/src/components/T3AppIcon.tsx b/apps/web/src/components/T3AppIcon.tsx new file mode 100644 index 0000000000000..7e3ebef5692b1 --- /dev/null +++ b/apps/web/src/components/T3AppIcon.tsx @@ -0,0 +1,6 @@ +// The production icon, as GitHub shows the T3 Code app, whatever this build's channel. +import appIconUrl from "../../../../assets/prod/t3-black-web-apple-touch-180.png"; + +export function T3AppIcon(props: { readonly className?: string }) { + return ; +} diff --git a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx index 03d2bf4ee82a8..60b114fe2382f 100644 --- a/apps/web/src/components/promptLinks/NewThreadLinkView.tsx +++ b/apps/web/src/components/promptLinks/NewThreadLinkView.tsx @@ -42,6 +42,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty"; import { SidebarInset } from "../ui/sidebar"; import { stackedThreadToast, toastManager } from "../ui/toast"; +import { suggestAppAccessForRemote } from "../sourceControlAppAccessToast"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { placeLinkedPrompt, warnAboutHiddenCharacters } from "./linkedPrompt"; @@ -233,6 +234,10 @@ export function NewThreadLinkView({ link }: { readonly link: NewThreadLink }) { setState({ status: "missing-repository", repository }); return; } + void suggestAppAccessForRemote( + environmentId, + `https://github.com/${repository.owner}/${repository.name}.git`, + ); const project = await waitForProject(scopeProjectRef(environmentId, projectId), 5_000).catch( () => null, ); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 98fd247a2e1f2..b67da2cc8f6cb 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -100,6 +100,7 @@ import { PullRequestBaseBranch } from "./PullRequestBaseBranch"; import { PullRequestDetailCrumbs } from "./PullRequestDetailCrumbs"; import { PullRequestTitleEditor } from "./PullRequestTitleEditor"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; +import { SourceControlAppAccessNotice } from "../SourceControlAppAccessNotice"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; @@ -1681,6 +1682,13 @@ export function PullRequestDetailPanel({ ) : null} +
{ diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx new file mode 100644 index 0000000000000..5662954ef7cd6 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -0,0 +1,277 @@ +import type { EnvironmentId, SourceControlSignInPrompt } from "@t3tools/contracts"; +import { + readHostCredentialSettings, + type SourceControlAppSignIn, + type SourceControlDefinition, +} from "@t3tools/client-runtime/source-control-clients"; +import { useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import { Check, Copy } from "lucide"; +import { CheckIcon, ExternalLinkIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { MorphIcon } from "~/components/MorphIcon"; +import { T3AppIcon } from "~/components/T3AppIcon"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { serverEnvironment } from "../../state/server"; +import { sourceControlEnvironment } from "../../state/sourceControl"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button, InlineButton } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; + +type SignInState = + | { readonly status: "idle" } + | { readonly status: "starting" } + | { readonly status: "waiting"; readonly prompt: SourceControlSignInPrompt } + | { readonly status: "failed"; readonly message: string }; + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +const expiryTime = (expiresAt: number) => + new Date(expiresAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + +function ExternalLink(props: { readonly href: string; readonly children: ReactNode }) { + return ( + }> + {props.children} + + + ); +} + +function AppMark(props: { readonly signedIn: boolean }) { + return ( +
+ + {props.signedIn ? ( + + + + ) : null} +
+ ); +} + +/** The device code, one cell per character, as GitHub prints it. */ +function DeviceCode(props: { readonly code: string }) { + return ( +
+ {props.code} + {Array.from(props.code, (char, index) => + char === "-" ? ( + + – + + ) : ( + + {char} + + ), + )} +
+ ); +} + +export function SourceControlAppSignInSettings({ + environmentId, + definition, + appSignIn, + account, + onChanged, +}: { + readonly environmentId: EnvironmentId; + readonly definition: SourceControlDefinition; + readonly appSignIn: SourceControlAppSignIn; + /** The login discovery reports, which is the signed-in account when no token overrides it. */ + readonly account: string | null; + readonly onChanged: () => void; +}) { + const host = appSignIn.hosts[0] ?? ""; + const { signIns, tokens } = useEnvironmentSettings(environmentId, (settings) => + readHostCredentialSettings(settings, definition.kind), + ); + const signedIn = (signIns[host] ?? "").length > 0; + const tokenOverrides = (tokens[host] ?? "").length > 0; + const canSignIn = useAtomValue( + sourceControlEnvironment.startSignIn.permissionAtom(environmentId), + ); + const start = useAtomCommand(sourceControlEnvironment.startSignIn, { reportFailure: false }); + const wait = useAtomCommand(sourceControlEnvironment.waitForSignIn, { reportFailure: false }); + const cancel = useAtomCommand(sourceControlEnvironment.cancelSignIn, { reportFailure: false }); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: `sign out of ${appSignIn.appName}`, + }); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "sign-in code", + }); + const [state, setState] = useState({ status: "idle" }); + + const signIn = async () => { + setState({ status: "starting" }); + const input = { provider: definition.kind, host }; + const started = await start({ environmentId, input }); + if (started._tag !== "Success") { + setState({ status: "failed", message: failureMessage(started.cause) }); + return; + } + const prompt = started.value; + setState({ status: "waiting", prompt }); + const finished = await wait({ environmentId, input }); + // A newer attempt, a cancel, or a sign-out owns the state by now. + setState((current) => { + if (current.status !== "waiting" || current.prompt !== prompt) return current; + return finished._tag === "Success" + ? { status: "idle" } + : { status: "failed", message: failureMessage(finished.cause) }; + }); + if (finished._tag === "Success") onChanged(); + }; + + const signOut = async () => { + setState({ status: "idle" }); + const result = await updateSettings({ + environmentId, + // Send the whole map; other hosts' redaction markers preserve their sessions. + input: { + patch: { + sourceControlHosts: { [definition.kind]: { signIns: { ...signIns, [host]: "" } } }, + }, + }, + }); + if (result._tag === "Success") onChanged(); + }; + + const waiting = !signedIn && state.status === "waiting" ? state.prompt : null; + + let subtitle: ReactNode; + let action: ReactNode; + if (signedIn) { + subtitle = + account !== null && !tokenOverrides ? ( + + Signed in as + + + ) : tokenOverrides ? ( + "Signed in, but the token saved below is used until you remove it." + ) : ( + "Signed in" + ); + action = ( + + ); + } else if (waiting !== null) { + subtitle = "Approve the app on GitHub to finish signing in."; + action = ( + + ); + } else { + subtitle = "A far higher GitHub API rate limit than a personal token or gh login."; + action = ( + + ); + } + + return ( +
+
+ +
+

{appSignIn.appName}

+
{subtitle}
+
+
{action}
+
+ + {waiting !== null ? ( +
+
+ + +
+
+ +

+ + Waiting for approval · code expires at {expiryTime(waiting.expiresAt)} +

+
+
+ ) : null} + + {signedIn ? ( +
+ + Reaches repositories in accounts and organizations that installed the app. + + Install on an organization + Manage on GitHub +
+ ) : null} + + {state.status === "failed" && !signedIn ? ( +

+ {state.message} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index cdf31a835ecd0..7f7b0a90ebcc5 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -32,6 +32,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { GitIcon, JujutsuIcon, type Icon } from "../Icons"; import { SourceControlHostSettings } from "./SourceControlHostSettings"; import { SourceControlAccountSettings } from "./SourceControlAccountSettings"; +import { SourceControlAppSignInSettings } from "./SourceControlAppSignInSettings"; import { SourceControlTokenSettings } from "./SourceControlTokenSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; @@ -391,6 +392,23 @@ export function SourceControlSettingsPanel() { return (
+ {credentials.appSignIn && item.appSignIn === true ? ( + + ) : null} + {credentials.appSignIn && item.appSignIn === true ? ( +
+ + Or use your own credentials + +
+ ) : null} { + const target = appSignInTargetForRemote(remoteUrl); + if (target === null) return; + const result = await runAtomCommand( + appAtomRegistry, + sourceControlEnvironment.checkAppAccess, + { environmentId, input: target.input }, + { label: "check app access", reportFailure: false, reportDefect: false }, + ); + if (result._tag !== "Success" || result.value.status !== "not-covered") return; + const owner = target.input.repository.split("/")[0] ?? target.input.repository; + const id = toastManager.add({ + type: "info", + title: `Give ${target.appSignIn.appName} access to ${owner}`, + description: `It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`, + actionProps: { + children: "Install", + onClick: () => { + toastManager.close(id); + window.open(target.appSignIn.installUrl, "_blank", "noopener,noreferrer"); + }, + }, + }); +} diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts index 496991daeeb93..989ac9f757d1a 100644 --- a/apps/web/src/hooks/useNewProject.ts +++ b/apps/web/src/hooks/useNewProject.ts @@ -15,6 +15,7 @@ import { projectEnvironment } from "~/state/projects"; import { sourceControlEnvironment } from "~/state/sourceControl"; import { useAtomCommand } from "~/state/use-atom-command"; import { useNewThreadHandler } from "./useHandleNewThread"; +import { suggestAppAccessForRemote } from "../components/sourceControlAppAccessToast"; function errorMessage(error: unknown): string { return error instanceof Error && error.message.trim().length > 0 @@ -63,6 +64,7 @@ export function useNewProject() { description: result.value.repository.nameWithOwner, }), ); + void suggestAppAccessForRemote(input.environmentId, result.value.remoteUrl); }; return async (input: { diff --git a/apps/web/src/lib/addProjectToMachine.ts b/apps/web/src/lib/addProjectToMachine.ts index bccfc9d84910e..0ef0467b934ea 100644 --- a/apps/web/src/lib/addProjectToMachine.ts +++ b/apps/web/src/lib/addProjectToMachine.ts @@ -13,6 +13,7 @@ import type { import { create } from "zustand"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { suggestAppAccessForRemote } from "../components/sourceControlAppAccessToast"; import { environmentProjectClonesAtom, silenceProjectCloneToasts } from "../state/projectClones"; import { projectEnvironment } from "../state/projects"; import { sourceControlEnvironment } from "../state/sourceControl"; @@ -143,6 +144,7 @@ export async function addProjectToMachine(input: AddProjectToMachineInput): Prom setFileCopy(copyKey, null); throw new Error(failureMessage(started)); } + void suggestAppAccessForRemote(input.target.environmentId, input.remoteUrl); if (reads.length === 0) return; void (async () => { diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 88990a4521339..c95ddae2afcde 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -27,10 +27,12 @@ interface PendingRpcAckRequest { const pendingRpcAckRequests = new Map(); // Background reports nobody waits on. A busy host delays them first, and warning -// about a request the user never made does not say what is wrong. +// about a request the user never made does not say what is wrong. A sign-in wait +// lasts as long as the user takes on the forge. const untrackedRpcAckMethods = new Set([ WS_METHODS.serverGetUsageSummary, WS_METHODS.serverReportClientActivity, + WS_METHODS.sourceControlSignInWait, ]); const longRunningRpcAckMethods = new Set([ WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/state/sourceControlActions.test.ts b/apps/web/src/state/sourceControlActions.test.ts index be47b4b9eb6fa..e4a05b8c3da40 100644 --- a/apps/web/src/state/sourceControlActions.test.ts +++ b/apps/web/src/state/sourceControlActions.test.ts @@ -19,6 +19,9 @@ vi.mock("@effect/atom-react", () => ({ useAtomValue: () => ({ operation: null, error: null, isRunning: false }), })); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("../components/sourceControlAppAccessToast", () => ({ + suggestAppAccessForRemote: () => Promise.resolve(), +})); vi.mock("./session", () => ({ useEnvironmentScope: (environmentId: string, scope: string) => state.scopes.get(environmentId)?.has(scope) === true, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 12b9aff67159b..dc21df5782468 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -31,6 +31,7 @@ import { readEnvironmentScope, useEnvironmentScope } from "./session"; import { sourceControlEnvironment } from "./sourceControl"; import { useAtomCommand } from "./use-atom-command"; import { vcsActionManager, vcsEnvironment } from "./vcs"; +import { suggestAppAccessForRemote } from "../components/sourceControlAppAccessToast"; export type SourceControlActionKind = | "init" @@ -286,13 +287,17 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ), ); } - return publishRepository({ + const result = await publishRepository({ environmentId: target.environmentId, input: { cwd: target.cwd, ...input, }, }); + if (result._tag === "Success") { + void suggestAppAccessForRemote(target.environmentId, result.value.remoteUrl); + } + return result; }; return useAction({ kind: "publishRepository", diff --git a/packages/client-runtime/src/sourceControlClients.test.ts b/packages/client-runtime/src/sourceControlClients.test.ts index 6954dd979cc8d..359da39b33b04 100644 --- a/packages/client-runtime/src/sourceControlClients.test.ts +++ b/packages/client-runtime/src/sourceControlClients.test.ts @@ -1,7 +1,7 @@ import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { sourceControlClients } from "./sourceControlClients.ts"; +import { appSignInTargetForRemote, sourceControlClients } from "./sourceControlClients.ts"; import { normalizeThreadPullRequestKey } from "./threadPullRequests.ts"; const parse = (url: string) => { @@ -366,3 +366,23 @@ describe("sourceControlClients.findByPublicDomain", () => { expect(sourceControlClients.findByPublicHost("gist.github.com")).toBeUndefined(); }); }); + +describe("appSignInTargetForRemote", () => { + it("names the repository a GitHub remote points at, in the remote's casing", () => { + for (const remote of [ + "https://github.com/octocat/Hello-World.git", + "git@github.com:octocat/Hello-World.git", + ]) { + expect(appSignInTargetForRemote(remote)?.input).toEqual({ + provider: "github", + host: "github.com", + repository: "octocat/Hello-World", + }); + } + }); + + it("skips hosts the app is not registered on", () => { + expect(appSignInTargetForRemote("https://ghe.acme.test/acme/web.git")).toBeNull(); + expect(appSignInTargetForRemote("https://gitlab.com/acme/web.git")).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/sourceControlClients.ts b/packages/client-runtime/src/sourceControlClients.ts index c7db3db4afc56..21cb1530aae30 100644 --- a/packages/client-runtime/src/sourceControlClients.ts +++ b/packages/client-runtime/src/sourceControlClients.ts @@ -2,8 +2,11 @@ import { builtInSourceControlDefinitions } from "@t3tools/source-control-definit import { HostCredentialSettings, readSourceControlHostSettings, + type SourceControlAppSignIn, + type SourceControlDefinition, } from "@t3tools/source-control-core/shared/definition"; -import type { ServerSettings } from "@t3tools/contracts"; +import type { ServerSettings, SourceControlAppAccessInput } from "@t3tools/contracts"; +import { parseGitRemoteRepository } from "@t3tools/shared/git"; export { isMonochromeSourceControlIcon, @@ -14,6 +17,7 @@ export { type HostCredentialSettings, type ParsedChangeRequestUrl, type ReferenceAutolink, + type SourceControlAppSignIn, type SourceControlDefinition, type SourceControlHostCredentials, type SourceControlIcon, @@ -33,3 +37,30 @@ export function readHostCredentialSettings( export function capitalizeTerm(term: string): string { return term.charAt(0).toUpperCase() + term.slice(1); } + +/** The repository a remote names, when its host has an app sign-in that could reach it. */ +export function appSignInTargetForRemote(remoteUrl: string): { + readonly definition: SourceControlDefinition; + readonly appSignIn: SourceControlAppSignIn; + readonly input: SourceControlAppAccessInput; +} | null { + const parsed = parseGitRemoteRepository(remoteUrl); + const provider = sourceControlClients.recognizeRemote(remoteUrl); + const definition = provider ? sourceControlClients.find(provider.kind) : undefined; + const appSignIn = definition?.credentials?.appSignIn; + if (parsed === null || definition === undefined || appSignIn === undefined) return null; + if (!appSignIn.hosts.includes(parsed.host)) return null; + // The parser lowercases; keep the remote's own casing for display when it names the same path. + const asWritten = remoteUrl + .trim() + .replace(/\.git\/?$/i, "") + .split(/[/:]/) + .slice(-2) + .join("/"); + const repository = asWritten.toLowerCase() === parsed.path ? asWritten : parsed.path; + return { + definition, + appSignIn, + input: { provider: definition.kind, host: parsed.host, repository }, + }; +} diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index d63fae293a54c..dad3ad6a1c247 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -21,6 +21,15 @@ export function createSourceControlEnvironmentAtoms( label: "environment-data:server:source-control-discovery", tag: WS_METHODS.serverDiscoverSourceControl, }), + appAccess: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:app-access", + tag: WS_METHODS.sourceControlAppAccess, + }), + // One check, as after cloning, where no view keeps the query mounted. + checkAppAccess: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:check-app-access", + tag: WS_METHODS.sourceControlAppAccess, + }), repository: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:source-control:repository", tag: WS_METHODS.sourceControlLookupRepository, @@ -56,6 +65,19 @@ export function createSourceControlEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + // Unscheduled: a pending sign-in waits on the user for minutes and must not hold up clones. + startSignIn: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:sign-in-start", + tag: WS_METHODS.sourceControlSignInStart, + }), + waitForSignIn: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:sign-in-wait", + tag: WS_METHODS.sourceControlSignInWait, + }), + cancelSignIn: createEnvironmentRpcCommand(runtime, { + label: "environment-data:source-control:sign-in-cancel", + tag: WS_METHODS.sourceControlSignInCancel, + }), projectClones: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:source-control:project-clones", tag: WS_METHODS.subscribeProjectClones, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index af9407760046e..3cd80feea6b73 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -35,6 +35,10 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, + // Signing in replaces the server's GitHub credential, as saving a token in Settings does. + [WS_METHODS.sourceControlSignInStart]: AuthSettingsWriteScope, + [WS_METHODS.sourceControlSignInWait]: AuthSettingsWriteScope, + [WS_METHODS.sourceControlSignInCancel]: AuthSettingsWriteScope, [WS_METHODS.projectCloneStart]: AuthSourceControlWriteScope, [WS_METHODS.projectCloneCancel]: AuthSourceControlWriteScope, [WS_METHODS.projectCloneRetry]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 2122869c09715..e4823f30272bd 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -382,6 +382,12 @@ import { SourceControlRepositoryError, SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, + SourceControlAppAccess, + SourceControlAppAccessInput, + SourceControlSignInError, + SourceControlSignInInput, + SourceControlSignInPrompt, + SourceControlSignInResult, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; @@ -567,6 +573,10 @@ export const WS_METHODS = { sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + sourceControlSignInStart: "sourceControl.signInStart", + sourceControlSignInWait: "sourceControl.signInWait", + sourceControlSignInCancel: "sourceControl.signInCancel", + sourceControlAppAccess: "sourceControl.appAccess", projectCloneStart: "projectClone.start", projectCloneCancel: "projectClone.cancel", projectCloneRetry: "projectClone.retry", @@ -1182,6 +1192,33 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); +// Starts a device-flow sign-in the server finishes on its own, so a client can leave meanwhile. +const WsSourceControlSignInStartRpc = Rpc.make(WS_METHODS.sourceControlSignInStart, { + payload: SourceControlSignInInput, + success: SourceControlSignInPrompt, + error: Schema.Union([SourceControlSignInError, EnvironmentAuthorizationError]), +}); + +// Resolves when the sign-in `signInStart` began ends, however long the user takes. +const WsSourceControlSignInWaitRpc = Rpc.make(WS_METHODS.sourceControlSignInWait, { + payload: SourceControlSignInInput, + success: SourceControlSignInResult, + error: Schema.Union([SourceControlSignInError, EnvironmentAuthorizationError]), +}); + +// Ends the sign-in `signInStart` began, so approving the code afterwards signs nothing in. +const WsSourceControlSignInCancelRpc = Rpc.make(WS_METHODS.sourceControlSignInCancel, { + payload: SourceControlSignInInput, + success: Schema.Void, + error: Schema.Union([SourceControlSignInError, EnvironmentAuthorizationError]), +}); + +const WsSourceControlAppAccessRpc = Rpc.make(WS_METHODS.sourceControlAppAccess, { + payload: SourceControlAppAccessInput, + success: SourceControlAppAccess, + error: EnvironmentAuthorizationError, +}); + // Returns when the project exists and cloning starts; progress uses the subscription. const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, { payload: ProjectCloneStartInput, @@ -1981,6 +2018,10 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, + WsSourceControlSignInStartRpc, + WsSourceControlSignInWaitRpc, + WsSourceControlSignInCancelRpc, + WsSourceControlAppAccessRpc, WsProjectCloneStartRpc, WsProjectCloneCancelRpc, WsProjectCloneRetryRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index e0713b2425a29..6ffb4b97066f2 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -155,6 +155,8 @@ export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, ...SourceControlDiscoverySharedFields, auth: SourceControlProviderAuth, + /** The server is configured to sign in through the host's app; clients offer it only then. */ + appSignIn: Schema.optionalKey(Schema.Boolean), }); export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; @@ -182,6 +184,63 @@ export class SourceControlProviderError extends Schema.TaggedError()( + "SourceControlSignInError", + { + provider: SourceControlProviderKind, + host: Schema.String, + reason: SourceControlSignInFailure, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} + export class SourceControlRepositoryError extends Schema.TaggedError()( "SourceControlRepositoryError", { diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index ce1a72c1aedf6..b300f14afb0cb 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -268,7 +268,8 @@ export interface ProviderRepositoryRef { export interface PullRequestProviderApi { readonly withVerifiedCredential?: ( - input: { readonly cwd: string; readonly host: string }, + /** `repository` lets a host pick the credential that can reach it. */ + input: { readonly cwd: string; readonly host: string; readonly repository?: string }, use: (identity: { readonly accountId: string; readonly viewer: string; diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index eb68cc4730c80..79efb54b26a5d 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -2,6 +2,7 @@ import type { GitCommandError, ServerSettings, ServerSettingsError, + ServerSettingsPatch, VcsError, VcsListRemotesResult, VcsProcessExitFailureKind, @@ -67,6 +68,10 @@ export class SourceControlHost extends Context.Service< readonly settings: { /** Read fresh on each call to pick up credential changes. */ readonly get: Effect.Effect; + /** For credentials the host obtains itself, such as a sign-in it refreshes. */ + readonly update: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; }; readonly process: { readonly run: ( diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index 81d3c03538885..c25179fa2d440 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -1,4 +1,10 @@ -import type { SourceControlProviderKind } from "@t3tools/contracts"; +import type { + SourceControlAppAccess, + SourceControlProviderKind, + SourceControlSignInError, + SourceControlSignInPrompt, + SourceControlSignInResult, +} from "@t3tools/contracts"; import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; @@ -21,6 +27,25 @@ export interface SourceControlMediaRequest { readonly headers: Readonly>; } +/** The server side of a definition's `credentials.appSignIn`. */ +export interface SourceControlSignIn { + /** Replaces any sign-in already pending for `host`; the driver finishes it on its own. */ + readonly start: ( + host: string, + ) => Effect.Effect; + /** The outcome of the latest sign-in `start` began for `host`, waiting for it if pending. */ + readonly wait: ( + host: string, + ) => Effect.Effect; + /** Ends the sign-in pending for `host`; its `wait` fails as cancelled. */ + readonly cancel: (host: string) => Effect.Effect; + /** Whether the sign-in reaches `owner/name`, so clients can suggest installing the app there. */ + readonly access: ( + host: string, + repository: string, + ) => Effect.Effect; +} + export interface SourceControlDriverInstance { readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"]; readonly discovery: SourceControlProviderDiscoverySpec; @@ -37,6 +62,7 @@ export interface SourceControlDriverInstance { HttpClientError.HttpClientError, Scope.Scope >; + readonly signIn?: SourceControlSignIn; } /** What the server gives every driver. A driver builds anything else it needs itself. */ diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 6a9f3d23bc6f6..6f99525260129 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -166,6 +166,23 @@ export interface SourceControlHostCredentials { /** What a token must be granted, completing "Give it …". */ readonly tokenAccess: string; readonly newTokenUrl: (host: string) => string; + /** Signing in through the forge's own app instead of a token or CLI login. */ + readonly appSignIn?: SourceControlAppSignIn; +} + +/** + * A forge app the server signs in to with a device code, on the hosts it is registered on. The + * session it keeps is the `signIns` setting, which takes precedence over everything but a saved + * token. + */ +export interface SourceControlAppSignIn { + /** The app's name on the forge, as the consent screen shows it. */ + readonly appName: string; + readonly hosts: ReadonlyArray; + /** Where an account or organization installs the app so it can reach its repositories. */ + readonly installUrl: string; + /** Where the user revokes the app's access entirely. */ + readonly revokeUrl: string; } export const SourceControlHostName = TrimmedNonEmptyString.pipe( @@ -189,6 +206,12 @@ export const hostCredentialSettingsFields = { secretSetting, Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + /** The serialized session of an `appSignIn`, owned by the server's driver. */ + signIns: Schema.Record(SourceControlHostName, TrimmedString).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + secretSetting, + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), }; export const HostCredentialSettings = Schema.Struct(hostCredentialSettingsFields); diff --git a/packages/source-control-github/scripts/measure-pr-preview.ts b/packages/source-control-github/scripts/measure-pr-preview.ts index d1c97074e16a2..6f03586958cca 100644 --- a/packages/source-control-github/scripts/measure-pr-preview.ts +++ b/packages/source-control-github/scripts/measure-pr-preview.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as GitHubAppAccess from "../src/server/GitHubAppAccess.ts"; import * as GitHubPullRequestApi from "../src/server/GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "../src/server/GitHubPullRequestProvider.ts"; import * as GitHubRest from "../src/server/GitHubRest.ts"; @@ -99,6 +100,7 @@ const sourceControlHost = Layer.unwrap( ).pipe(Layer.provideMerge(NodeServices.layer)); const services = GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubAppAccess.layer), Layer.provideMerge(GitHubRest.layerWithDependencies), Layer.provideMerge(SourceControlRateLimit.layer), Layer.provideMerge(measuredHttp), diff --git a/packages/source-control-github/src/server/GitHubAppAccess.ts b/packages/source-control-github/src/server/GitHubAppAccess.ts new file mode 100644 index 0000000000000..39aa08c5e9ccf --- /dev/null +++ b/packages/source-control-github/src/server/GitHubAppAccess.ts @@ -0,0 +1,216 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; +import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; +import * as GitHubRest from "./GitHubRest.ts"; + +const Installations = Schema.fromJsonString( + Schema.Struct({ + installations: Schema.Array( + Schema.Struct({ + id: Schema.Number, + repository_selection: Schema.String, + account: Schema.NullOr(Schema.Struct({ login: Schema.String })), + }), + ), + }), +); + +const InstallationRepositories = Schema.fromJsonString( + Schema.Struct({ repositories: Schema.Array(Schema.Struct({ full_name: Schema.String })) }), +); + +const Viewer = Schema.fromJsonString(Schema.Struct({ login: Schema.String })); + +const PAGE_SIZE = 100; +const MAX_PAGES = 10; +// Installing the app on another account should show up soon after the user comes back. +const COVERAGE_TTL = Duration.minutes(2); +// A failed read answers `covered`; keep that briefly so an outage is not re-asked per request. +const COVERAGE_FAILURE_TTL = Duration.seconds(30); + +/** Whether the app sign-in reaches a repository; `signed-out` when the server is not signed in. */ +export type GitHubAppReach = "covered" | "not-covered" | "signed-out"; + +/** Accounts that installed the app for all repositories, and repositories listed one by one. */ +interface AppCoverage { + readonly accounts: ReadonlySet; + readonly repositories: ReadonlySet; +} + +export class GitHubAppAccess extends Context.Service< + GitHubAppAccess, + { + /** + * Whether an installation covers `owner/name`. A check that fails answers `covered`, so a + * GitHub hiccup does not nag the user. + */ + readonly reach: (host: string, repository: string) => Effect.Effect; + /** The login the app sign-in acts as. */ + readonly viewer: (host: string) => Effect.Effect; + /** + * The credential for work pinned to one repository: the app's only where an installation is + * confirmed to cover it, the user's own otherwise when there is one. + */ + readonly credentialFor: ( + host: string, + repository: string, + ) => Effect.Effect< + GitHubRequestPolicy.GitHubHeldCredential, + GitHubRequestPolicy.GitHubApiError + >; + } +>()("@t3tools/source-control-github/server/GitHubAppAccess") {} + +/** Callers may name a repository as a URL, `host/owner/name` or `owner/name.git`. */ +function ownerAndName(repository: string): string { + return repository + .trim() + .toLowerCase() + .replace(/\.git\/?$/, "") + .split(/[/:]/) + .filter((segment) => segment.length > 0) + .slice(-2) + .join("/"); +} + +/** @public */ +export const make = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + const appSignIn = yield* GitHubAppSignIn.GitHubAppSignIn; + + /** Reads `path` as the app sign-in, whatever credential outranks it elsewhere. */ + const readAsApp = Effect.fn("GitHubAppAccess.readAsApp")(function* ( + host: string, + operation: string, + path: string, + schema: Schema.Codec, + ) { + const app = yield* api.appCredential(host); + if (Option.isNone(app)) return Option.none(); + const response = yield* api.rest({ host, operation, path, allowReserve: true }).pipe( + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host, + token: app.value.token, + credentialFingerprint: app.value.fingerprint, + }), + ); + return Option.some(yield* Schema.decodeEffect(schema)(response.body)); + }); + + const coverageOf = Effect.fn("GitHubAppAccess.coverageOf")(function* (host: string) { + const installations: Array<{ + readonly id: number; + readonly repository_selection: string; + readonly account: { readonly login: string } | null; + }> = []; + for (let page = 1; page <= MAX_PAGES; page++) { + const listed = yield* readAsApp( + host, + "appInstallations", + `user/installations?per_page=${PAGE_SIZE}&page=${page}`, + Installations, + ); + if (Option.isNone(listed)) return null; + installations.push(...listed.value.installations); + if (listed.value.installations.length < PAGE_SIZE) break; + } + const accounts = new Set(); + const repositories = new Set(); + for (const installation of installations) { + const login = installation.account?.login.toLowerCase(); + if (login === undefined) continue; + if (installation.repository_selection === "all") { + accounts.add(login); + continue; + } + for (let page = 1; ; page++) { + // Past the cap the list is cut short; count the whole account as reached rather than nag. + if (page > MAX_PAGES) { + accounts.add(login); + break; + } + const listed = yield* readAsApp( + host, + "appInstallationRepositories", + `user/installations/${installation.id}/repositories?per_page=${PAGE_SIZE}&page=${page}`, + InstallationRepositories, + ); + if (Option.isNone(listed)) return null; + for (const repository of listed.value.repositories) { + repositories.add(repository.full_name.toLowerCase()); + } + if (listed.value.repositories.length < PAGE_SIZE) break; + } + } + return { accounts, repositories } satisfies AppCoverage; + }); + + const coverage = yield* Cache.makeWith(coverageOf, { + capacity: 8, + timeToLive: (exit) => (Exit.isSuccess(exit) ? COVERAGE_TTL : COVERAGE_FAILURE_TTL), + }); + + /** `unknown` when GitHub could not list the installations just now. */ + const coverageFor = Effect.fn("GitHubAppAccess.coverageFor")(function* ( + rawHost: string, + rawRepository: string, + ) { + const host = rawHost.trim().toLowerCase(); + if (!appSignIn.enabled) return "signed-out" as const; + const known = yield* Cache.get(coverage, host).pipe(Effect.option); + if (Option.isNone(known)) return "unknown" as const; + if (known.value === null) return "signed-out" as const; + const repository = ownerAndName(rawRepository); + const owner = repository.split("/")[0] ?? ""; + return known.value.accounts.has(owner) || known.value.repositories.has(repository) + ? ("covered" as const) + : ("not-covered" as const); + }); + + const reach: GitHubAppAccess["Service"]["reach"] = (host, repository) => + coverageFor(host, repository).pipe( + Effect.map((answer) => (answer === "unknown" ? "covered" : answer)), + ); + + const viewer: GitHubAppAccess["Service"]["viewer"] = Effect.fn("GitHubAppAccess.viewer")( + function* (rawHost) { + const host = rawHost.trim().toLowerCase(); + // A new sign-in can reach different installations than the cached answer. + yield* Cache.invalidate(coverage, host); + const login = yield* readAsApp(host, "appViewer", "user", Viewer).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(Option.none<{ readonly login: string }>()), + }), + ); + if (Option.isNone(login)) { + return yield* new GitHubRequestPolicy.GitHubApiAuthenticationError({ + host, + operation: "appViewer", + }); + } + return login.value.login; + }, + ); + + const credentialFor: GitHubAppAccess["Service"]["credentialFor"] = Effect.fn( + "GitHubAppAccess.credentialFor", + )(function* (host, repository) { + // Pinned work cannot be handed on, so only confirmed app access keeps the app credential. + if ((yield* coverageFor(host, repository)) === "covered") return yield* api.credential(host); + return yield* api + .credential(host, { personal: true }) + .pipe(Effect.catch(() => api.credential(host))); + }); + + return GitHubAppAccess.of({ reach, viewer, credentialFor }); +}); + +export const layer = Layer.effect(GitHubAppAccess, make); diff --git a/packages/source-control-github/src/server/GitHubAppSignIn.test.ts b/packages/source-control-github/src/server/GitHubAppSignIn.test.ts new file mode 100644 index 0000000000000..2f78a3aab83e9 --- /dev/null +++ b/packages/source-control-github/src/server/GitHubAppSignIn.test.ts @@ -0,0 +1,593 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; + +import { DEFAULT_SERVER_SETTINGS, ServerSettingsError } from "@t3tools/contracts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; + +import * as GitHubClient from "../shared/definition.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; +import * as GitHubRest from "./GitHubRest.ts"; + +type Reply = { readonly status?: number; readonly body: unknown }; +interface Sent { + readonly host: string; + readonly path: string; + readonly form: URLSearchParams; + /** The bearer token, if any. */ + readonly token: string | undefined; +} + +const HOUR = 3_600_000; + +const session = (overrides: Record = {}) => + JSON.stringify({ + accessToken: "ghu_current", + accessTokenExpiresAt: 8 * HOUR, + refreshToken: "ghr_current", + refreshTokenExpiresAt: 4_000 * HOUR, + ...overrides, + }); + +/** The whole GitHub request stack over a fake GitHub that `handle` answers. */ +function harness(options: { + readonly handle: (sent: Sent) => Reply; + readonly signIns?: Record; + readonly tokens?: Record; + /** Without one, the server has no `gh` login to fall back to. */ + readonly ghSignedIn?: boolean; + /** Makes every settings write fail, as a full disk would. */ + readonly settingsWritesFail?: boolean; + /** Holds a request open until it completes, to interleave other work with it. */ + readonly gate?: (sent: Sent) => Effect.Effect; +}) { + const requests: Array = []; + const http = HttpClient.make((request) => { + const url = new URL(request.url); + const sent: Sent = { + host: url.host, + path: url.pathname, + form: new URLSearchParams( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", + ), + token: request.headers["authorization"]?.replace(/^Bearer /, ""), + }; + return Effect.suspend(() => options.gate?.(sent) ?? Effect.void).pipe( + Effect.map(() => { + requests.push(sent); + const reply = options.handle(sent); + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(reply.body), { + status: reply.status ?? 200, + headers: { "content-type": "application/json" }, + }), + ); + }), + ); + }); + const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: options.ghSignedIn === false ? "" : "gh-token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + const host = TestSourceControlHost.layer({ + settings: applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { + github: { signIns: options.signIns ?? {}, tokens: options.tokens ?? {} }, + }, + }), + process: { run }, + }); + const failingWrites = Layer.effect( + SourceControlHost.SourceControlHost, + Effect.map(Effect.service(SourceControlHost.SourceControlHost), (service) => ({ + ...service, + settings: { + ...service.settings, + update: () => + Effect.fail( + new ServerSettingsError({ settingsPath: "settings.json", operation: "write-file" }), + ), + }, + })), + ).pipe(Layer.provideMerge(host)); + const layer = GitHubAppAccess.layer.pipe( + Layer.provideMerge(GitHubRest.layerWithDependencies), + Layer.provideMerge(SourceControlRateLimit.layer), + Layer.provideMerge(options.settingsWritesFail === true ? failingWrites : host), + Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + ); + return { layer, requests }; +} + +const savedSignIns = Effect.gen(function* () { + const host = yield* SourceControlHost.SourceControlHost; + const settings = yield* host.settings.get; + return readSourceControlHostSettings(GitHubClient.settings, settings.sourceControlHosts.github) + .signIns; +}); + +/** A REST read through the request policy, answering with the token it went out with. */ +const readUser = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + return yield* api.rest({ host: "github.com", operation: "test", path: "user" }); +}); + +const deviceCode = { + device_code: "device-1", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, +}; + +const installedOn = (sent: Sent): Reply => + sent.path === "/user/installations" + ? { + body: { + installations: [ + { id: 1, repository_selection: "all", account: { login: "pingdotgg" } }, + { id: 2, repository_selection: "selected", account: { login: "acme" } }, + ], + }, + } + : sent.path === "/user/installations/2/repositories" + ? { body: { repositories: [{ full_name: "acme/one" }] } } + : { status: 404, body: {} }; + +beforeEach(() => { + for (const name of ["GH_TOKEN", "GITHUB_TOKEN"]) vi.stubEnv(name, ""); + vi.stubEnv(GitHubAppSignIn.CLIENT_ID_VARIABLE, "Iv-test-client"); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("GitHubAppSignIn", () => { + it.effect("finishes a device-flow sign-in and saves the session", () => { + const answers = [ + { error: "authorization_pending" }, + { error: "slow_down", interval: 10 }, + { + access_token: "ghu_new", + expires_in: 28_800, + refresh_token: "ghr_new", + refresh_token_expires_in: 15_897_600, + }, + ]; + const { layer, requests } = harness({ + handle: (sent) => + sent.path === "/login/device/code" ? { body: deviceCode } : { body: answers.shift() }, + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect(yield* signIn.start("github.com")).toEqual({ + userCode: "ABCD-1234", + verificationUri: "https://github.com/login/device", + expiresAt: 900_000, + }); + const waiting = yield* Effect.forkChild(signIn.wait("github.com")); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(waiting); + + // `slow_down` stretches the interval: polls at 5s, 10s, then 20s. + const polls = requests.filter((sent) => sent.path === "/login/oauth/access_token"); + expect(polls).toHaveLength(3); + expect(polls[0]?.form.get("client_id")).toBe("Iv-test-client"); + expect(polls[0]?.form.get("device_code")).toBe("device-1"); + expect(JSON.parse((yield* savedSignIns)["github.com"] ?? "")).toEqual({ + accessToken: "ghu_new", + accessTokenExpiresAt: 20_000 + 28_800_000, + refreshToken: "ghr_new", + refreshTokenExpiresAt: 20_000 + 15_897_600_000, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports a sign-in cancelled on GitHub and saves nothing", () => { + const { layer } = harness({ + handle: (sent) => + sent.path === "/login/device/code" + ? { body: deviceCode } + : { body: { error: "access_denied" } }, + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + yield* signIn.start("github.com"); + const waiting = yield* Effect.forkChild(Effect.flip(signIn.wait("github.com"))); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(waiting)).reason).toBe("denied"); + expect(yield* savedSignIns).toEqual({}); + }).pipe(Effect.provide(layer)); + }); + + it.effect("expires a code nobody entered", () => { + const { layer } = harness({ + handle: (sent) => + sent.path === "/login/device/code" + ? { body: { ...deviceCode, expires_in: 12 } } + : { body: { error: "authorization_pending" } }, + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + yield* signIn.start("github.com"); + const waiting = yield* Effect.forkChild(Effect.flip(signIn.wait("github.com"))); + yield* TestClock.adjust("15 seconds"); + expect((yield* Fiber.join(waiting)).reason).toBe("expired"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("cancels a sign-in so approving the code afterwards saves nothing", () => { + const { layer, requests } = harness({ + handle: (sent) => + sent.path === "/login/device/code" + ? { body: deviceCode } + : { body: { access_token: "ghu_new", expires_in: 28_800 } }, + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + yield* signIn.start("github.com"); + const waiting = yield* Effect.forkChild(Effect.flip(signIn.wait("github.com"))); + yield* signIn.cancel("github.com"); + yield* TestClock.adjust("10 seconds"); + expect((yield* Fiber.join(waiting)).reason).toBe("cancelled"); + expect(requests.map((sent) => sent.path)).toEqual(["/login/device/code"]); + expect(yield* savedSignIns).toEqual({}); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses hosts the app is not registered on", () => { + const { layer, requests } = harness({ handle: () => ({ body: {} }) }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect((yield* Effect.flip(signIn.start("ghe.acme.test"))).reason).toBe("unsupported"); + expect((yield* Effect.flip(signIn.wait("github.com"))).reason).toBe("not-started"); + expect(requests).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does nothing with the app when the server has no client ID", () => { + vi.stubEnv(GitHubAppSignIn.CLIENT_ID_VARIABLE, ""); + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + handle: () => ({ body: { login: "octocat" } }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect(signIn.enabled).toBe(false); + expect((yield* Effect.flip(signIn.start("github.com"))).reason).toBe("unsupported"); + expect(yield* (yield* GitHubAppAccess.GitHubAppAccess).reach("github.com", "acme/one")).toBe( + "signed-out", + ); + // A session saved while it was on stays put but is not used. + yield* readUser; + expect(requests.map((sent) => sent.token)).toEqual(["gh-token"]); + expect(Object.keys(yield* savedSignIns)).toEqual(["github.com"]); + }).pipe(Effect.provide(layer)); + }); + + describe("refresh", () => { + const refreshed = { + access_token: "ghu_refreshed", + expires_in: 28_800, + refresh_token: "ghr_refreshed", + refresh_token_expires_in: 15_897_600, + }; + + it.effect("refreshes an expiring token once for concurrent requests", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 3 * 60_000 }) }, + handle: () => ({ body: refreshed }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + const tokens = yield* Effect.all([signIn.token("github.com"), signIn.token("github.com")], { + concurrency: "unbounded", + }); + expect(tokens).toEqual([Option.some("ghu_refreshed"), Option.some("ghu_refreshed")]); + expect(requests).toHaveLength(1); + expect(requests[0]?.form.get("grant_type")).toBe("refresh_token"); + expect(requests[0]?.form.get("refresh_token")).toBe("ghr_current"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refreshes a token GitHub refused, and only that one", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + handle: () => ({ body: refreshed }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + yield* signIn.invalidate("github.com", "gh-token"); + expect(yield* signIn.token("github.com")).toEqual(Option.some("ghu_current")); + yield* signIn.invalidate("github.com", "ghu_current"); + expect(yield* signIn.token("github.com")).toEqual(Option.some("ghu_refreshed")); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("signs out when GitHub refuses the refresh token", () => { + const { layer } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 0 }) }, + handle: () => ({ body: { error: "bad_refresh_token" } }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect(yield* signIn.token("github.com")).toEqual(Option.none()); + expect(yield* savedSignIns).toEqual({}); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the session when a refresh fails for another reason", () => { + const { layer } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 0 }) }, + handle: () => ({ body: { error: "unauthorized_client" } }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect((yield* Effect.flip(signIn.token("github.com")))._tag).toBe("GitHubAppSessionError"); + expect(Object.keys(yield* savedSignIns)).toEqual(["github.com"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("serves the still-valid token when a refresh ahead of expiry fails", () => { + const { layer } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 3 * 60_000 }) }, + handle: () => ({ status: 502, body: {} }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + expect(yield* signIn.token("github.com")).toEqual(Option.some("ghu_current")); + }).pipe(Effect.provide(layer)); + }); + + it.effect("serves a refreshed token GitHub issued even when saving it fails", () => { + const { layer } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 3 * 60_000 }) }, + settingsWritesFail: true, + handle: () => ({ body: refreshed }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + const pending = yield* Effect.forkChild(signIn.token("github.com")); + yield* TestClock.adjust("1 second"); + expect(yield* Fiber.join(pending)).toEqual(Option.some("ghu_refreshed")); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not bring a session back that was signed out while it refreshed", () => { + const released = Deferred.makeUnsafe(); + const requested = Deferred.makeUnsafe(); + const { layer } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 0 }) }, + gate: () => + Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(released))), + handle: () => ({ body: refreshed }), + }); + return Effect.gen(function* () { + const signIn = yield* GitHubAppSignIn.GitHubAppSignIn; + const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; + const refreshing = yield* Effect.forkChild(signIn.token("github.com")); + yield* Deferred.await(requested); + yield* settings.update({ sourceControlHosts: { github: { signIns: {} } } }); + yield* Deferred.succeed(released, undefined); + expect(yield* Fiber.join(refreshing)).toEqual(Option.none()); + expect(yield* savedSignIns).toEqual({}); + }).pipe(Effect.provide(layer)); + }); + }); +}); + +describe("GitHub requests with the app sign-in", () => { + const answerUser = (sent: Sent): Reply => ({ body: { login: sent.token ?? "anonymous" } }); + + it.effect("uses the app before GH_TOKEN and gh, and a saved token before the app", () => { + vi.stubEnv("GH_TOKEN", "from-env"); + const signedIn = harness({ signIns: { "github.com": session() }, handle: answerUser }); + const withToken = harness({ + signIns: { "github.com": session() }, + tokens: { "github.com": "saved-pat" }, + handle: answerUser, + }); + return Effect.gen(function* () { + yield* readUser.pipe(Effect.provide(signedIn.layer)); + yield* readUser.pipe(Effect.provide(withToken.layer)); + expect(signedIn.requests.map((sent) => sent.token)).toEqual(["ghu_current"]); + expect(withToken.requests.map((sent) => sent.token)).toEqual(["saved-pat"]); + }); + }); + + it.effect("asks again with the user's own credential when the app gets not found", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + handle: (sent) => + sent.token === "ghu_current" ? { status: 404, body: {} } : answerUser(sent), + }); + return Effect.gen(function* () { + const response = yield* readUser; + expect(JSON.parse(response.body)).toEqual({ login: "gh-token" }); + expect(requests.map((sent) => sent.token)).toEqual(["ghu_current", "gh-token"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("asks again when GitHub says the resource is not accessible by integration", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + handle: (sent) => + sent.token === "ghu_current" + ? { status: 403, body: { message: "Resource not accessible by integration" } } + : answerUser(sent), + }); + return Effect.gen(function* () { + yield* readUser; + expect(requests.map((sent) => sent.token)).toEqual(["ghu_current", "gh-token"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the app's answer when there is no other credential", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + ghSignedIn: false, + handle: () => ({ status: 404, body: {} }), + }); + return Effect.gen(function* () { + expect((yield* Effect.flip(readUser))._tag).toBe("GitHubApiNotFoundError"); + expect(requests.map((sent) => sent.token)).toEqual(["ghu_current"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not ask again after the user's own credential is refused", () => { + const { layer, requests } = harness({ handle: () => ({ status: 404, body: {} }) }); + return Effect.gen(function* () { + expect((yield* Effect.flip(readUser))._tag).toBe("GitHubApiNotFoundError"); + expect(requests.map((sent) => sent.token)).toEqual(["gh-token"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses the gh login while the app session cannot refresh, and keeps the session", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session({ accessTokenExpiresAt: 0 }) }, + handle: (sent) => (sent.host === "github.com" ? { status: 502, body: {} } : answerUser(sent)), + }); + return Effect.gen(function* () { + yield* readUser; + expect(requests.filter((sent) => sent.path === "/user").map((sent) => sent.token)).toEqual([ + "gh-token", + ]); + expect(Object.keys(yield* savedSignIns)).toEqual(["github.com"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refreshes the app token after GitHub rejects it", () => { + let rejected = false; + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + handle: (sent) => { + if (sent.path === "/login/oauth/access_token") { + return { body: { access_token: "ghu_refreshed", expires_in: 28_800 } }; + } + if (sent.token === "ghu_current" && !rejected) { + rejected = true; + return { status: 401, body: {} }; + } + return answerUser(sent); + }, + }); + return Effect.gen(function* () { + expect((yield* Effect.flip(readUser))._tag).toBe("GitHubApiAuthenticationError"); + yield* readUser; + expect(requests.map((sent) => sent.token ?? sent.path)).toEqual([ + "ghu_current", + "/login/oauth/access_token", + "ghu_refreshed", + ]); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubAppAccess", () => { + it.effect("reads the installations as the app, even with a saved token", () => { + const { layer, requests } = harness({ + signIns: { "github.com": session() }, + tokens: { "github.com": "saved-pat" }, + handle: installedOn, + }); + return Effect.gen(function* () { + const access = yield* GitHubAppAccess.GitHubAppAccess; + expect(yield* access.reach("github.com", "pingdotgg/t3code")).toBe("covered"); + expect(yield* access.reach("github.com", "acme/one")).toBe("covered"); + expect(yield* access.reach("github.com", "acme/two")).toBe("not-covered"); + expect(yield* access.reach("github.com", "https://github.com/Acme/One.git")).toBe("covered"); + expect(new Set(requests.map((sent) => sent.token))).toEqual(new Set(["ghu_current"])); + // One read of the installations serves every repository until it goes stale. + expect(requests.filter((sent) => sent.path === "/user/installations")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("answers signed out without a session, and covered while GitHub fails", () => { + const signedOut = harness({ handle: installedOn }); + const failing = harness({ + signIns: { "github.com": session() }, + handle: () => ({ status: 502, body: {} }), + }); + return Effect.gen(function* () { + expect( + yield* Effect.flatMap(Effect.service(GitHubAppAccess.GitHubAppAccess), (access) => + access.reach("github.com", "acme/one"), + ).pipe(Effect.provide(signedOut.layer)), + ).toBe("signed-out"); + const answers = yield* Effect.gen(function* () { + const access = yield* GitHubAppAccess.GitHubAppAccess; + return [ + yield* access.reach("github.com", "acme/one"), + yield* access.reach("github.com", "acme/two"), + ]; + }).pipe(Effect.provide(failing.layer)); + expect(answers).toEqual(["covered", "covered"]); + // A failed read is kept briefly instead of asked again per request. + expect(failing.requests).toHaveLength(1); + }); + }); + + it.effect("gives work on a repository the app cannot reach the user's credential", () => { + const { layer } = harness({ signIns: { "github.com": session() }, handle: installedOn }); + return Effect.gen(function* () { + const access = yield* GitHubAppAccess.GitHubAppAccess; + const tokenFor = (repository: string) => + access + .credentialFor("github.com", repository) + .pipe(Effect.map((held) => Redacted.value(held.token))); + expect(yield* tokenFor("pingdotgg/t3code")).toBe("ghu_current"); + expect(yield* tokenFor("someone/else")).toBe("gh-token"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("pins the user's credential while the app's reach is unknown", () => { + const failing = (sent: Sent): Reply => + sent.path.startsWith("/user/installations") ? { status: 502, body: {} } : { body: {} }; + const withGh = harness({ signIns: { "github.com": session() }, handle: failing }); + const appOnly = harness({ + signIns: { "github.com": session() }, + ghSignedIn: false, + handle: failing, + }); + const tokenFor = Effect.flatMap(Effect.service(GitHubAppAccess.GitHubAppAccess), (access) => + access + .credentialFor("github.com", "pingdotgg/t3code") + .pipe(Effect.map((held) => Redacted.value(held.token))), + ); + return Effect.gen(function* () { + expect(yield* tokenFor.pipe(Effect.provide(withGh.layer))).toBe("gh-token"); + expect(yield* tokenFor.pipe(Effect.provide(appOnly.layer))).toBe("ghu_current"); + }); + }); + + it.effect("names the account the app sign-in acts as", () => { + const { layer } = harness({ + signIns: { "github.com": session() }, + tokens: { "github.com": "saved-pat" }, + handle: (sent) => ({ body: { login: sent.token === "ghu_current" ? "octocat" : "other" } }), + }); + return Effect.gen(function* () { + expect(yield* (yield* GitHubAppAccess.GitHubAppAccess).viewer("github.com")).toBe("octocat"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/source-control-github/src/server/GitHubAppSignIn.ts b/packages/source-control-github/src/server/GitHubAppSignIn.ts new file mode 100644 index 0000000000000..e7e792068617e --- /dev/null +++ b/packages/source-control-github/src/server/GitHubAppSignIn.ts @@ -0,0 +1,467 @@ +import { + SourceControlProviderKind, + SourceControlSignInError, + type SourceControlSignInFailure, + type SourceControlSignInPrompt, +} from "@t3tools/contracts"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as GitHubClient from "../shared/definition.ts"; + +/** + * The public client ID of the T3 Code for GitHub app, which the device flow needs (no secret). + * Unset turns the app sign-in off: no app tokens, no sign-in, nothing for clients to show. + */ +export const CLIENT_ID_VARIABLE = "T3CODE_GITHUB_APP_CLIENT_ID"; + +const KIND = SourceControlProviderKind.make("github"); +const REQUEST_TIMEOUT = Duration.seconds(15); +// Refresh ahead of expiry so a token never lapses between the check and a long request. +const REFRESH_MARGIN_MS = Duration.toMillis(Duration.minutes(5)); + +const GitHubAppSession = Schema.Struct({ + accessToken: Schema.String, + /** Null when the app hands out tokens that do not expire. */ + accessTokenExpiresAt: Schema.NullOr(Schema.Number), + refreshToken: Schema.NullOr(Schema.String), + refreshTokenExpiresAt: Schema.NullOr(Schema.Number), +}); +type GitHubAppSession = typeof GitHubAppSession.Type; + +const GitHubAppSessionJson = Schema.fromJsonString(GitHubAppSession); +const decodeSession = Schema.decodeUnknownOption(GitHubAppSessionJson); +const encodeSession = Schema.encodeSync(GitHubAppSessionJson); + +// GitHub answers both token grants with HTTP 200 and puts refusals in `error`. +const TokenResponse = Schema.Struct({ + access_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + refresh_token: Schema.optional(Schema.String), + refresh_token_expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}); + +const DeviceCodeResponse = Schema.Struct({ + device_code: Schema.optional(Schema.String), + user_code: Schema.optional(Schema.String), + verification_uri: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}); + +export class GitHubAppSessionError extends Schema.TaggedError()( + "GitHubAppSessionError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not refresh the ${GitHubClient.APP_SIGN_IN.appName} sign-in for ${this.host}. Check the server's connection to GitHub, or sign in again in Settings → Source Control.`; + } +} + +export class GitHubAppSignIn extends Context.Service< + GitHubAppSignIn, + { + /** Whether the server is configured with the app's client ID. */ + readonly enabled: boolean; + /** The signed-in access token for `host`, refreshed when due; none when not signed in. */ + readonly token: (host: string) => Effect.Effect, GitHubAppSessionError>; + /** Refreshes on the next `token` when `rejected` is the token GitHub just refused. */ + readonly invalidate: (host: string, rejected: string) => Effect.Effect; + readonly start: ( + host: string, + ) => Effect.Effect; + /** Resolves once the sign-in `start` began has saved a session. */ + readonly wait: (host: string) => Effect.Effect; + readonly cancel: (host: string) => Effect.Effect; + } +>()("@t3tools/source-control-github/server/GitHubAppSignIn") {} + +interface PendingSignIn { + readonly deferred: Deferred.Deferred; + readonly fiber: Fiber.Fiber; +} + +const signInError = (host: string, reason: SourceControlSignInFailure, detail: string) => + new SourceControlSignInError({ provider: KIND, host, reason, detail }); + +/** @public */ +export const make = Effect.gen(function* () { + const sourceControlHost = yield* SourceControlHost.SourceControlHost; + const httpClient = yield* HttpClient.HttpClient; + const clientId = (yield* HostProcess.Environment)[CLIENT_ID_VARIABLE]?.trim() ?? ""; + const enabled = clientId !== ""; + const scope = yield* Effect.scope; + const refreshLock = yield* Semaphore.make(1); + const refused = yield* Ref.make>(new Set()); + const pending = yield* Ref.make>(new Map()); + + const isAppHost = (host: string) => + enabled && GitHubClient.APP_SIGN_IN.hosts.includes(host.trim().toLowerCase()); + + const readSignIns = sourceControlHost.settings.get.pipe( + Effect.map( + (settings) => + readSourceControlHostSettings( + GitHubClient.settings, + settings.sourceControlHosts[GitHubClient.definition.kind], + ).signIns, + ), + Effect.orElseSucceed((): Readonly> => ({})), + ); + + const readSession = (host: string) => + readSignIns.pipe(Effect.map((signIns) => decodeSession(signIns[host] ?? ""))); + + const writeSession = Effect.fn("GitHubAppSignIn.writeSession")(function* ( + host: string, + session: GitHubAppSession | null, + ) { + const signIns = { ...(yield* readSignIns) }; + if (session === null) delete signIns[host]; + else signIns[host] = encodeSession(session); + yield* sourceControlHost.settings.update({ + sourceControlHosts: { [GitHubClient.definition.kind]: { signIns } }, + }); + }); + + /** + * Writes `next` only while the stored session is still the one a refresh started from, so a + * sign-out or a newer sign-in during the request is not undone. + */ + const replaceSession = Effect.fn("GitHubAppSignIn.replaceSession")(function* ( + host: string, + from: GitHubAppSession, + next: GitHubAppSession | null, + ) { + const current = yield* readSession(host); + if (Option.isNone(current) || current.value.accessToken !== from.accessToken) return false; + yield* writeSession(host, next); + return true; + }); + + const postForm = ( + host: string, + path: string, + body: Record, + schema: S, + ) => + httpClient + .execute( + HttpClientRequest.post(`https://${host}${path}`).pipe( + HttpClientRequest.setHeaders({ accept: "application/json", "user-agent": "t3code" }), + HttpClientRequest.bodyUrlParams(body), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.timeout(REQUEST_TIMEOUT), + ); + + const sessionFrom = ( + now: number, + response: typeof TokenResponse.Type & { readonly access_token: string }, + ): GitHubAppSession => ({ + accessToken: response.access_token, + accessTokenExpiresAt: + response.expires_in === undefined ? null : now + response.expires_in * 1_000, + refreshToken: response.refresh_token ?? null, + refreshTokenExpiresAt: + response.refresh_token_expires_in === undefined + ? null + : now + response.refresh_token_expires_in * 1_000, + }); + + const refresh = Effect.fn("GitHubAppSignIn.refresh")(function* ( + host: string, + session: GitHubAppSession, + ) { + const now = yield* Clock.currentTimeMillis; + if ( + session.refreshToken === null || + (session.refreshTokenExpiresAt !== null && session.refreshTokenExpiresAt <= now) + ) { + yield* replaceSession(host, session, null).pipe(Effect.ignore); + return Option.none(); + } + const response = yield* postForm( + host, + "/login/oauth/access_token", + { + client_id: clientId, + grant_type: "refresh_token", + refresh_token: session.refreshToken, + }, + TokenResponse, + ).pipe(Effect.mapError((cause) => new GitHubAppSessionError({ host, cause }))); + if (response.error === "bad_refresh_token") { + // Final: the user revoked the app or the session lapsed. + yield* Effect.logWarning("GitHub refused the app sign-in refresh; signing out.", { host }); + yield* replaceSession(host, session, null).pipe(Effect.ignore); + return Option.none(); + } + if (response.access_token === undefined) { + return yield* new GitHubAppSessionError({ + host, + cause: new Error(`GitHub did not refresh the sign-in (${response.error ?? "no token"}).`), + }); + } + const next = sessionFrom(now, { + ...response, + access_token: response.access_token, + }); + // GitHub has retired the old pair, so an unsaved new one is still the only usable token. + const saved = yield* replaceSession(host, session, next).pipe( + Effect.retry({ times: 2, schedule: Schedule.spaced(Duration.millis(250)) }), + Effect.catch((cause) => + Effect.logError("Could not save the refreshed app sign-in; using it unsaved.", { + host, + cause, + }).pipe(Effect.as(true)), + ), + ); + if (saved) return Option.some(next.accessToken); + // Signed out or signed in again meanwhile: serve whatever is stored now. + return Option.map(yield* readSession(host), (current) => current.accessToken); + }); + + const isFresh = (session: GitHubAppSession, now: number) => + session.accessTokenExpiresAt === null || now < session.accessTokenExpiresAt - REFRESH_MARGIN_MS; + + const token: GitHubAppSignIn["Service"]["token"] = Effect.fn("GitHubAppSignIn.token")( + function* (rawHost) { + const host = rawHost.trim().toLowerCase(); + if (!isAppHost(host)) return Option.none(); + const session = yield* readSession(host); + if (Option.isNone(session)) return Option.none(); + const now = yield* Clock.currentTimeMillis; + if ( + isFresh(session.value, now) && + !(yield* Ref.get(refused)).has(session.value.accessToken) + ) { + return Option.some(session.value.accessToken); + } + return yield* refreshLock.withPermits(1)( + Effect.gen(function* () { + // Another request may have refreshed while this one queued. + const current = yield* readSession(host); + if (Option.isNone(current)) return Option.none(); + const refusedTokens = yield* Ref.get(refused); + const now = yield* Clock.currentTimeMillis; + if (isFresh(current.value, now) && !refusedTokens.has(current.value.accessToken)) { + return Option.some(current.value.accessToken); + } + const usable = Option.some(current.value.accessToken).pipe( + Option.filter( + (accessToken) => + !refusedTokens.has(accessToken) && + (current.value.accessTokenExpiresAt === null || + now < current.value.accessTokenExpiresAt), + ), + ); + // A refresh ahead of expiry that fails leaves the still-valid token in use. + return yield* refresh(host, current.value).pipe( + Effect.catchTags({ + GitHubAppSessionError: (error) => + Option.isSome(usable) ? Effect.succeed(usable) : Effect.fail(error), + }), + ); + }), + ); + }, + ); + + const invalidate: GitHubAppSignIn["Service"]["invalidate"] = Effect.fn( + "GitHubAppSignIn.invalidate", + )(function* (rawHost, rejected) { + const session = yield* readSession(rawHost.trim().toLowerCase()); + // A refused fallback token says nothing about the app session. + if (Option.isNone(session) || session.value.accessToken !== rejected) return; + yield* Ref.update(refused, (current) => new Set([...current, rejected])); + }); + + const poll = Effect.fn("GitHubAppSignIn.poll")(function* ( + host: string, + deviceCode: string, + expiresAt: number, + initialInterval: number, + ) { + let interval = initialInterval; + while (true) { + yield* Effect.sleep(Duration.seconds(interval)); + const now = yield* Clock.currentTimeMillis; + if (now >= expiresAt) { + return yield* signInError(host, "expired", "The sign-in code expired. Start again."); + } + const answer = yield* postForm( + host, + "/login/oauth/access_token", + { + client_id: clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + TokenResponse, + ).pipe(Effect.option); + // A dropped poll is retried on the next tick; the code stays valid until it expires. + if (Option.isNone(answer)) continue; + const response = answer.value; + if (response.access_token !== undefined) { + const accessToken = response.access_token; + return yield* writeSession( + host, + sessionFrom(yield* Clock.currentTimeMillis, { ...response, access_token: accessToken }), + ).pipe( + Effect.mapError( + (cause) => + new SourceControlSignInError({ + provider: KIND, + host, + reason: "failed", + detail: "The server could not save the sign-in.", + cause, + }), + ), + ); + } + switch (response.error) { + case "authorization_pending": + continue; + case "slow_down": + interval = response.interval ?? interval + 5; + continue; + case "expired_token": + return yield* signInError(host, "expired", "The sign-in code expired. Start again."); + case "access_denied": + return yield* signInError(host, "denied", "The sign-in was cancelled on GitHub."); + default: + return yield* signInError( + host, + "failed", + response.error_description ?? + `GitHub refused the sign-in (${response.error ?? "no reason given"}).`, + ); + } + } + }); + + const start: GitHubAppSignIn["Service"]["start"] = Effect.fn("GitHubAppSignIn.start")( + function* (rawHost) { + const host = rawHost.trim().toLowerCase(); + if (!enabled) { + return yield* signInError( + host, + "unsupported", + `This server is not set up to sign in with ${GitHubClient.APP_SIGN_IN.appName}.`, + ); + } + if (!isAppHost(host)) { + return yield* signInError( + host, + "unsupported", + `${GitHubClient.APP_SIGN_IN.appName} only signs in to ${GitHubClient.APP_SIGN_IN.hosts.join(", ")}. Use a token or the GitHub CLI for ${host}.`, + ); + } + const code = yield* postForm( + host, + "/login/device/code", + { client_id: clientId }, + DeviceCodeResponse, + ).pipe( + Effect.mapError( + (cause) => + new SourceControlSignInError({ + provider: KIND, + host, + reason: "failed", + detail: "The server could not reach GitHub to start the sign-in.", + cause, + }), + ), + ); + if ( + code.device_code === undefined || + code.user_code === undefined || + code.verification_uri === undefined || + code.expires_in === undefined + ) { + return yield* signInError( + host, + "failed", + code.error_description ?? + `GitHub would not start the sign-in (${code.error ?? "no reason given"}).`, + ); + } + const expiresAt = (yield* Clock.currentTimeMillis) + code.expires_in * 1_000; + const deferred = yield* Deferred.make(); + const fiber = yield* poll(host, code.device_code, expiresAt, code.interval ?? 5).pipe( + Effect.tap(() => Ref.set(refused, new Set())), + Deferred.into(deferred), + Effect.forkIn(scope), + ); + const previous = yield* Ref.modify(pending, (current) => [ + current.get(host), + new Map(current).set(host, { deferred, fiber }), + ]); + if (previous !== undefined) { + yield* Deferred.fail( + previous.deferred, + signInError(host, "failed", "A newer sign-in replaced this one."), + ); + yield* Fiber.interrupt(previous.fiber); + } + return { + userCode: code.user_code, + verificationUri: code.verification_uri, + expiresAt, + } satisfies SourceControlSignInPrompt; + }, + ); + + const wait: GitHubAppSignIn["Service"]["wait"] = Effect.fn("GitHubAppSignIn.wait")( + function* (rawHost) { + const host = rawHost.trim().toLowerCase(); + const current = (yield* Ref.get(pending)).get(host); + if (current === undefined) { + return yield* signInError(host, "not-started", "No sign-in is in progress. Start again."); + } + return yield* Deferred.await(current.deferred); + }, + ); + + const cancel: GitHubAppSignIn["Service"]["cancel"] = Effect.fn("GitHubAppSignIn.cancel")( + function* (rawHost) { + const host = rawHost.trim().toLowerCase(); + const current = (yield* Ref.get(pending)).get(host); + if (current === undefined) return; + yield* Deferred.fail( + current.deferred, + signInError(host, "cancelled", "The sign-in was cancelled."), + ); + yield* Fiber.interrupt(current.fiber); + }, + ); + + return GitHubAppSignIn.of({ enabled, token, invalidate, start, wait, cancel }); +}); + +export const layer = Layer.effect(GitHubAppSignIn, make); diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index a17be69501055..35d0eb23bf4ed 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -62,6 +63,12 @@ function harness( }), ), Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.die(`Unexpected request to ${request.url}`)), + ), + ), ); return { layer, calls }; } diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index b958750e860ad..41641790aac52 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -29,7 +29,7 @@ export const GH_PROCESS_CONCURRENCY = { key: "gh", limit: 4 } as const; const TOKEN_TTL = Duration.minutes(5); const MISSING_TTL = Duration.seconds(10); -export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); +export const GitHubCredentialSource = Schema.Literals(["settings", "app", "env", "gh"]); export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; export interface GitHubCredential { @@ -89,6 +89,14 @@ export class GitHubCredentials extends Context.Service< readonly get: ( host: string, ) => Effect.Effect; + /** The token saved in Settings for `host`, which outranks every other source. */ + readonly saved: ( + host: string, + ) => Effect.Effect< + Option.Option, + GitHubHostDisabledError | GitHubNotSignedInError + >; + readonly fingerprint: (host: string, token: string) => Effect.Effect; readonly invalidate: (host: string) => Effect.Effect; } >()("@t3tools/source-control-github/server/GitHubCredentials") {} @@ -225,25 +233,31 @@ export const make = Effect.gen(function* () { : MISSING_TTL, }); + const saved = Effect.fn("GitHubCredentials.saved")(function* (rawHost: string) { + const host = normalizeHost(rawHost); + if (!isGitHubHost(host)) return yield* new GitHubNotSignedInError({ host }); + if ((yield* hostChoice(host))?.enabled === false) { + return yield* new GitHubHostDisabledError({ host }); + } + const token = yield* savedToken(host); + if (token === null) return Option.none(); + return Option.some({ + host, + token: Redacted.make(token), + source: "settings", + fingerprint: yield* fingerprintOf(host, token), + }); + }); + return GitHubCredentials.of({ get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { const host = normalizeHost(rawHost); - if (!isGitHubHost(host)) return yield* new GitHubNotSignedInError({ host }); - const choice = yield* hostChoice(host); - if (choice?.enabled === false) { - return yield* new GitHubHostDisabledError({ host }); - } - const saved = yield* savedToken(host); - if (saved !== null) { - return { - host, - token: Redacted.make(saved), - source: "settings", - fingerprint: yield* fingerprintOf(host, saved), - } satisfies GitHubCredential; - } - return yield* Cache.get(cache, cacheKey(host, choice?.account)); + const fromSettings = yield* saved(host); + if (Option.isSome(fromSettings)) return fromSettings.value; + return yield* Cache.get(cache, cacheKey(host, (yield* hostChoice(host))?.account)); }), + saved, + fingerprint: fingerprintOf, invalidate: Effect.fn("GitHubCredentials.invalidate")(function (rawHost) { const host = normalizeHost(rawHost); return hostChoice(host).pipe( diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 61005227a168c..3fd74e08130ec 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -21,6 +21,14 @@ import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSour import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -112,6 +120,7 @@ const mockApi = Layer.succeed( GitHubRest.GitHubRest.of({ rest: (input) => mockedExecute({ kind: "rest", ...input }), credential: (host) => mockedCredential(host), + appCredential: () => Effect.succeedNone, }), ); @@ -167,8 +176,10 @@ const mockHttp = Layer.succeed( const layer = it.layer( GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubAppAccess.layer), Layer.provideMerge(mockApi), Layer.provideMerge(GitHubRequestPolicy.layer), + Layer.provideMerge(appSignedOut), Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), Layer.provideMerge(mockHttp), @@ -176,6 +187,8 @@ const layer = it.layer( Layer.succeed(GitHubCredentials.GitHubCredentials, { get: (host) => Effect.map(mockedCredential(host), (held) => ({ ...held, host, source: "gh" as const })), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.void, }), ), @@ -623,6 +636,8 @@ it.effect( source: "gh" as const, fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, })), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.void, }), ); @@ -648,8 +663,9 @@ it.effect( ); const cli = yield* GitHubPullRequestApi.make.pipe( Effect.provide( - GitHubRest.layer.pipe( - Layer.provideMerge(Layer.mergeAll(credentials, http)), + GitHubAppAccess.layer.pipe( + Layer.provideMerge(GitHubRest.layer), + Layer.provideMerge(Layer.mergeAll(credentials, http, appSignedOut)), Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), Layer.merge(TestSourceControlHost.layer()), diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 12e38f40a05ba..4b7014a890744 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -45,6 +45,7 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubGraphQlClient from "./gitHubGraphQlClient.ts"; @@ -405,7 +406,7 @@ export class GitHubPullRequestApi extends Context.Service< GitHubPullRequestApi, { readonly withVerifiedCredential: ( - input: { readonly cwd: string; readonly host: string }, + input: { readonly cwd: string; readonly host: string; readonly repository?: string }, use: (identity: { readonly accountId: string; readonly viewer: string; @@ -886,6 +887,7 @@ interface RestReadInput { /** @public */ export const make = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; + const appAccess = yield* GitHubAppAccess.GitHubAppAccess; const policy = yield* GitHubRequestPolicy.GitHubRequestPolicy; const httpClient = yield* HttpClient.HttpClient; const sourceControlHost = yield* SourceControlHost.SourceControlHost; @@ -909,10 +911,18 @@ export const make = Effect.gen(function* () { const decodeRoutingIdentity = Schema.decodeUnknownEffect(Schema.fromJsonString(RoutingIdentity)); const decodeViewerIdentity = Schema.decodeUnknownEffect(RoutingIdentity); const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( - function* (input: { readonly cwd: string; readonly host: string }) { + function* (input: { + readonly cwd: string; + readonly host: string; + readonly repository?: string; + }) { const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); - const { token, fingerprint: key } = yield* api.credential(host); + // A pinned credential is never handed on to another, so pick one that reaches the repository. + const { token, fingerprint: key } = + input.repository === undefined + ? yield* api.credential(host) + : yield* appAccess.credentialFor(host, input.repository); const credential = { host, token, credentialFingerprint: key }; return yield* Effect.acquireUseRelease( Effect.sync(() => { @@ -2391,6 +2401,7 @@ export const make = Effect.gen(function* () { if (input.stackNumber !== undefined) return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( Effect.provideService(GitHubRest.GitHubRest, api), + Effect.provideService(GitHubAppAccess.GitHubAppAccess, appAccess), Effect.provideService(GitHubRequestPolicy.GitHubRequestPolicy, policy), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(SourceControlHost.SourceControlHost, sourceControlHost), diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.credential.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.credential.test.ts index 8bde631903ca8..231aec1f12aa6 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.credential.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.credential.test.ts @@ -12,6 +12,14 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; import * as GitHubRest from "./GitHubRest.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); it.effect("keeps branch deletion on the credential that passed source repository permissions", () => Effect.gen(function* () { @@ -27,6 +35,8 @@ it.effect("keeps branch deletion on the credential that passed source repository source: "gh" as const, fingerprint: `${host}:${activeToken}`, })), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.void, }), ); @@ -101,10 +111,11 @@ it.effect("keeps branch deletion on the credential that passed source repository const provider = yield* GitHubPullRequestProvider.make.pipe( Effect.provide( GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubAppAccess.layer), // GraphQL reads go over `http` too, through the same credential and quota as REST. Layer.provide( GitHubRest.layer.pipe( - Layer.provideMerge(Layer.mergeAll(credentials, http)), + Layer.provideMerge(Layer.mergeAll(credentials, http, appSignedOut)), Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ), diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index 47b145a5f1388..2c2935bd1fbcf 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -11,6 +11,10 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; + +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; @@ -86,6 +90,7 @@ export class GitHubApiResponseError extends Schema.TaggedError = { readonly status: number | undefined; readonly headers: Readonly>; } & ( - | { readonly _tag: "Ok"; readonly value: A } + | { + readonly _tag: "Ok"; + readonly value: A; + /** GitHub left part of the answer out as not found or forbidden for this credential. */ + readonly withheld?: boolean; + } | { readonly _tag: "RateLimited" | "Unauthorized" | "NotFound" } | { readonly _tag: "Failed"; readonly messages?: ReadonlyArray | undefined } ); @@ -188,40 +198,109 @@ export class GitHubRequestPolicy extends Context.Service< input: GitHubRequest, send: (headers: Readonly>) => Effect.Effect, E, R>, ) => Effect.Effect; + /** + * The credential requests to `host` use: a token saved in Settings, else the app sign-in, + * else `GH_TOKEN` or `gh`. `personal` skips the app, for repositories it cannot reach. + */ readonly credential: ( host: string, - ) => Effect.Effect< - { readonly token: Redacted.Redacted; readonly fingerprint: string }, - GitHubApiError - >; + options?: { readonly personal?: boolean }, + ) => Effect.Effect; + /** The app sign-in's own credential, whatever else outranks it; none when not signed in. */ + readonly appCredential: ( + host: string, + ) => Effect.Effect, GitHubAppSignIn.GitHubAppSessionError>; } >()("@t3tools/source-control-github/server/GitHubRequestPolicy") {} +export interface GitHubHeldCredential { + readonly token: Redacted.Redacted; + readonly fingerprint: string; +} + +interface ChosenCredential extends GitHubHeldCredential { + readonly fromApp: boolean; +} + +/** + * The app reaches only repositories where it is installed. A not-found or "not accessible by + * integration" answer to its token is worth one more try with the user's own credential. + */ +function refusedToApp(answer: GitHubAnswer): boolean { + switch (answer._tag) { + case "NotFound": + return true; + case "Ok": + return answer.withheld === true; + case "Failed": + return ( + answer.status === 403 && + (answer.messages ?? []).some((message) => /not accessible by integration/i.test(message)) + ); + default: + return false; + } +} + /** @public */ export const make = Effect.gen(function* () { const credentials = yield* GitHubCredentials.GitHubCredentials; + const appSignIn = yield* GitHubAppSignIn.GitHubAppSignIn; const quota = yield* GitHubQuota.GitHubQuota; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const slots = yield* Semaphore.make(CONCURRENCY); - const credential: GitHubRequestPolicy["Service"]["credential"] = Effect.fn( - "GitHubRest.credential", + const personalCredential = (host: string) => + credentials.get(host).pipe( + Effect.map((held): ChosenCredential => ({ + token: held.token, + fingerprint: held.fingerprint, + fromApp: false, + })), + ); + + const appCredential: GitHubRequestPolicy["Service"]["appCredential"] = Effect.fn( + "GitHubRest.appCredential", )(function* (host) { - const normalized = normalizeHost(host); + const token = yield* appSignIn.token(normalizeHost(host)); + if (Option.isNone(token)) return Option.none(); + return Option.some({ + token: Redacted.make(token.value), + fingerprint: yield* credentials.fingerprint(normalizeHost(host), token.value), + }); + }); + + const choose = Effect.fn("GitHubRest.credential")(function* (host: string, personal: boolean) { const pinned = yield* PinnedGitHubCredential; if (pinned !== null) { - if (pinned.host !== normalized) { - return yield* new GitHubApiAuthenticationError({ - host: normalized, - operation: "credential", - }); + if (pinned.host !== host) { + return yield* new GitHubApiAuthenticationError({ host, operation: "credential" }); } - return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; + return { + token: pinned.token, + fingerprint: pinned.credentialFingerprint, + fromApp: false, + } satisfies ChosenCredential; + } + const saved = yield* credentials.saved(host); + if (Option.isSome(saved)) { + return { token: saved.value.token, fingerprint: saved.value.fingerprint, fromApp: false }; + } + if (personal) return yield* personalCredential(host); + const app = yield* Effect.result(appCredential(host)); + // An app session that cannot refresh right now steps aside for the user's own credential. + if (Result.isFailure(app)) { + return yield* personalCredential(host).pipe(Effect.mapError(() => app.failure)); } - const held = yield* credentials.get(normalized); - return { token: held.token, fingerprint: held.fingerprint }; + if (Option.isNone(app.success)) return yield* personalCredential(host); + return { ...app.success.value, fromApp: true } satisfies ChosenCredential; }); + const credential: GitHubRequestPolicy["Service"]["credential"] = (host, options) => + choose(normalizeHost(host), options?.personal === true).pipe( + Effect.map(({ token, fingerprint }) => ({ token, fingerprint })), + ); + // The REST/GraphQL transport span owns request and quota metadata. const request: GitHubRequestPolicy["Service"]["request"] = Effect.fnUntraced(function* ( input: GitHubRequest, @@ -233,14 +312,17 @@ export const make = Effect.gen(function* () { "github.host": host, "github.operation": input.operation, }); - const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; // A refusal pauses the whole host, whichever quota it spent: the key PullRequestService // records its own backoff under. const key = { provider: SourceControlProviderKind.make("github"), host }; // Settlement annotates the request span with status and rate-limit attributes. - const settle = Effect.fnUntraced(function* (lease: number, answer: GitHubAnswer) { + const settle = Effect.fnUntraced(function* ( + lease: number, + answer: GitHubAnswer, + token: Redacted.Redacted, + ) { yield* quota.observe(host, answer.headers); yield* Effect.annotateCurrentSpan({ ...(answer.status === undefined ? {} : { "http.response.status_code": answer.status }), @@ -264,6 +346,7 @@ export const make = Effect.gen(function* () { } case "Unauthorized": // The source may hold a newer token than the one that was refused. + yield* appSignIn.invalidate(host, Redacted.value(token)); yield* credentials.invalidate(host); return yield* new GitHubApiAuthenticationError(context); case "NotFound": @@ -277,39 +360,65 @@ export const make = Effect.gen(function* () { } }); - const run = Effect.gen(function* () { - const lease = yield* quota - .admit(host, input.resource, { allowReserve: input.allowReserve }) + /** One send under `held`; `Retry` hands an app-refused request to the user's credential. */ + const attempt = (held: ChosenCredential) => + slots + .withPermit( + Effect.gen(function* () { + const lease = yield* quota + .admit(host, input.resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen( + limits.check(key, input.allowReserve ? { allowPaused: true } : undefined), + ), + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, + }), + ), + ); + // One deadline covers the headers and the body: a host that answers headers and then + // stalls must not hold a shared slot for undici's own five-minute body timeout. + const answer = yield* send({ + ...REQUEST_HEADERS, + authorization: `Bearer ${Redacted.value(held.token)}`, + }).pipe( + Effect.timeoutOrElse({ + duration: input.timeout ?? DEFAULT_TIMEOUT, + orElse: () => + Effect.fail( + new GitHubApiRequestError({ ...context, cause: new Cause.TimeoutError() }), + ), + }), + ); + if (held.fromApp && refusedToApp(answer)) { + const personal = yield* Effect.option(personalCredential(host)); + if (Option.isSome(personal)) { + yield* quota.observe(host, answer.headers); + yield* limits.recordSuccess({ ...key, lease }); + yield* Effect.annotateCurrentSpan({ "github.app_refused": true }); + return { _tag: "Retry", with: personal.value } as const; + } + } + return { _tag: "Done", value: yield* settle(lease, answer, held.token) } as const; + }), + ) + // The rate-limit scope follows the credential, so a pause on one account never blocks another. .pipe( - Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), - Effect.tapError((paused) => - Effect.annotateCurrentSpan({ - "github.paused": true, - "github.retry_at": paused.retryAt, - }), - ), + Effect.provideService(SourceControlRateLimit.CredentialScope, scope || held.fingerprint), ); - // One deadline covers the headers and the body: a host that answers headers and then - // stalls must not hold a shared slot for undici's own five-minute body timeout. - const answer = yield* send({ - ...REQUEST_HEADERS, - authorization: `Bearer ${Redacted.value(token)}`, - }).pipe( - Effect.timeoutOrElse({ - duration: input.timeout ?? DEFAULT_TIMEOUT, - orElse: () => - Effect.fail(new GitHubApiRequestError({ ...context, cause: new Cause.TimeoutError() })), - }), - ); - return yield* settle(lease, answer); - }); - // The rate-limit scope follows the credential, so a pause on one account never blocks another. - return yield* slots - .withPermit(run) - .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + + // Only an app credential is handed on, so this runs at most twice. + let held = yield* choose(host, false); + while (true) { + const outcome = yield* attempt(held); + if (outcome._tag === "Done") return outcome.value; + held = outcome.with; + } }); - return GitHubRequestPolicy.of({ request, credential }); + return GitHubRequestPolicy.of({ request, credential, appCredential }); }); export const layer = Layer.effect(GitHubRequestPolicy, make); diff --git a/packages/source-control-github/src/server/GitHubRest.test.ts b/packages/source-control-github/src/server/GitHubRest.test.ts index a4ca7a14c77ab..fec5ab9242605 100644 --- a/packages/source-control-github/src/server/GitHubRest.test.ts +++ b/packages/source-control-github/src/server/GitHubRest.test.ts @@ -16,6 +16,13 @@ import * as GitHubQuota from "./GitHubQuota.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); const NOW = Date.parse("2026-10-05T12:00:00.000Z"); @@ -37,6 +44,8 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp fingerprint: `${host}:${tokens[0]}`, }; }), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.sync(() => { invalidations++; @@ -52,7 +61,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp }), ); const layer = GitHubRest.layer.pipe( - Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provide(Layer.mergeAll(credentials, http, appSignedOut)), Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); @@ -231,10 +240,14 @@ describe("GitHubRest", () => { }); describe("GitHubCredentials", () => { + const unexpectedHttp = HttpClient.make((request) => + Effect.die(`Unexpected request to ${request.url}`), + ); const credentialsWith = (run: SourceControlHost.SourceControlHost["Service"]["process"]["run"]) => GitHubCredentials.layer.pipe( Layer.provide(TestSourceControlHost.layer({ process: { run } })), Layer.provide(NodeCrypto.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)), ); it.effect("does not cache a gh failure that is not about signing in", () => { diff --git a/packages/source-control-github/src/server/GitHubRest.ts b/packages/source-control-github/src/server/GitHubRest.ts index 6d7dd29049753..35bdfb39cc5c2 100644 --- a/packages/source-control-github/src/server/GitHubRest.ts +++ b/packages/source-control-github/src/server/GitHubRest.ts @@ -4,11 +4,11 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; @@ -49,12 +49,8 @@ export class GitHubRest extends Context.Service< GitHubRest, { readonly rest: (input: GitHubRestInput) => Effect.Effect; - readonly credential: ( - host: string, - ) => Effect.Effect< - { readonly token: Redacted.Redacted; readonly fingerprint: string }, - GitHubApiError - >; + readonly credential: GitHubRequestPolicy.GitHubRequestPolicy["Service"]["credential"]; + readonly appCredential: GitHubRequestPolicy.GitHubRequestPolicy["Service"]["appCredential"]; } >()("@t3tools/source-control-github/server/GitHubRest") {} @@ -212,7 +208,11 @@ export const make = Effect.gen(function* () { ); }; - return GitHubRest.of({ rest, credential: policy.credential }); + return GitHubRest.of({ + rest, + credential: policy.credential, + appCredential: policy.appCredential, + }); }); export const layer = Layer.effect(GitHubRest, make).pipe( @@ -226,5 +226,6 @@ export const layer = Layer.effect(GitHubRest, make).pipe( */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubAppSignIn.layer), Layer.provideMerge(GitHubQuota.layer), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 0bf7773aa0ea5..0fb270156ceaa 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -22,6 +22,13 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -220,15 +227,19 @@ function harness(input: { source: "gh" as const, fingerprint: `${host}:token`, })), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.void, }), GitHubQuota.layer, SourceControlRateLimit.layer, + appSignedOut, ), ), ); const layer = Layer.mergeAll( host, + appSignedOut, Layer.mock(GitHubRest.GitHubRest)(input.api ?? {}), policy, Layer.succeed(HttpClient.HttpClient, graphQlHttp(graphql, graphqlCalls)), @@ -831,6 +842,7 @@ it("reports every gh login and leads with the account Settings pin", () => { const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {}, + signIns: {}, }); assert.deepStrictEqual(auth.account, Option.some("work")); assert.deepStrictEqual(auth.accounts, [ @@ -844,6 +856,7 @@ it("reports unauthenticated when Settings turn off every signed-in host", () => const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), { hosts: { "github.com": { enabled: false } }, tokens: {}, + signIns: {}, }); assert.strictEqual(auth.status, "unauthenticated"); assert.deepStrictEqual( @@ -863,7 +876,7 @@ it("names the environment token that overrides the Settings choice", () => { tokenSource: "GH_TOKEN", }, ]), - { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, + { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {}, signIns: {} }, ); assert.deepStrictEqual(auth.account, Option.some("bot")); assert.deepStrictEqual( diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index dda1c7c752777..bfc5fc29041ea 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -27,6 +27,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { GH_PROCESS_CONCURRENCY } from "./GitHubCredentials.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as GitHubChangeRequestTemplate from "./gitHubChangeRequestTemplate.ts"; @@ -212,6 +213,9 @@ export const makeDiscovery = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const process = sourceControlHost.process; const environment = yield* HostProcess.Environment; + const appSignIn = yield* GitHubAppSignIn.GitHubAppSignIn; + const offerAppSignIn = (item: A): A => + appSignIn.enabled ? { ...item, appSignIn: true } : item; return { type: "managed-cli", @@ -234,9 +238,18 @@ export const makeDiscovery = Effect.gen(function* () { spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; + const signedIn = + appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); - const tokenSource = savedToken ? "the token saved in Settings" : variable; - if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli; + const appName = GitHubClient.APP_SIGN_IN.appName; + const tokenSource = savedToken + ? "the token saved in Settings" + : signedIn + ? `the ${appName} sign-in` + : variable; + if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) { + return offerAppSignIn(cli); + } const viewer = yield* api .rest({ host: "github.com", operation: "discovery", path: "user" }) .pipe(Effect.result); @@ -244,7 +257,7 @@ export const makeDiscovery = Effect.gen(function* () { ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login : undefined; const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts }; - return { + return offerAppSignIn({ ...cli, status: "available" as const, auth: { @@ -255,24 +268,28 @@ export const makeDiscovery = Effect.gen(function* () { host: "github.com", detail: savedToken ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login." - : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, + : signedIn + ? `Signed in with ${appName}.` + : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`, }) : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError" ? providerAuth({ status: "unknown", host: "github.com", - detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, + detail: `Could not check ${savedToken || signedIn ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`, }) : providerAuth({ status: "unauthenticated", host: "github.com", detail: savedToken ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control." - : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, + : signedIn + ? `GitHub refused the ${appName} sign-in. Sign in again in Settings → Source Control.` + : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`, })), ...accounts, }, - } satisfies SourceControlProviderDiscoveryItem; + } satisfies SourceControlProviderDiscoveryItem); }), // Claim disabled hosts so errors identify the setting. refineUnknownRemote: ({ context }) => { @@ -337,6 +354,7 @@ function fromGitHubApiError( case "GitHubApiNotFoundError": return failure(notFound, error); case "GitHubCliFailedError": + case "GitHubAppSessionError": case "GitHubApiResponseError": case "GitHubApiRequestError": return failure(error.message.trim() || "GitHub request failed.", error); @@ -983,7 +1001,11 @@ export const make = Effect.gen(function* () { const locator = parseGitHubRepositorySelector(input.repository, fallbackHost) ?? (/^[^/\s]+$/.test(bareName) - ? { host: fallbackHost, owner: yield* readViewerLogin(fallbackHost), name: bareName } + ? { + host: fallbackHost, + owner: yield* readViewerLogin(fallbackHost), + name: bareName, + } : null); if (locator === null) return yield* failure("Repositories are named owner/name."); return repositoryCloneUrls(yield* readRepository(locator)); @@ -991,7 +1013,9 @@ export const make = Effect.gen(function* () { (effect, input) => effect.pipe( Effect.mapError( - providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), + providerError("getRepositoryCloneUrls", input.cwd, { + repository: input.repository, + }), ), ), ), diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index a767cbbef8553..023098b0b63f5 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -1,12 +1,16 @@ +import { SourceControlSignInError, type SourceControlProviderKind } from "@t3tools/contracts"; import { defineSourceControlDriver, withOwnServices, + type SourceControlSignIn, } from "@t3tools/source-control-core/server/driver"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { definition } from "../shared/definition.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; import * as GitHubMediaFetch from "./gitHubMediaFetch.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; @@ -16,14 +20,45 @@ import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; // `GitHubRest.layerWithDependencies` carries the quota reserve every GitHub reader shares, so it // must be built once; the driver is. const services = GitHubPullRequestApi.layer.pipe( + Layer.provideMerge(GitHubAppAccess.layer), Layer.provideMerge(GitHubRest.layerWithDependencies), ); +const makeSignIn = Effect.gen(function* () { + const appSignIn = yield* GitHubAppSignIn.GitHubAppSignIn; + const appAccess = yield* GitHubAppAccess.GitHubAppAccess; + const kind: SourceControlProviderKind = definition.kind; + return { + start: appSignIn.start, + wait: (host) => + appSignIn.wait(host).pipe( + Effect.andThen( + appAccess.viewer(host).pipe( + Effect.mapError( + (cause) => + new SourceControlSignInError({ + provider: kind, + host, + reason: "failed", + detail: "Signed in, but GitHub would not say which account it belongs to.", + cause, + }), + ), + ), + ), + Effect.map((account) => ({ account })), + ), + cancel: appSignIn.cancel, + access: appAccess.reach, + } satisfies SourceControlSignIn; +}); + const makeInstance = Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, pullRequests: GitHubPullRequestProvider.make, fetchMedia: GitHubMediaFetch.make, + signIn: makeSignIn, }); export const driver = defineSourceControlDriver({ diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index 85fecc967ac6b..cd8a838b23723 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -7,6 +7,7 @@ import * as Redacted from "effect/Redacted"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; import * as GitHubRest from "./GitHubRest.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -22,6 +23,7 @@ const makeDiscovery = (credential: GitHubRest.GitHubRest["Service"]["credential" Effect.provide( Layer.mergeAll( Layer.mock(GitHubRest.GitHubRest)({ credential }), + Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ enabled: false }), TestSourceControlHost.layer(), ), ), diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts index 5027e618f9e93..667dc4f8f4c22 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts @@ -13,10 +13,20 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubGraphQlClient from "./gitHubGraphQlClient.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; import { PullRequestBatchesGraphQLGroup } from "./pullRequestBatches.graphql.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); const NOW = Date.parse("2026-10-05T12:00:00.000Z"); -function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { +function harness( + respond: (request: HttpClientRequest.HttpClientRequest) => Response, + options?: { readonly appToken?: string }, +) { const requests: Array = []; let invalidations = 0; const services = Layer.mergeAll( @@ -28,6 +38,8 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp source: "gh" as const, fingerprint: `${host}:secret`, }), + saved: () => Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.sync(() => { invalidations++; @@ -42,6 +54,13 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp ), GitHubQuota.layer, SourceControlRateLimit.layer, + options?.appToken === undefined + ? appSignedOut + : Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: true, + token: () => Effect.succeedSome(options.appToken ?? ""), + invalidate: () => Effect.void, + }), ); const layer = GitHubRest.layer.pipe(Layer.provideMerge(services)); return { layer, requests, invalidations: () => invalidations }; @@ -74,6 +93,26 @@ const readStats = (host: string, number = 7) => const stats = { additions: 3, deletions: 1 }; describe("GitHubGraphQlAuth", () => { + it.effect("asks again with the user's own credential when the app token gets NOT_FOUND", () => { + const { layer, requests } = harness( + (request) => + request.headers["authorization"] === "Bearer app-token" + ? json({ + data: { _0_repository: null }, + errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository" }], + }) + : json({ data: { _0_repository: { pullRequest: stats } } }), + { appToken: "app-token" }, + ); + return Effect.gen(function* () { + expect(yield* readStats("github.com")).toEqual({ repository: { pullRequest: stats } }); + expect(requests.map((request) => request.headers["authorization"])).toEqual([ + "Bearer app-token", + "Bearer secret", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect( "sends each host's lookups to its own endpoint with its token, never in one batch", () => { diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 36ea0765505a5..32198ad0b0236 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -77,9 +77,18 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< headers: responseHeaders, ...(answer.errors === undefined ? {} : { graphqlErrors: answer.errors }), }); + const withheld = (answer.errors ?? []).some( + (error) => error["type"] === "NOT_FOUND" || error["type"] === "FORBIDDEN", + ); return rateLimited ? { _tag: "RateLimited" as const, status, headers: responseHeaders } - : { _tag: "Ok" as const, value: answer, status, headers: responseHeaders }; + : { + _tag: "Ok" as const, + value: answer, + withheld, + status, + headers: responseHeaders, + }; }), Effect.catchIf(GraphQLClientError.isGraphQLClientError, (error) => refused(error, { host, operation: target.operation }), diff --git a/packages/source-control-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts index 5f9b96a60e1cd..3d95638bc48e9 100644 --- a/packages/source-control-github/src/server/githubStackActions.test.ts +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -13,6 +13,14 @@ import * as GitHubQuota from "./GitHubQuota.ts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; +import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; + +const appSignedOut = Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ + enabled: false, + token: () => Effect.succeedNone, + invalidate: () => Effect.void, +}); type Call = ReadonlyArray; type Send = (call: Call, kind: "graphql" | "rest") => Effect.Effect; @@ -86,13 +94,19 @@ const runGitHubStackAction = (send: Send, input: Parameters Effect.succeedNone, + fingerprint: (host, token) => Effect.succeed(`${host}:${token}`), invalidate: () => Effect.void, }), GitHubQuota.layer, SourceControlRateLimit.layer, + appSignedOut, ), ), ), + Layer.mock(GitHubAppAccess.GitHubAppAccess)({ + credentialFor: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + }), Layer.succeed(HttpClient.HttpClient, graphQlHttp(send)), ), ), diff --git a/packages/source-control-github/src/server/githubStackRebase.test.ts b/packages/source-control-github/src/server/githubStackRebase.test.ts index 725abeed42ca0..aa04c61744753 100644 --- a/packages/source-control-github/src/server/githubStackRebase.test.ts +++ b/packages/source-control-github/src/server/githubStackRebase.test.ts @@ -8,12 +8,12 @@ import * as Redacted from "effect/Redacted"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; -import * as GitHubRest from "./GitHubRest.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; import { cascadeRebaseStack } from "./githubStackRebase.ts"; const layer = Layer.mergeAll( - Layer.mock(GitHubRest.GitHubRest)({ - credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), + Layer.mock(GitHubAppAccess.GitHubAppAccess)({ + credentialFor: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }), }), TestSourceControlHost.layerWithRealGit(), ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/packages/source-control-github/src/server/githubStackRebase.ts b/packages/source-control-github/src/server/githubStackRebase.ts index cf795ab6e1f65..27595e7e7706d 100644 --- a/packages/source-control-github/src/server/githubStackRebase.ts +++ b/packages/source-control-github/src/server/githubStackRebase.ts @@ -4,7 +4,7 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Base64 from "effect/encoding/Base64"; -import * as GitHubRest from "./GitHubRest.ts"; +import * as GitHubAppAccess from "./GitHubAppAccess.ts"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; export interface CascadeLayer { @@ -42,10 +42,11 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp readonly layers: ReadonlyArray; readonly remote?: string; }) { - const api = yield* GitHubRest.GitHubRest; + const appAccess = yield* GitHubAppAccess.GitHubAppAccess; const { process } = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; - const { token } = yield* api.credential(input.host); + // A push takes one credential up front, so it must be one that reaches the repository. + const { token } = yield* appAccess.credentialFor(input.host, input.repository); const first = input.layers[0]; if (first === undefined) return 0; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 170395bb7eef5..965ed42814c19 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -8,6 +8,7 @@ import { parseChangeRequestPath, projectChangeRequestHost, remoteWebOrigin, + type SourceControlAppSignIn, } from "@t3tools/source-control-core/shared/definition"; import { gitHubMediaUrl } from "./gitHubMedia.ts"; @@ -42,6 +43,14 @@ function isRepositoryPath(repository: string): boolean { ); } +/** Signing in through the T3 Code for GitHub app lifts the per-user API rate limit to the app's. */ +export const APP_SIGN_IN: SourceControlAppSignIn = { + appName: "T3 Code for GitHub", + hosts: ["github.com"], + installUrl: "https://github.com/apps/t3-code-for-github/installations/new", + revokeUrl: "https://github.com/settings/apps/authorizations", +}; + export const settings = makeProviderSettingsSchema(hostCredentialSettingsFields); export type GitHubSettings = typeof settings.Type; @@ -58,6 +67,7 @@ export const definition = defineSourceControl({ tokenEnvVar: "GH_TOKEN", tokenAccess: "read and write access to pull requests and contents", newTokenUrl: (host) => `https://${host}/settings/personal-access-tokens/new`, + appSignIn: APP_SIGN_IN, }, changeRequest: { shortLabel: "PR", diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts index d241f9f737fac..3f1ef6c2b4c2b 100644 --- a/packages/source-control-testing/src/TestSourceControlHost.ts +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -55,8 +55,10 @@ export const layer = ( Layer.effectContext( Effect.gen(function* () { const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); + const update = (patch: ServerSettingsPatch) => + Ref.updateAndGet(settings, (current) => applyServerSettingsPatch(current, patch)); const host = SourceControlHost.SourceControlHost.of({ - settings: { get: Ref.get(settings) }, + settings: { get: Ref.get(settings), update }, process: { run: options.process?.run ?? @@ -66,8 +68,7 @@ export const layer = ( }); return Context.make(SourceControlHost.SourceControlHost, host).pipe( Context.add(TestSourceControlHostSettings, { - update: (patch) => - Ref.update(settings, (current) => applyServerSettingsPatch(current, patch)), + update: (patch) => Effect.asVoid(update(patch)), }), ); }),