2026-10-24 12:17:08 +03:00
"use strict" ;
// LearnHarness-owned public-client OAuth. Never import pi's bundled login.
// This module is main-process-only; dependencies are injectable for scratch tests.
const crypto = require ( "node:crypto" );
const http = require ( "node:http" );
const fs = require ( "node:fs/promises" );
const path = require ( "node:path" );
const ISSUER = "https://auth.openai.com" ;
const RESOURCE = "https://api.openai.com/v1" ;
const AUTHORIZE = ` ${ ISSUER } /api/accounts/authorize` ;
const TOKEN = ` ${ ISSUER } /api/accounts/oauth/token` ;
const JWKS = ` ${ ISSUER } /.well-known/jwks.json` ;
const SCOPES = [ "openid" , "profile" , "email" , "offline_access" , "resource.invoke" , "chatgpt.tokens.use.direct" ];
const random = () => crypto . randomBytes ( 32 ). toString ( "base64url" );
const fail = ( code ) => Object . assign ( new Error ( code ), { code });
const publicError = ( err ) => ({
cancelled : "ChatGPT sign-in cancelled." ,
timeout : "ChatGPT sign-in timed out. Try again." ,
secure_storage : "Secure credential storage is unavailable. ChatGPT sign-in requires the desktop app and an OS secure-storage backend." ,
storage : "ChatGPT credentials could not be saved securely. Reconnect before making requests." ,
identity : "ChatGPT sign-in could not be verified. Select the intended saved account and try again." ,
scope : "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use." ,
2026-10-24 14:02:54 +03:00
disconnected : "This ChatGPT connection is disconnected. Reconnect in Model providers." ,
background : "Background use is off for this ChatGPT account. Enable Allow background use in Model providers, or explicitly select an API-key model for that workload." ,
2026-10-24 12:17:08 +03:00
catalog : "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used." ,
2026-10-24 14:02:54 +03:00
expired : "The ChatGPT session expired. Reconnect in Model providers." ,
2026-10-24 12:17:08 +03:00
network : "ChatGPT could not be reached. Check your connection and try again." ,
pending : "A ChatGPT sign-in is already pending." ,
unavailable : "ChatGPT sign-in is unavailable in browser-only mode." ,
}[ err ? . code ] || "ChatGPT sign-in failed. Try again; no API-key fallback was used." );
const nonempty = ( v ) => typeof v === "string" && v . length > 0 && ! /[\x00-\x20\x7f]/ . test ( v );
const planScopes = ( tokens ) => !! tokens ? . accessToken && SCOPES . every (( scope ) => tokens . scopes . includes ( scope ));
/** Reject the entire encrypted record. Encryption is not schema validation. */
function validateState ( state ) {
if ( ! state || state . version !== 1 || ! nonempty ( state . hostId ) || ! Array . isArray ( state . registrations ) || ! ( state . activeId === null || nonempty ( state . activeId ))) throw fail ( "storage" );
const ids = new Set (); const clients = new Set ();
for ( const r of state . registrations ) {
if ( ! r || ! nonempty ( r . id ) || ids . has ( r . id ) || ! nonempty ( r . clientId ) || r . clientId === "dynamic_agent_client" || clients . has ( r . clientId ) || r . issuer !== ISSUER || ! nonempty ( r . subject ) || typeof r . background !== "boolean" || ( r . email !== undefined && typeof r . email !== "string" ) || ( Object . hasOwn ( r , "reconnectRequired" ) && r . reconnectRequired !== true )) throw fail ( "storage" );
ids . add ( r . id ); clients . add ( r . clientId );
if ( r . tokens !== undefined ) {
const t = r . tokens ;
if ( ! t || ! nonempty ( t . idToken ) || ! Array . isArray ( t . scopes ) || t . scopes . some (( s ) => ! nonempty ( s )) || ! Number . isFinite ( t . expiresAt ) || t . expiresAt < 0 || ( t . accessToken !== undefined && ( ! nonempty ( t . accessToken ) || t . accessToken . startsWith ( "sk-" ))) || ( t . refreshToken !== undefined && ! nonempty ( t . refreshToken ))) throw fail ( "storage" );
}
}
if ( state . activeId !== null && ! ids . has ( state . activeId )) throw fail ( "storage" );
return state ;
}
/** Encrypted envelope only, outside learning data. No plaintext fallback. */
function encryptedStore ( file , safeStorage ) {
function secure () {
if ( ! safeStorage . isEncryptionAvailable () || safeStorage . getSelectedStorageBackend ? .() === "basic_text" ) throw fail ( "secure_storage" );
}
return {
secure ,
async read () {
secure ();
try {
const envelope = JSON . parse ( await fs . readFile ( file , "utf8" ));
if ( envelope . version !== 1 || typeof envelope . encrypted !== "string" || ! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ . test ( envelope . encrypted ) || ! envelope . encrypted ) throw fail ( "storage" );
const state = JSON . parse ( safeStorage . decryptString ( Buffer . from ( envelope . encrypted , "base64" )));
return validateState ( state );
} catch ( err ) {
if ( err . code === "ENOENT" ) return null ;
throw fail ( "storage" );
}
},
async write ( state ) {
secure ();
validateState ( state );
const temp = ` ${ file } . ${ crypto . randomUUID () } .tmp` ;
let handle ;
try {
const encrypted = safeStorage . encryptString ( JSON . stringify ( state )). toString ( "base64" );
await fs . mkdir ( path . dirname ( file ), { recursive : true , mode : 0o700 });
handle = await fs . open ( temp , "wx" , 0o600 );
await handle . writeFile ( JSON . stringify ({ version : 1 , encrypted }));
await handle . sync ();
await handle . close ();
handle = null ;
await fs . rename ( temp , file );
// Directory fsync is supported on Unix; the file fsync above is mandatory.
if ( process . platform !== "win32" ) {
const dir = await fs . open ( path . dirname ( file ), "r" );
try { await dir . sync (); } finally { await dir . close (); }
}
} catch {
throw fail ( "storage" );
} finally {
await handle ? . close (). catch (() => {});
await fs . unlink ( temp ). catch (() => {});
}
},
};
}
function allowedAuthEndpoint ( value ) {
try {
const url = new URL ( value );
return url . origin === ISSUER && ! url . username && ! url . password && ! url . search && ! url . hash ;
} catch { return false ; }
}
function createChatGPT ({ store , openExternal , fetch : request = globalThis . fetch , onChange = () => {}, timeoutMs = 180000 }) {
let state ;
let init ;
let queue = Promise . resolve ();
let epoch = 0 ;
let revision = 0 ;
let authorityRevision = 0 ;
let catalogSequence = 0 ;
let storageDenied = false ;
let secureAvailable = true ;
const stamp = () => ({ epoch , authorityRevision , revision });
const backgroundIntent = new Map ();
let pending ;
let catalog = [];
let catalogError = null ;
let lastError = null ;
let closed = false ;
let paused = false ;
let suspended = false ;
let retryRegistration ;
let transportAbort = new AbortController ();
let backgroundAbort = new AbortController ();
const serial = ( fn ) => {
const next = queue . then ( fn , fn );
queue = next . catch (() => {});
return next ;
};
const active = () => ! storageDenied && ! suspended && ! closed && ! paused ? state ? . registrations . find (( r ) => r . id === state . activeId && r . tokens && ! r . reconnectRequired ) : undefined ;
const backgroundAllowed = ( account ) => account . background === true && backgroundIntent . get ( account . id ) ? . enabled !== false ;
const notify = ( invalidate = "none" ) => { revision ++ ; onChange ({ channel : "lh:chatgpt" , kind : "state" , stamp : stamp (), status : snapshot (), invalidate }); };
function stop ( backgroundOnly = false ) {
if ( backgroundOnly ) {
backgroundAbort . abort ();
backgroundAbort = new AbortController ();
notify ( "background" );
return ;
}
suspended = true ;
epoch ++ ;
authorityRevision ++ ;
catalogSequence ++ ;
transportAbort . abort ();
transportAbort = new AbortController ();
catalog = [];
catalogError = null ;
notify ( "all" );
}
async function ready () {
if ( closed ) throw fail ( "disconnected" );
if ( ! init ) init = ( async () => {
const loaded = await store . read ();
state = loaded ? validateState ( loaded ) : null ;
if ( ! state ) {
const first = { version : 1 , hostId : `urn:uuid: ${ crypto . randomUUID () } ` , activeId : null , registrations : [] };
await store . write ( first );
state = first ;
}
})();
try { await init ; checkSecure (); } catch ( err ) { denyStorage ( err ); throw err ; }
}
function denyStorage ( err ) {
secureAvailable = false ;
if ( ! storageDenied ) { storageDenied = true ; lastError = publicError ( err ); stop (); }
}
function checkSecure () {
try { store . secure (); secureAvailable = true ; } catch ( err ) { denyStorage ( err ); throw err ; }
}
async function save ( next ) {
try { checkSecure (); validateState ( next ); await store . write ( next ); checkSecure (); } catch ( err ) {
// A rotated token may already have invalidated its predecessor. Fail closed.
denyStorage ( err );
lastError = publicError ( err );
notify ();
throw err ;
}
state = next ;
}
function snapshot () {
return {
available : !! state && secureAvailable && ! closed && ! paused ,
pending : !! pending ,
activeId : state ? . activeId ?? null ,
accounts : ( state ? . registrations || []). map (( r ) => ({
id : r . id ,
email : r . email || "ChatGPT account" ,
registration : r . clientId ,
connected : ! storageDenied && ! closed && ! paused && ! suspended && ! r . reconnectRequired && planScopes ( r . tokens ),
planEnabled : ! storageDenied && ! r . reconnectRequired && planScopes ( r . tokens ),
background : backgroundAllowed ( r ),
})),
error : lastError ,
catalogError ,
};
}
async function status () {
try { await ready (); } catch ( err ) { return { ... snapshot (), available : false , error : publicError ( err ) }; }
return snapshot ();
}
async function json ( url , options = {}) {
try {
const response = await request ( url , { ... options , redirect : "error" , signal : options . signal ?? AbortSignal . timeout ( 15000 ) });
if ( ! response . ok ) {
const body = await response . json (). catch (() => ({}));
const oauthCode = typeof body . error === "string" ? body . error : body . error ? . code ;
throw Object . assign ( fail ( oauthCode === "invalid_grant" || response . status === 401 || response . status === 403 ? "expired" : "network" ), { oauthCode });
}
return await response . json ();
} catch ( err ) { throw err . code ? err : fail ( "network" ); }
}
async function identity ( idToken , clientId , nonce , expectedSubject ) {
try {
const { jwtVerify , createLocalJWKSet } = await import ( "jose" );
// Fixed, documented JWKS origin; never follow a discovery-supplied URL.
const keys = await json ( JWKS );
const { payload } = await jwtVerify ( idToken , createLocalJWKSet ( keys ), {
issuer : ISSUER , audience : clientId , algorithms : [ "RS256" ],
requiredClaims : [ "sub" , "exp" , "iat" ], clockTolerance : 5 ,
});
if ( typeof payload . sub !== "string" || ! payload . sub || ( nonce !== undefined && payload . nonce !== nonce ) || ( expectedSubject && payload . sub !== expectedSubject )) throw fail ( "identity" );
if (( payload . azp !== undefined && payload . azp !== clientId ) || ( Array . isArray ( payload . aud ) && payload . aud . length > 1 && payload . azp !== clientId )) throw fail ( "identity" );
return { subject : payload . sub , email : typeof payload . email === "string" ? payload . email : undefined };
} catch { throw fail ( "identity" ); }
}
function tokenSet ( body , previous ) {
const scopes = typeof body . scope === "string" ? body . scope . split ( /\s+/ ) : previous ? . scopes ;
if ( ! scopes || SCOPES . some (( scope ) => ! scopes . includes ( scope ))) {
// Lack of entitlement is not lack of revocable material.
return { idToken : body . id_token ?? previous ? . idToken , scopes : scopes ?? [], expiresAt : 0 ,
...( nonempty ( body . refresh_token ) ? { refreshToken : body . refresh_token } : previous ? . refreshToken ? { refreshToken : previous . refreshToken } : {}) };
}
if ( typeof body . access_token !== "string" || ! body . access_token || body . access_token . startsWith ( "sk-" ) || body . token_type ? . toLowerCase () !== "bearer" || ! Number . isFinite ( body . expires_in ) || body . expires_in <= 0 ) throw fail ( "identity" );
const refreshToken = body . refresh_token ?? previous ? . refreshToken ;
if ( ! nonempty ( refreshToken )) throw fail ( "identity" );
return { accessToken : body . access_token , refreshToken , idToken : body . id_token ?? previous ? . idToken , scopes , expiresAt : Date . now () + body . expires_in * 1000 };
}
async function grant ( fields , signal ) {
return json ( TOKEN , { method : "POST" , headers : { "content-type" : "application/x-www-form-urlencoded" , accept : "application/json" }, body : new URLSearchParams ({ ... fields , resource : RESOURCE }), signal });
}
async function login ( accountId ) {
await ready ();
if ( pending ) throw fail ( "pending" );
if ( paused ) throw fail ( "disconnected" );
const selected = accountId ? state . registrations . find (( r ) => r . id === accountId ) : retryRegistration ;
if ( accountId && ! selected ) throw fail ( "identity" );
const attemptEpoch = epoch ;
const verifier = random ();
const nonce = random ();
const expectedState = random ();
const abort = new AbortController ();
let finish ;
const completion = new Promise (( resolve ) => { finish = resolve ; });
const listener = http . createServer ();
let timer ;
let consumed = false ;
const cleanup = () => { clearTimeout ( timer ); listener . close (); listener . closeAllConnections (); };
const cancel = ( code = "cancelled" ) => {
abort . abort ();
cleanup ();
if ( pending ? . abort === abort ) pending = null ;
lastError = publicError ( fail ( code ));
finish ();
notify ();
};
pending = { abort , cancel };
lastError = null ;
notify ();
try {
await new Promise (( resolve , reject ) => {
listener . once ( "error" , reject );
listener . listen ( 0 , "127.0.0.1" , resolve );
});
const redirectUri = `http://127.0.0.1: ${ listener . address (). port } /auth/callback` ;
listener . on ( "request" , async ( req , res ) => {
let callback ;
try { callback = new URL ( req . url || "/" , redirectUri ); }
catch { res . writeHead ( 400 ). end (); return ; }
res . setHeader ( "Cache-Control" , "no-store" );
res . setHeader ( "Content-Security-Policy" , "default-src 'none'" );
res . setHeader ( "Referrer-Policy" , "no-referrer" );
if ( req . method !== "GET" || callback . origin !== new URL ( redirectUri ). origin || callback . pathname !== "/auth/callback" || req . headers . host !== new URL ( redirectUri ). host || consumed ) { res . writeHead ( 404 ). end (); return ; }
const params = callback . searchParams ;
// Reject duplicate parameters and unsolicited callbacks without consuming a valid pending attempt.
if ([ "state" , "code" , "client_id" , "error" ]. some (( key ) => params . getAll ( key ). length > 1 ) || params . get ( "state" ) !== expectedState ) { res . writeHead ( 400 ). end ( "Sign-in could not be verified." ); return ; }
consumed = true ;
res . writeHead ( 200 , { "content-type" : "text/plain; charset=utf-8" }). end ( "Return to LearnHarness to view the sign-in result. You may close this tab." );
cleanup ();
try {
if ( params . has ( "error" )) throw fail ( params . get ( "error" ) === "access_denied" ? "cancelled" : "identity" );
const clientId = params . get ( "client_id" ) || selected ? . clientId ;
if ( ! clientId || clientId === "dynamic_agent_client" || clientId . length > 512 || /[\s\x00-\x1f]/ . test ( clientId ) || ( selected && clientId !== selected . clientId ) || ! params . get ( "code" )) throw fail ( "identity" );
let body ;
try { body = await grant ({ grant_type : "authorization_code" , client_id : clientId , code : params . get ( "code" ), code_verifier : verifier , redirect_uri : redirectUri }, AbortSignal . any ([ abort . signal , AbortSignal . timeout ( 15000 )])); }
catch ( err ) {
if ( err . oauthCode === "invalid_grant" && ! selected ? . subject ) retryRegistration = { clientId };
throw err ;
}
if ( typeof body . id_token !== "string" ) throw fail ( "identity" );
const verified = await identity ( body . id_token , clientId , nonce , selected ? . subject );
const tokens = tokenSet ( body );
await serial ( async () => {
if ( closed || abort . signal . aborted || epoch !== attemptEpoch ) throw fail ( "cancelled" );
const existing = state . registrations . find (( r ) => r . clientId === clientId );
if ( existing && existing . subject !== verified . subject ) throw fail ( "identity" );
const record = { id : existing ? . id ?? crypto . randomUUID (), clientId , issuer : ISSUER , ... verified , background : existing ? . background === true , tokens };
const next = structuredClone ( state );
next . registrations = next . registrations . filter (( r ) => r . id !== record . id ). concat ( record );
next . activeId = record . id ;
const previous = structuredClone ( state );
await save ( next ); // Active identity changes only AFTER validation and durable encrypted persistence.
if ( closed || abort . signal . aborted || epoch !== attemptEpoch ) {
await save ( previous );
throw fail ( "cancelled" );
}
stop ();
storageDenied = false ;
suspended = false ;
retryRegistration = undefined ;
});
lastError = planScopes ( tokens ) ? null : publicError ( fail ( "scope" ));
} catch ( err ) { if ( ! abort . signal . aborted ) lastError = publicError ( err ); }
finally { if ( pending ? . abort === abort ) pending = null ; finish (); notify (); }
});
timer = setTimeout (() => cancel ( "timeout" ), timeoutMs );
const url = new URL ( AUTHORIZE );
url . search = new URLSearchParams ({
client_id : selected ? . clientId ?? "dynamic_agent_client" ,
...( selected ? {} : { agent_name_hint : "LearnHarness" }),
ext_agent_host_id : state . hostId ,
...( selected ? . tokens ? . idToken ? { id_token_hint : selected . tokens . idToken } : {}),
...( selected ? . email ? { login_hint : selected . email } : {}),
response_type : "code" , redirect_uri : redirectUri , scope : SCOPES . join ( " " ), resource : RESOURCE ,
state : expectedState , nonce , code_challenge_method : "S256" ,
code_challenge : crypto . createHash ( "sha256" ). update ( verifier ). digest ( "base64url" ),
}). toString ();
await openExternal ( url . toString ()); // Trusted main only. Never return this URL to the renderer.
} catch ( err ) { cancel ( err . code || "network" ); }
void completion ;
return snapshot ();
}
function cancelLogin () { pending ? . cancel (); return snapshot (); }
function authorized ( background , expected , capturedEpoch ) {
checkSecure ();
const account = active ();
if ( ! account || ( capturedEpoch !== undefined && capturedEpoch !== epoch )) throw fail ( "disconnected" );
if ( ! planScopes ( account . tokens )) throw fail ( "scope" );
if ( expected && ( expected . expectedAccountId !== account . id || expected . expectedAuthorityRevision !== authorityRevision || ! catalog . some (( m ) => m . id === expected . selectedModelId ))) throw fail ( "catalog" );
if ( background && ! backgroundAllowed ( account )) throw fail ( "background" );
return account ;
}
async function credential ( background , expected ) {
await ready ();
return serial ( async () => {
const requestEpoch = epoch ;
const account = authorized ( background , expected );
if ( account . tokens . expiresAt <= Date . now () + 60000 ) {
const refreshEpoch = epoch ;
const signal = AbortSignal . any ([ transportAbort . signal , ...( background ? [ backgroundAbort . signal ] : []), AbortSignal . timeout ( 15000 )]);
let body ;
try { body = await grant ({ grant_type : "refresh_token" , client_id : account . clientId , refresh_token : account . tokens . refreshToken }, signal ); }
catch ( err ) {
if ( err . oauthCode === "invalid_grant" ) {
const next = structuredClone ( state );
delete next . registrations . find (( r ) => r . id === account . id ). tokens ;
next . activeId = null ;
await save ( next );
stop ();
notify ();
}
throw err ;
}
let tokens ;
try {
if ( body . id_token !== undefined ) await identity ( body . id_token , account . clientId , undefined , account . subject );
tokens = tokenSet ( body , account . tokens );
} catch ( err ) {
// The fixed token endpoint may have rotated already. Preserve only
// verified identity and a single likely-latest revocation handle.
stop ();
const restricted = structuredClone ( state );
const record = restricted . registrations . find (( r ) => r . id === account . id );
record . reconnectRequired = true ;
if ( nonempty ( body . refresh_token )) record . tokens . refreshToken = body . refresh_token ;
await save ( restricted );
lastError = publicError ( fail ( "identity" )); notify ();
throw err ;
}
// A successful remote rotation must be durably retained even when
// logout, account selection, consent revocation, or pause denies use.
// Those serialized operations will revoke/clear the LATEST token.
const next = structuredClone ( state );
next . registrations . find (( r ) => r . id === account . id ). tokens = tokens ;
await save ( next );
if ( closed || refreshEpoch !== epoch || signal . aborted ) throw fail ( "disconnected" );
}
const current = authorized ( background , expected , requestEpoch );
return { accessToken : current . tokens . accessToken , accountId : current . id , ... stamp () };
});
}
async function lease ( background , expected ) {
if ( typeof background !== "boolean" || ! expected || ! nonempty ( expected . expectedAccountId ) || ! Number . isSafeInteger ( expected . expectedAuthorityRevision ) || ! nonempty ( expected . selectedModelId )) throw fail ( "catalog" );
return credential ( background , expected );
}
const modelValue = () => ({ ... stamp (), status : snapshot (), accountId : active () ? . id ?? null , models : catalog });
async function models () {
const sequence = ++ catalogSequence ;
try {
await ready ();
if ( ! active ()) return modelValue ();
// Discovery is identity/scope authorization, not an invented model lease.
const token = await credential ( false );
const body = await json ( ` ${ RESOURCE } /models` , { headers : { Authorization : `Bearer ${ token . accessToken } ` }, signal : AbortSignal . any ([ transportAbort . signal , AbortSignal . timeout ( 15000 )]) });
authorized ( false , undefined , token . epoch );
if ( sequence !== catalogSequence || active () ? . id !== token . accountId || ! Array . isArray ( body . models )) throw fail ( "catalog" );
const visible = body . models . filter (( m ) => m && m . visibility === "list" && nonempty ( m . slug ) && typeof m . display_name === "string" ). map (( m ) => ({ id : m . slug , name : m . display_name }));
const changed = JSON . stringify ( visible ) !== JSON . stringify ( catalog );
catalog = visible ;
if ( changed ) authorityRevision ++ ;
const hadError = catalogError !== null ; catalogError = null ;
if ( changed || hadError ) notify ();
return modelValue ();
} catch {
if ( sequence === catalogSequence ) {
if ( catalog . length ) { catalog = []; authorityRevision ++ ; }
catalogError = publicError ( fail ( "catalog" )); notify ();
}
return modelValue ();
}
}
async function select ( id ) {
pending ? . cancel ();
stop ();
const selectionIntent = epoch ;
await ready ();
return serial ( async () => {
const account = state . registrations . find (( r ) => r . id === id );
if ( ! account ? . tokens ) throw fail ( "disconnected" );
await save ({ ... state , activeId : id });
if ( epoch !== selectionIntent || closed || paused || storageDenied ) throw fail ( "disconnected" );
suspended = false ;
lastError = null ;
notify ();
return snapshot ();
});
}
async function consent ( id , enabled ) {
if ( typeof enabled !== "boolean" ) throw fail ( "identity" );
const intent = { enabled : false , version : ( backgroundIntent . get ( id ) ? . version ?? 0 ) + 1 };
backgroundIntent . set ( id , intent );
if ( ! enabled ) {
const account = state ? . registrations . find (( r ) => r . id === id );
if ( account ) account . background = false ;
stop ( true ); // Revoke background calls immediately; foreground stays usable.
}
await ready ();
return serial ( async () => {
const next = structuredClone ( state );
const account = next . registrations . find (( r ) => r . id === id );
if ( ! account ) throw fail ( "identity" );
account . background = enabled ;
await save ( next );
if ( backgroundIntent . get ( id ) === intent && enabled ) backgroundIntent . delete ( id );
notify ();
return snapshot ();
});
}
async function logout ( id ) {
pending ? . cancel ();
stop ();
await ready ();
return serial ( async () => {
const account = state . registrations . find (( r ) => r . id === id );
if ( ! account ) throw fail ( "identity" );
let revoked = false ;
try {
if ( account . tokens ? . refreshToken ) {
const config = await json ( ` ${ ISSUER } /.well-known/openid-configuration` );
if ( config . issuer !== ISSUER || ! allowedAuthEndpoint ( config . revocation_endpoint )) throw fail ( "identity" );
for ( let attempt = 0 ; attempt < 2 ; attempt ++ ) {
if ( attempt ) await new Promise (( resolve ) => setTimeout ( resolve , 200 ));
try {
const response = await request ( config . revocation_endpoint , { method : "POST" , redirect : "error" , signal : AbortSignal . timeout ( 5000 ), headers : { "content-type" : "application/x-www-form-urlencoded" }, body : new URLSearchParams ({ token : account . tokens . refreshToken , token_type_hint : "refresh_token" , client_id : account . clientId }) });
revoked = response . status === 200 ;
if ( revoked || response . status < 500 ) break ;
} catch { /* retry only while the refresh token is still in memory */ }
}
}
} catch { /* local sign-out is mandatory even when revocation is unavailable */ }
const next = structuredClone ( state );
delete next . registrations . find (( r ) => r . id === id ). tokens ;
if ( next . activeId === id ) next . activeId = null ;
const logoutEpoch = epoch ;
await save ( next );
if ( ! closed && epoch === logoutEpoch ) suspended = false ;
lastError = revoked ? null : "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings." ;
notify ();
return snapshot ();
});
}
function close () { closed = true ; pending ? . cancel (); stop (); }
function pause () { paused = true ; pending ? . cancel (); stop (); notify (); }
function resume () { paused = false ; if ( ! storageDenied ) suspended = false ; notify (); }
return { status , login , cancelLogin , lease , models , select , consent , logout , close , pause , resume , stamp };
}
module . exports = { createChatGPT , encryptedStore , publicError , allowedAuthEndpoint , SCOPES , validateState };