Skip to content

Commit a03c7a1

Browse files
committed
feat(auth): validate github status tokens live
1 parent 51fdf12 commit a03c7a1

File tree

4 files changed

+295
-54
lines changed

4 files changed

+295
-54
lines changed

packages/lib/src/usecases/auth-github.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
33
import type { PlatformError } from "@effect/platform/Error"
44
import type * as FileSystem from "@effect/platform/FileSystem"
55
import type * as Path from "@effect/platform/Path"
6-
import { Duration, Effect, Schedule } from "effect"
6+
import { Duration, Effect, Match, Schedule } from "effect"
77

88
import type { AuthGithubLoginCommand, AuthGithubLogoutCommand, AuthGithubStatusCommand } from "../core/domain.js"
99
import { defaultTemplateConfig } from "../core/domain.js"
@@ -15,6 +15,8 @@ import { buildDockerAuthSpec, normalizeAccountLabel } from "./auth-helpers.js"
1515
import { migrateLegacyOrchLayout } from "./auth-sync.js"
1616
import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey } from "./env-file.js"
1717
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
18+
import type { GithubTokenValidationResult } from "./github-token-validation.js"
19+
import { validateGithubToken } from "./github-token-validation.js"
1820
import { resolvePathFromCwd } from "./path-helpers.js"
1921
import { withFsPathContext } from "./runtime.js"
2022
import { ensureStateDotDockerGitRepo } from "./state-repo-github.js"
@@ -35,6 +37,8 @@ type EnvContext = {
3537
readonly current: string
3638
}
3739

40+
type GithubTokenStatusEntry = GithubTokenEntry & GithubTokenValidationResult
41+
3842
const ensureGithubOrchLayout = (
3943
cwd: string,
4044
envGlobalPath: string
@@ -116,6 +120,21 @@ const withEnvContext = <A, E, R>(
116120
})
117121
)
118122

123+
const renderGithubTokenStatusLine = (entry: GithubTokenStatusEntry): string =>
124+
Match.value(entry.status).pipe(
125+
Match.when("valid", () =>
126+
entry.login === null
127+
? `- ${entry.label}: valid (owner unavailable)`
128+
: `- ${entry.label}: valid (owner: ${entry.login})`
129+
),
130+
Match.when("invalid", () => `- ${entry.label}: invalid`),
131+
Match.when("unknown", () => `- ${entry.label}: unknown (validation unavailable)`),
132+
Match.exhaustive
133+
)
134+
135+
const renderGithubTokenStatusReport = (entries: ReadonlyArray<GithubTokenStatusEntry>): string =>
136+
[`GitHub tokens (${entries.length}):`, ...entries.map(renderGithubTokenStatusLine)].join("\n")
137+
119138
const resolveGithubTokenFromGh = (
120139
cwd: string,
121140
accountPath: string
@@ -251,16 +270,16 @@ export const authGithubLogin = (
251270
})
252271
)
253272

254-
// CHANGE: show GitHub auth status from the shared env file
255-
// WHY: surface current account labels without leaking tokens
273+
// CHANGE: show GitHub auth status with live token validation and owner login
274+
// WHY: presence in the env file is weaker than actual GitHub validity for operator diagnostics
256275
// QUOTE(ТЗ): "система авторизации"
257-
// REF: user-request-2026-01-28-auth
276+
// REF: user-request-2026-03-19-github-token-status-owner
258277
// SOURCE: n/a
259-
// FORMAT THEOREM: forall env: status(env) -> labels(env)
278+
// FORMAT THEOREM: forall env: status(env) -> validated(labels(env))
260279
// PURITY: SHELL
261280
// EFFECT: Effect<void, PlatformError, FileSystem | Path>
262281
// INVARIANT: tokens are never logged
263-
// COMPLEXITY: O(n) where n = |env|
282+
// COMPLEXITY: O(n) env scan + O(n) network round-trips where n = |tokens|
264283
export const authGithubStatus = (
265284
command: AuthGithubStatusCommand
266285
): Effect.Effect<void, PlatformError, GithubFsRuntime> =>
@@ -271,10 +290,22 @@ export const authGithubStatus = (
271290
yield* _(Effect.log(`GitHub not connected (no tokens in ${envPath}).`))
272291
return
273292
}
274-
const sample = tokens.slice(0, 20).map((entry) => entry.label).join(", ")
275-
const remaining = tokens.length - Math.min(tokens.length, 20)
276-
const suffix = remaining > 0 ? ` ... (+${remaining} more)` : ""
277-
yield* _(Effect.log(`GitHub tokens (${tokens.length}): ${sample}${suffix}`))
293+
294+
const statuses = yield* _(
295+
Effect.forEach(tokens, (entry) =>
296+
validateGithubToken(entry.token).pipe(
297+
Effect.map((validation) => ({
298+
key: entry.key,
299+
label: entry.label,
300+
token: entry.token,
301+
status: validation.status,
302+
login: validation.login
303+
}))
304+
)
305+
)
306+
)
307+
308+
yield* _(Effect.log(renderGithubTokenStatusReport(statuses)))
278309
}))
279310

