| 1 | import * as Cache from "effect/Cache"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Hex from "effect/encoding/Hex"; |
| 7 | import * as Exit from "effect/Exit"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | |
| 14 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 15 | |
| 16 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 17 | import { readSourceControlHostSettings } from "@t3tools/source-control-core/client/definition"; |
| 18 | import type { ServerSettings } from "@t3tools/contracts"; |
| 19 | |
| 20 | import * as GitHubClient from "../client/definition.ts"; |
| 21 | |
| 22 | /** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ |
| 23 | const TOKEN_TTL = Duration.minutes(5); |
| 24 | /** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ |
| 25 | const MISSING_TTL = Duration.seconds(10); |
| 26 | |
| 27 | export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]); |
| 28 | export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; |
| 29 | |
| 30 | export interface GitHubCredential { |
| 31 | readonly host: string; |
| 32 | readonly token: Redacted.Redacted<string>; |
| 33 | readonly source: GitHubCredentialSource; |
| 34 | /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ |
| 35 | readonly fingerprint: string; |
| 36 | } |
| 37 | |
| 38 | /** Nothing in the environment, and no `gh` on PATH to ask. */ |
| 39 | export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingError>()( |
| 40 | "GitHubCliMissingError", |
| 41 | { host: Schema.String }, |
| 42 | ) { |
| 43 | override get message(): string { |
| 44 | return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */ |
| 49 | export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()( |
| 50 | "GitHubNotSignedInError", |
| 51 | { host: Schema.String, account: Schema.optional(Schema.String) }, |
| 52 | ) { |
| 53 | override get message(): string { |
| 54 | return this.account === undefined |
| 55 | ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.` |
| 56 | : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /** The user turned the host off in Settings. */ |
| 61 | export class GitHubHostDisabledError extends Schema.TaggedError<GitHubHostDisabledError>()( |
| 62 | "GitHubHostDisabledError", |
| 63 | { host: Schema.String }, |
| 64 | ) { |
| 65 | override get message(): string { |
| 66 | return `GitHub host ${this.host} is turned off in Settings → Source Control.`; |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /** `gh auth token` timed out or failed for a reason other than having no login. */ |
| 71 | export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedError>()( |
| 72 | "GitHubCliFailedError", |
| 73 | { host: Schema.String, cause: Schema.Defect() }, |
| 74 | ) { |
| 75 | override get message(): string { |
| 76 | return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /** There is no token for the host. */ |
| 81 | export type GitHubCredentialUnavailableError = |
| 82 | | GitHubCliMissingError |
| 83 | | GitHubNotSignedInError |
| 84 | | GitHubHostDisabledError |
| 85 | | GitHubCliFailedError; |
| 86 | |
| 87 | /** |
| 88 | * Where GitHub tokens come from. Callers ask per host and never see how the token was found, |
| 89 | * so another source (an in-app OAuth login) slots in here without touching any of them. |
| 90 | */ |
| 91 | export class GitHubCredentials extends Context.Service< |
| 92 | GitHubCredentials, |
| 93 | { |
| 94 | readonly get: ( |
| 95 | host: string, |
| 96 | ) => Effect.Effect<GitHubCredential, GitHubCredentialUnavailableError>; |
| 97 | /** Drops the held token after GitHub refused it, so the next read asks its source again. */ |
| 98 | readonly invalidate: (host: string) => Effect.Effect<void>; |
| 99 | } |
| 100 | >()("@t3tools/source-control-github/server/GitHubCredentials") {} |
| 101 | |
| 102 | function normalizeHost(host: string): string { |
| 103 | return host.trim().toLowerCase(); |
| 104 | } |
| 105 | |
| 106 | /** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ |
| 107 | function isGitHubDotCom(host: string): boolean { |
| 108 | return host === "github.com" || host.endsWith(".ghe.com"); |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to |
| 113 | * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL |
| 114 | * picks the host and a hostile one must not receive an enterprise token. |
| 115 | */ |
| 116 | export function environmentToken( |
| 117 | host: string, |
| 118 | env: Readonly<Record<string, string | undefined>>, |
| 119 | ): string | null { |
| 120 | const names = isGitHubDotCom(host) |
| 121 | ? ["GH_TOKEN", "GITHUB_TOKEN"] |
| 122 | : env.GH_HOST?.trim().toLowerCase() === host |
| 123 | ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] |
| 124 | : []; |
| 125 | for (const name of names) { |
| 126 | const value = env[name]?.trim(); |
| 127 | if (value) return value; |
| 128 | } |
| 129 | return null; |
| 130 | } |
| 131 | |
| 132 | /** @public Service construction is part of the canonical Effect module API. */ |
| 133 | export const make = Effect.gen(function* () { |
| 134 | const sourceControlHost = yield* SourceControlHost.SourceControlHost; |
| 135 | const process = sourceControlHost.process; |
| 136 | const crypto = yield* Crypto.Crypto; |
| 137 | const environment = yield* HostProcess.Environment; |
| 138 | const workingDirectory = yield* HostProcess.WorkingDirectory; |
| 139 | |
| 140 | /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */ |
| 141 | const fingerprintOf = (host: string, token: string) => |
| 142 | crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( |
| 143 | Effect.map((digest) => `${host}:${Hex.encode(digest)}`), |
| 144 | // Hashing a string in memory has no platform failure worth a typed error. |
| 145 | Effect.orDie, |
| 146 | ); |
| 147 | |
| 148 | const fromGh = (host: string, account: string | undefined) => |
| 149 | process |
| 150 | .run({ |
| 151 | operation: "GitHubCredentials.get", |
| 152 | command: "gh", |
| 153 | args: [ |
| 154 | "auth", |
| 155 | "token", |
| 156 | "--hostname", |
| 157 | host, |
| 158 | ...(account === undefined ? [] : ["--user", account]), |
| 159 | ], |
| 160 | cwd: workingDirectory, |
| 161 | // Never let gh print the token into a debug log. |
| 162 | env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, |
| 163 | timeoutMs: 10_000, |
| 164 | }) |
| 165 | .pipe( |
| 166 | Effect.mapError((error) => |
| 167 | error._tag === "VcsProcessSpawnError" && |
| 168 | error.cause instanceof PlatformError.PlatformError && |
| 169 | error.cause.reason._tag === "NotFound" |
| 170 | ? new GitHubCliMissingError({ host }) |
| 171 | : // gh exits non-zero with "no oauth token" when it has no login for the host. |
| 172 | error._tag === "VcsProcessExitError" |
| 173 | ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }) |
| 174 | : new GitHubCliFailedError({ host, cause: error }), |
| 175 | ), |
| 176 | Effect.map((output) => output.stdout.trim()), |
| 177 | Effect.filterOrFail( |
| 178 | (token) => token !== "", |
| 179 | () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }), |
| 180 | ), |
| 181 | ); |
| 182 | |
| 183 | const readSettings = (settings: ServerSettings) => |
| 184 | readSourceControlHostSettings( |
| 185 | GitHubClient.settings, |
| 186 | settings.sourceControlHosts[GitHubClient.definition.kind], |
| 187 | ); |
| 188 | |
| 189 | /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ |
| 190 | const hostChoice = (host: string) => |
| 191 | sourceControlHost.settings.get.pipe( |
| 192 | Effect.map((settings) => readSettings(settings).hosts[host]), |
| 193 | Effect.orElseSucceed(() => undefined), |
| 194 | ); |
| 195 | |
| 196 | /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ |
| 197 | const savedToken = (host: string) => |
| 198 | sourceControlHost.settings.get.pipe( |
| 199 | Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), |
| 200 | Effect.orElseSucceed(() => null), |
| 201 | ); |
| 202 | |
| 203 | /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ |
| 204 | const cacheKey = (host: string, account: string | undefined) => |
| 205 | account === undefined ? host : `${host}\u0000${account}`; |
| 206 | |
| 207 | const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { |
| 208 | const [host = key, choice] = key.split("\u0000"); |
| 209 | // An environment token wins over a pinned account, exactly as it does in gh. |
| 210 | const fromEnv = environmentToken(host, environment); |
| 211 | // A pinned login gh no longer holds (logged out, expired) falls back to the active one, |
| 212 | // which is what discovery reports as the account in use. |
| 213 | const token = |
| 214 | fromEnv ?? |
| 215 | (yield* fromGh(host, choice).pipe( |
| 216 | Effect.catchTags({ |
| 217 | GitHubNotSignedInError: (error) => |
| 218 | choice === undefined ? Effect.fail(error) : fromGh(host, undefined), |
| 219 | }), |
| 220 | )); |
| 221 | return { |
| 222 | host, |
| 223 | token: Redacted.make(token), |
| 224 | source: fromEnv !== null ? "env" : "gh", |
| 225 | fingerprint: yield* fingerprintOf(host, token), |
| 226 | } satisfies GitHubCredential; |
| 227 | }); |
| 228 | |
| 229 | const cache = yield* Cache.makeWith(lookup, { |
| 230 | capacity: 32, |
| 231 | // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. |
| 232 | timeToLive: (exit) => |
| 233 | Exit.isSuccess(exit) |
| 234 | ? TOKEN_TTL |
| 235 | : Exit.findErrorOption(exit).pipe( |
| 236 | Option.exists((error) => error._tag === "GitHubCliFailedError"), |
| 237 | ) |
| 238 | ? Duration.zero |
| 239 | : MISSING_TTL, |
| 240 | }); |
| 241 | |
| 242 | return GitHubCredentials.of({ |
| 243 | get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { |
| 244 | const host = normalizeHost(rawHost); |
| 245 | const choice = yield* hostChoice(host); |
| 246 | if (choice?.enabled === false) { |
| 247 | return yield* new GitHubHostDisabledError({ host }); |
| 248 | } |
| 249 | // A token saved in Settings is the most deliberate choice, so it comes before the |
| 250 | // environment and gh. It is read from the secret store each time, so it needs no cache. |
| 251 | const saved = yield* savedToken(host); |
| 252 | if (saved !== null) { |
| 253 | return { |
| 254 | host, |
| 255 | token: Redacted.make(saved), |
| 256 | source: "settings", |
| 257 | fingerprint: yield* fingerprintOf(host, saved), |
| 258 | } satisfies GitHubCredential; |
| 259 | } |
| 260 | return yield* Cache.get(cache, cacheKey(host, choice?.account)); |
| 261 | }), |
| 262 | invalidate: (rawHost) => { |
| 263 | const host = normalizeHost(rawHost); |
| 264 | return hostChoice(host).pipe( |
| 265 | Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), |
| 266 | ); |
| 267 | }, |
| 268 | }); |
| 269 | }); |
| 270 | |
| 271 | export const layer = Layer.effect(GitHubCredentials, make); |