@@ -18,6 +18,7 @@ const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.dev/clo
1818const TOKEN_EXPIRY_BUFFER_MS = 60_000
1919const ACCOUNT_WORKSPACE_RETRY_ATTEMPTS = 8
2020const ACCOUNT_WORKSPACE_RETRY_DELAY_MS = 500
21+ const warnedWhoamiWorkspaceFailures = new Set < string > ( )
2122
2223interface AuthStatus {
2324 loggedIn : boolean
@@ -26,7 +27,8 @@ interface AuthStatus {
2627}
2728
2829type AccountWorkspaceCache = {
29- tokenHash : string
30+ accountKey ?: string
31+ tokenHash ?: string
3032 workspaceId : string
3133}
3234
@@ -128,16 +130,35 @@ function accountWorkspaceTokenHash(accessToken: string): string {
128130 return createHash ( 'sha256' ) . update ( accessToken ) . digest ( 'hex' )
129131}
130132
133+ function accountWorkspaceCacheMatches (
134+ cached : AccountWorkspaceCache | undefined ,
135+ auth : Pick < CloudAuth , 'accountKey' | 'accessToken' >
136+ ) : boolean {
137+ if ( ! cached ?. workspaceId . trim ( ) ) return false
138+ return cached . accountKey === auth . accountKey ||
139+ cached . tokenHash === accountWorkspaceTokenHash ( auth . accessToken )
140+ }
141+
131142function delay ( ms : number ) : Promise < void > {
132143 return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) )
133144}
134145
135146function saveAuthMeta ( tokens : Pick < StoredTokens , 'apiUrl' | 'user' > & Partial < Pick < StoredTokens , 'accessToken' > > ) : void {
136147 const previous = loadAuthMeta ( )
148+ const accountKey = tokens . accessToken
149+ ? deriveCloudAuthAccountKey ( tokens . apiUrl , tokens . accessToken , tokens . user )
150+ : undefined
137151 const tokenHash = tokens . accessToken ? accountWorkspaceTokenHash ( tokens . accessToken ) : undefined
138152 const accountWorkspace =
139- tokenHash && previous . accountWorkspace ?. tokenHash === tokenHash
140- ? previous . accountWorkspace
153+ accountKey && accountWorkspaceCacheMatches ( previous . accountWorkspace , {
154+ accountKey,
155+ accessToken : tokens . accessToken || ''
156+ } )
157+ ? {
158+ ...previous . accountWorkspace ,
159+ accountKey,
160+ ...( tokenHash ? { tokenHash } : { } )
161+ }
141162 : undefined
142163 const meta = {
143164 apiUrl : tokens . apiUrl ,
@@ -240,7 +261,21 @@ function clearTokens(): void {
240261 }
241262}
242263
243- async function fetchWhoamiPayload ( apiUrl : string , accessToken : string ) : Promise < unknown | undefined > {
264+ type WhoamiPayloadResult =
265+ | { ok : true ; data : unknown }
266+ | { ok : false ; failureClass : string ; status ?: number }
267+
268+ function whoamiFailureClassForStatus ( status : number ) : string {
269+ return `whoami-http-${ status } `
270+ }
271+
272+ function warnWhoamiWorkspaceFailure ( failureClass : string ) : void {
273+ if ( warnedWhoamiWorkspaceFailures . has ( failureClass ) ) return
274+ warnedWhoamiWorkspaceFailures . add ( failureClass )
275+ console . warn ( '[auth] Account workspace whoami lookup failed:' , { failureClass } )
276+ }
277+
278+ async function fetchWhoamiPayload ( apiUrl : string , accessToken : string ) : Promise < WhoamiPayloadResult > {
244279 const controller = new AbortController ( )
245280 const timeout = setTimeout ( ( ) => controller . abort ( ) , 2500 )
246281
@@ -249,10 +284,21 @@ async function fetchWhoamiPayload(apiUrl: string, accessToken: string): Promise<
249284 headers : { Authorization : `Bearer ${ accessToken } ` } ,
250285 signal : controller . signal
251286 } )
252- if ( ! res . ok ) return undefined
253- return await res . json ( ) as unknown
254- } catch {
255- return undefined
287+ if ( ! res . ok ) {
288+ return {
289+ ok : false ,
290+ status : res . status ,
291+ failureClass : whoamiFailureClassForStatus ( res . status )
292+ }
293+ }
294+ return { ok : true , data : await res . json ( ) as unknown }
295+ } catch ( error ) {
296+ return {
297+ ok : false ,
298+ failureClass : error instanceof Error && error . name === 'AbortError'
299+ ? 'whoami-timeout'
300+ : 'whoami-network'
301+ }
256302 } finally {
257303 clearTimeout ( timeout )
258304 }
@@ -277,6 +323,7 @@ function saveAccountWorkspaceCache(auth: CloudAuth, workspaceId: string): void {
277323 apiUrl : auth . apiUrl || previous . apiUrl ?. trim ( ) || CLOUD_API_URL ,
278324 user : previous . user ,
279325 accountWorkspace : {
326+ accountKey : auth . accountKey ,
280327 tokenHash : accountWorkspaceTokenHash ( auth . accessToken ) ,
281328 workspaceId
282329 }
@@ -286,7 +333,9 @@ function saveAccountWorkspaceCache(auth: CloudAuth, workspaceId: string): void {
286333
287334async function fetchWhoami ( apiUrl : string , accessToken : string ) : Promise < UserInfo | undefined > {
288335 try {
289- const data = await fetchWhoamiPayload ( apiUrl , accessToken )
336+ const payload = await fetchWhoamiPayload ( apiUrl , accessToken )
337+ if ( ! payload . ok ) return undefined
338+ const data = payload . data
290339 const record = isRecord ( data ) ? data : { }
291340 const userRecord = firstObject ( record , [ 'user' ] ) || record
292341 const organizationRecord = firstObject ( record , [ 'organization' , 'org' ] )
@@ -646,24 +695,36 @@ export async function getAccountWorkspaceId(options: AccountWorkspaceIdOptions =
646695 const auth = await resolveCloudAuth ( )
647696 if ( ! auth ) throw new Error ( 'cloud-auth-required' )
648697
649- const tokenHash = accountWorkspaceTokenHash ( auth . accessToken )
650698 const cached = loadAuthMeta ( ) . accountWorkspace
651- if ( cached ?. tokenHash === tokenHash && cached . workspaceId . trim ( ) ) {
699+ if ( accountWorkspaceCacheMatches ( cached , auth ) ) {
652700 return cached . workspaceId . trim ( )
653701 }
654702
655703 const retryAttempts = Math . max ( 1 , Math . floor ( options . retryAttempts ?? 1 ) )
656704 const retryDelayMs = Math . max ( 0 , Math . floor ( options . retryDelayMs ?? ACCOUNT_WORKSPACE_RETRY_DELAY_MS ) )
657705 let workspaceId : string | undefined
706+ let failureClass = 'whoami-no-workspace-in-payload'
658707
659708 for ( let attempt = 1 ; attempt <= retryAttempts ; attempt += 1 ) {
660- const data = await fetchWhoamiPayload ( auth . apiUrl , auth . accessToken )
661- workspaceId = accountWorkspaceIdFromWhoami ( data )
709+ const payload = await fetchWhoamiPayload ( auth . apiUrl , auth . accessToken )
710+ if ( ! payload . ok ) {
711+ failureClass = payload . failureClass
712+ if ( payload . status === 401 || payload . status === 403 ) {
713+ warnWhoamiWorkspaceFailure ( failureClass )
714+ throw new Error ( `cloud-auth-required:${ failureClass } ` )
715+ }
716+ } else {
717+ workspaceId = accountWorkspaceIdFromWhoami ( payload . data )
718+ failureClass = workspaceId ? '' : 'whoami-no-workspace-in-payload'
719+ }
662720 if ( workspaceId ) break
663721 if ( attempt < retryAttempts ) await delay ( retryDelayMs )
664722 }
665723
666- if ( ! workspaceId ) throw new Error ( 'account-workspace-required' )
724+ if ( ! workspaceId ) {
725+ warnWhoamiWorkspaceFailure ( failureClass )
726+ throw new Error ( `account-workspace-required:${ failureClass } ` )
727+ }
667728
668729 saveAccountWorkspaceCache ( auth , workspaceId )
669730 return workspaceId
0 commit comments