280311
// CHANGE: remove GitHub auth token from the shared env file

packages/lib/src/usecases/github-token-preflight.ts

Lines changed: 16 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import { FetchHttpClient, HttpClient } from "@effect/platform"
21
import type { PlatformError } from "@effect/platform/Error"
32
import * as FileSystem from "@effect/platform/FileSystem"
4-
import { Effect } from "effect"
3+
import { Effect, Match } from "effect"
54

65
import type { TemplateConfig } from "../core/domain.js"
76
import { parseGithubRepoUrl } from "../core/repo.js"
87
import { normalizeGitTokenLabel } from "../core/token-labels.js"
98
import { AuthError } from "../shell/errors.js"
109
import { findEnvValue, readEnvText } from "./env-file.js"
10+
import {
11+
githubInvalidTokenMessage,
12+
githubTokenValidationWarning,
13+
validateGithubToken
14+
} from "./github-token-validation.js"
1115

12-
const githubTokenValidationUrl = "https://api.github.com/user"
13-
const githubTokenValidationWarning = "Unable to validate GitHub token before start; continuing."
14-
export const githubInvalidTokenMessage =
15-
"GitHub token is invalid. Register GitHub again: docker-git auth github login --web"
16+
export { githubInvalidTokenMessage } from "./github-token-validation.js"
1617

