@@ -24,6 +24,20 @@ export interface GitHubUser {
2424 readonly login : string ;
2525 readonly name : string | null ;
2626 readonly avatar_url ?: string ;
27+ /** Reputation facts from the same /user response (specs/api/auth.md step 5). */
28+ readonly created_at : string | null ;
29+ readonly public_repos : number | null ;
30+ readonly followers : number | null ;
31+ readonly following : number | null ;
32+ readonly type : string | null ;
33+ }
34+
35+ export type GitHubProbeStatus = 'ok' | 'gone' ;
36+
37+ export interface GitHubProbeResult {
38+ readonly status : GitHubProbeStatus ;
39+ /** Present when status is `ok`. */
40+ readonly user : GitHubUser | null ;
2741}
2842
2943export interface GitHubEmail {
@@ -153,14 +167,65 @@ export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser>
153167 if ( ! body || typeof body . id !== 'number' || typeof body . login !== 'string' ) {
154168 throw new GitHubApiError ( 'GitHub /user returned unexpected shape' , 'github_unreachable' ) ;
155169 }
170+ return toGitHubUser ( body as Partial < GitHubUser > & { id : number ; login : string } ) ;
171+ }
172+
173+ function toGitHubUser ( body : Partial < GitHubUser > & { id : number ; login : string } ) : GitHubUser {
156174 return {
157175 id : body . id ,
158176 login : body . login ,
159177 name : typeof body . name === 'string' ? body . name : null ,
160178 ...( typeof body . avatar_url === 'string' ? { avatar_url : body . avatar_url } : { } ) ,
179+ created_at : typeof body . created_at === 'string' ? body . created_at : null ,
180+ public_repos : typeof body . public_repos === 'number' ? body . public_repos : null ,
181+ followers : typeof body . followers === 'number' ? body . followers : null ,
182+ following : typeof body . following === 'number' ? body . following : null ,
183+ type : typeof body . type === 'string' ? body . type : null ,
161184 } ;
162185}
163186
187+ /**
188+ * Is the linked GitHub account still there? `GET /user/{id}` authenticated
189+ * with the OAuth app's client credentials (5,000 req/h). GitHub answers 404
190+ * once it has deleted or suspended the account; that is the signal. Any other
191+ * non-2xx throws so the caller keeps its previous record rather than
192+ * misreading an outage as a verdict.
193+ */
194+ export async function probeGitHubUser (
195+ githubUserId : number ,
196+ clientId : string ,
197+ clientSecret : string ,
198+ opts : { readonly timeoutMs ?: number } = { } ,
199+ ) : Promise < GitHubProbeResult > {
200+ const url = `https://api.github.com/user/${ githubUserId } ` ;
201+ const basic = Buffer . from ( `${ clientId } :${ clientSecret } ` ) . toString ( 'base64' ) ;
202+ let res : Response ;
203+ try {
204+ res = await fetch ( url , {
205+ method : 'GET' ,
206+ headers : {
207+ Authorization : `Basic ${ basic } ` ,
208+ Accept : 'application/vnd.github+json' ,
209+ 'User-Agent' : USER_AGENT ,
210+ } ,
211+ signal : AbortSignal . timeout ( opts . timeoutMs ?? 4000 ) ,
212+ } ) ;
213+ } catch ( err ) {
214+ throw new GitHubApiError ( `GitHub API transport error: ${ url } ` , 'github_unreachable' , { cause : err } ) ;
215+ }
216+ if ( res . status === 404 ) return { status : 'gone' , user : null } ;
217+ if ( ! res . ok ) {
218+ throw new GitHubApiError ( `GitHub API ${ url } returned ${ res . status } ` , 'github_unreachable' , {
219+ status : res . status ,
220+ } ) ;
221+ }
222+ const body = ( await res . json ( ) . catch ( ( ) => null ) ) as Partial < GitHubUser > | null ;
223+ if ( ! body || typeof body . id !== 'number' || typeof body . login !== 'string' ) {
224+ throw new GitHubApiError ( 'GitHub /user/{id} returned unexpected shape' , 'github_unreachable' ) ;
225+ }
226+ return { status : 'ok' , user : toGitHubUser ( body as Partial < GitHubUser > & { id : number ; login : string } ) } ;
227+ }
228+
164229export async function fetchGitHubEmails ( accessToken : string ) : Promise < GitHubEmail [ ] > {
165230 const body = await ghGet ( EMAILS_URL , accessToken ) ;
166231 if ( ! Array . isArray ( body ) ) {
@@ -197,6 +262,12 @@ export interface ResolvedGitHubIdentity {
197262 readonly name : string | null ;
198263 readonly emails : readonly GitHubEmail [ ] ;
199264 readonly primaryEmail : string | null ;
265+ /**
266+ * The full /user snapshot, kept so sign-in can record reputation facts.
267+ * Absent when the identity was rebuilt from a claim-pending token rather
268+ * than a live GitHub response.
269+ */
270+ readonly user ?: GitHubUser ;
200271}
201272
202273export function resolveIdentitySnapshot (
@@ -211,5 +282,20 @@ export function resolveIdentitySnapshot(
211282 name : user . name ,
212283 emails : verified ,
213284 primaryEmail : primary ?. email . toLowerCase ( ) ?? null ,
285+ user,
286+ } ;
287+ }
288+
289+ /** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */
290+ export function githubFactsFrom ( user : GitHubUser , status : GitHubProbeStatus , checkedAt : string ) {
291+ return {
292+ login : user . login ,
293+ accountCreatedAt : user . created_at ,
294+ publicRepos : user . public_repos ,
295+ followers : user . followers ,
296+ following : user . following ,
297+ type : user . type ,
298+ status,
299+ checkedAt,
214300 } ;
215301}
0 commit comments