1718
const defaultGithubTokenKeys: ReadonlyArray<string> = [
1819
"GIT_AUTH_TOKEN",
@@ -75,37 +76,6 @@ export const resolveGithubCloneAuthToken = (
7576
return findFirstEnvValue(envText, defaultGithubTokenKeys)
7677
}
7778

78-
type GithubTokenValidationStatus = "valid" | "invalid" | "unknown"
79-
80-
const unknownGithubTokenValidationStatus = (): GithubTokenValidationStatus => "unknown"
81-
82-
const mapGithubTokenValidationStatus = (status: number): GithubTokenValidationStatus => {
83-
if (status === 401) {
84-
return "invalid"
85-
}
86-
return status >= 200 && status < 300 ? "valid" : "unknown"
87-
}
88-
89-
const validateGithubTokenStatus = (token: string): Effect.Effect<GithubTokenValidationStatus> =>
90-
Effect.gen(function*(_) {
91-
const client = yield* _(HttpClient.HttpClient)
92-
const response = yield* _(
93-
client.get(githubTokenValidationUrl, {
94-
headers: {
95-
Authorization: `Bearer ${token}`,
96-
Accept: "application/vnd.github+json"
97-
}
98-
})
99-
)
100-
return mapGithubTokenValidationStatus(response.status)
101-
}).pipe(
102-
Effect.provide(FetchHttpClient.layer),
103-
Effect.match({
104-
onFailure: unknownGithubTokenValidationStatus,
105-
onSuccess: (status) => status
106-
})
107-
)
108-
10979
// CHANGE: validate GitHub auth token before clone/create starts mutating the project
11080
// WHY: dead tokens make git clone fail later with a misleading branch/auth error inside the container
11181
// QUOTE(ТЗ): "Если токен мёртв то пусть пишет что надо зарегистрировать github используй docker-git auth github login --web"
@@ -128,11 +98,13 @@ export const validateGithubCloneAuthTokenPreflight = (
12898
return
12999
}
130100

131-
const status = yield* _(validateGithubTokenStatus(token))
132-
if (status === "invalid") {
133-
return yield* _(Effect.fail(new AuthError({ message: githubInvalidTokenMessage })))
134-
}
135-
if (status === "unknown") {
136-
yield* _(Effect.logWarning(githubTokenValidationWarning))
137-
}
101+
const validation = yield* _(validateGithubToken(token))
102+
yield* _(
103+
Match.value(validation.status).pipe(
104+
Match.when("valid", () => Effect.void),
105+
Match.when("invalid", () => Effect.fail(new AuthError({ message: githubInvalidTokenMessage }))),
106+
Match.when("unknown", () => Effect.logWarning(githubTokenValidationWarning)),
107+
Match.exhaustive
108+
)
109+
)
138110
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { FetchHttpClient, HttpClient } from "@effect/platform"
2+
import * as ParseResult from "@effect/schema/ParseResult"
3+
import * as Schema from "@effect/schema/Schema"
4+
import { Either, Effect } from "effect"
5+
6+
const githubTokenValidationUrl = "https://api.github.com/user"
7+
8+
export const githubTokenValidationWarning = "Unable to validate GitHub token before start; continuing."
9+
export const githubInvalidTokenMessage =
10+
"GitHub token is invalid. Register GitHub again: docker-git auth github login --web"
11+
12+
type GithubUser = {
13+
readonly login: string
14+
}
15+
16+
export type GithubTokenValidationStatus = "valid" | "invalid" | "unknown"
17+
18+
export type GithubTokenValidationResult = {
19+
readonly status: GithubTokenValidationStatus
20+
readonly login: string | null
21+
}
22+
23+
const GithubUserSchema: Schema.Schema<GithubUser> = Schema.Struct({
24+
login: Schema.String
25+
})
26+
27+
const unknownGithubTokenValidationResult = (): GithubTokenValidationResult => ({
28+
status: "unknown",
29+
login: null
30+
})
31+
32+
const decodeGithubUserLogin = (input: unknown): string | null =>
33+
Either.match(ParseResult.decodeUnknownEither(GithubUserSchema)(input), {
34+
onLeft: () => null,
35+
onRight: (user) => user.login
36+
})
37+
38+
const mapGithubTokenValidationStatus = (status: number): GithubTokenValidationStatus => {
39+
if (status === 401) {
40+
return "invalid"
41+
}
42+
return status >= 200 && status < 300 ? "valid" : "unknown"
43+
}
44+
45+
// CHANGE: validate GitHub token and decode the authenticated account login on success
46+
// WHY: auth status and create preflight must share one live GitHub validation boundary
47+
// QUOTE(ТЗ): "status проверял валидность токена и если он валидный то писал бы кто овнер"
48+
// REF: user-request-2026-03-19-github-token-status-owner
49+
// SOURCE: n/a
50+
// FORMAT THEOREM: ∀t: probe(t).status = valid → probe(t).login ∈ String ∪ null
51+
// PURITY: SHELL
52+
// EFFECT: Effect<GithubTokenValidationResult, never, never>
53+
// INVARIANT: token is never logged; unknown/transport failures degrade to `unknown`
54+
// COMPLEXITY: O(1) network round-trip
55+
export const validateGithubToken = (token: string): Effect.Effect<GithubTokenValidationResult> =>
56+
Effect.gen(function*(_) {
57+
const client = yield* _(HttpClient.HttpClient)
58+
const response = yield* _(
59+
client.get(githubTokenValidationUrl, {
60+
headers: {
61+
Authorization: `Bearer ${token}`,
62+
Accept: "application/vnd.github+json"
63+
}
64+
})
65+
)
66+
67+
const status = mapGithubTokenValidationStatus(response.status)
68+
if (status !== "valid") {
69+
return {
70+
status,
71+
login: null
72+
} satisfies GithubTokenValidationResult
73+
}
74+
75+
const body = yield* _(response.json)
76+
return {
77+
status,
78+
login: decodeGithubUserLogin(body)
79+
} satisfies GithubTokenValidationResult
80+
}).pipe(
81+
Effect.provide(FetchHttpClient.layer),
82+
Effect.match({
83+
onFailure: unknownGithubTokenValidationResult,
84+
onSuccess: (result) => result
85+
})
86+
)

0 commit comments

Comments
 (0)