The 1st decentralized social network for sharing when you're on the toilet. Post a "flush" today! Powered by the AT Protocol.
at main 928 lines 34 kB view raw
1import { exportJWK, generateDPoPToken } from './bluesky-auth'; 2 3// Bluesky API utilities 4const DEFAULT_API_URL = 'https://public.api.bsky.app/xrpc'; 5 6// Create a custom lexicon schema for "im.flushing.right.now" 7// This would normally be registered with the AT Protocol 8export const FLUSHING_STATUS_NSID = 'im.flushing.right.now'; 9 10export interface FlushingRecord { 11 $type: typeof FLUSHING_STATUS_NSID; 12 text: string; 13 emoji: string; 14 createdAt: string; 15} 16 17// Check if a JWT token is expired 18export function isTokenExpired(token: string): boolean { 19 try { 20 // Extract the payload from the JWT token 21 const parts = token.split('.'); 22 if (parts.length !== 3) { 23 console.error('Invalid token format'); 24 return true; // Assume expired if format is invalid 25 } 26 27 // Decode the payload 28 const payload = JSON.parse(atob(parts[1])); 29 30 // Check if the token has an expiration time 31 if (!payload.exp) { 32 console.warn('Token does not have an expiration time'); 33 return false; // Can't determine if it's expired 34 } 35 36 // Check if the token is expired 37 const now = Math.floor(Date.now() / 1000); 38 const isExpired = payload.exp <= now; 39 40 if (isExpired) { 41 console.log(`Token expired at ${new Date(payload.exp * 1000).toISOString()}`); 42 } 43 44 // We also want to proactively refresh tokens that will expire soon (within 5 minutes) 45 const expiresInSeconds = payload.exp - now; 46 const isExpiringSoon = expiresInSeconds > 0 && expiresInSeconds < 300; // 5 minutes 47 48 if (isExpiringSoon) { 49 console.log(`Token will expire soon: ${expiresInSeconds} seconds remaining`); 50 } 51 52 return isExpired || isExpiringSoon; 53 } catch (error) { 54 console.error('Error checking token expiration:', error); 55 return true; // Assume expired if there's an error 56 } 57} 58 59// Refresh an access token using the refresh token 60export async function refreshAccessToken( 61 refreshToken: string, 62 keyPair: CryptoKeyPair, 63 pdsEndpoint: string 64): Promise<{ 65 accessToken: string; 66 refreshToken: string; 67 dpopNonce?: string; 68}> { 69 try { 70 if (!pdsEndpoint) { 71 throw new Error('No PDS endpoint provided for token refresh'); 72 } 73 74 console.log('[TOKEN REFRESH] Refreshing token for PDS:', pdsEndpoint); 75 76 // CRITICAL FIX: Token refresh endpoint selection based on PDS type 77 let authServer = pdsEndpoint; 78 79 // For bsky.network PDSes, use public.api.bsky.app 80 if (pdsEndpoint.includes('bsky.network')) { 81 console.log('[TOKEN REFRESH] Using public.api.bsky.app for bsky.network PDS'); 82 authServer = 'https://public.api.bsky.app'; 83 } else if (pdsEndpoint.includes('public.api.bsky.app')) { 84 // Already using public.api.bsky.app 85 console.log('[TOKEN REFRESH] Using public.api.bsky.app directly'); 86 } else { 87 // For third-party PDSes, use their own endpoint for token refresh 88 console.log('[TOKEN REFRESH] Using third-party PDS\'s own endpoint for token refresh:', pdsEndpoint); 89 // Keep authServer as the original PDS endpoint for third-party servers 90 } 91 92 // Endpoint for token refresh 93 const tokenEndpoint = `${authServer}/oauth/token`; 94 95 // For third-party PDS, directly get nonce from PDS endpoint 96 // This is critical because third-party PDSes need their own specific nonce 97 let dpopNonce = null; 98 99 // Special handling for third-party PDS token refresh 100 if (!authServer.includes('public.api.bsky.app') && !authServer.includes('bsky.network')) { 101 try { 102 // For third-party PDS, use a two-step approach to get the valid nonce: 103 console.log('[TOKEN REFRESH] Direct nonce retrieval from third-party PDS'); 104 105 // Step 1: Send an empty token refresh request to get a nonce error 106 // This ensures we get the exact format of nonce the PDS expects 107 console.log('[TOKEN REFRESH] Step 1: Sending probe request to get nonce'); 108 const probeResponse = await fetch(tokenEndpoint, { 109 method: 'POST', 110 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 111 body: new URLSearchParams({ 112 'grant_type': 'refresh_token', 113 'refresh_token': refreshToken, 114 'client_id': 'https://flushes.app/oauth-client-metadata.json' 115 }) 116 }); 117 118 // Get the nonce from the error response 119 const probeNonce = probeResponse.headers.get('DPoP-Nonce'); 120 if (probeNonce) { 121 console.log('[TOKEN REFRESH] Got DPoP-Nonce from probe response:', probeNonce); 122 dpopNonce = probeNonce; 123 } else { 124 // Try to parse the response body for a nonce in the error message 125 let probeText = ''; 126 try { 127 // First try to get the response as text to avoid consuming the body 128 probeText = await probeResponse.text(); 129 130 // Then try to parse it as JSON 131 try { 132 const probeData = JSON.parse(probeText); 133 if (probeData.error === 'use_dpop_nonce' && probeData.nonce) { 134 console.log('[TOKEN REFRESH] Got nonce from error body:', probeData.nonce); 135 dpopNonce = probeData.nonce; 136 } 137 } catch (jsonError) { 138 console.warn('[TOKEN REFRESH] Failed to parse probe response as JSON:', jsonError); 139 } 140 } catch (e) { 141 console.warn('[TOKEN REFRESH] Failed to get probe response text:', e); 142 } 143 } 144 } catch (directError) { 145 console.warn('[TOKEN REFRESH] Direct nonce retrieval failed:', directError); 146 } 147 } 148 149 // Fall back to standard nonce retrieval methods if direct method failed 150 if (!dpopNonce) { 151 try { 152 // Try server-side nonce retrieval first 153 console.log('[TOKEN REFRESH] Getting fresh nonce from server API'); 154 const nonceResponse = await fetch('/api/auth/nonce', { 155 method: 'POST', 156 headers: { 'Content-Type': 'application/json' }, 157 body: JSON.stringify({ pdsEndpoint: authServer }) // Use the correct server 158 }); 159 160 if (nonceResponse.ok) { 161 const nonceData = await nonceResponse.json(); 162 if (nonceData.nonce) { 163 dpopNonce = nonceData.nonce; 164 console.log('[TOKEN REFRESH] Got fresh nonce from server API:', dpopNonce); 165 } 166 } 167 168 // If server-side retrieval fails, try client-side 169 if (!dpopNonce) { 170 console.log('[TOKEN REFRESH] Trying HEAD request for nonce'); 171 const headResponse = await fetch(tokenEndpoint, { method: 'HEAD' }); 172 dpopNonce = headResponse.headers.get('DPoP-Nonce'); 173 } 174 175 // If still no nonce, try POST probe 176 if (!dpopNonce) { 177 console.log('[TOKEN REFRESH] Trying POST probe for nonce'); 178 const probeResponse = await fetch(tokenEndpoint, { 179 method: 'POST', 180 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 181 body: new URLSearchParams({}) // Empty body to trigger error response with nonce 182 }); 183 dpopNonce = probeResponse.headers.get('DPoP-Nonce'); 184 } 185 } catch (nonceError) { 186 console.warn('[TOKEN REFRESH] Failed to get initial nonce:', nonceError); 187 } 188 } 189 190 if (!dpopNonce) { 191 console.log('[TOKEN REFRESH] No nonce obtained, proceeding without one'); 192 } else { 193 console.log('[TOKEN REFRESH] Obtained nonce:', dpopNonce); 194 } 195 196 // Generate DPoP token for the refresh request 197 const publicKey = await exportJWK(keyPair.publicKey); 198 const dpopToken = await generateDPoPToken( 199 keyPair.privateKey, 200 publicKey, 201 'POST', 202 tokenEndpoint, 203 dpopNonce || undefined 204 ); 205 206 console.log('[TOKEN REFRESH] Making token refresh request'); 207 208 // Make the token refresh request 209 const response = await fetch(tokenEndpoint, { 210 method: 'POST', 211 headers: { 212 'Content-Type': 'application/x-www-form-urlencoded', 213 'DPoP': dpopToken 214 }, 215 body: new URLSearchParams({ 216 'grant_type': 'refresh_token', 217 'refresh_token': refreshToken, 218 'client_id': 'https://flushes.app/oauth-client-metadata.json' 219 }) 220 }); 221 222 // Handle nonce error explicitly - this is the critical part! 223 if (response.status === 401 || response.status === 400) { 224 let responseBody; 225 try { 226 responseBody = await response.json(); 227 } catch (e) { 228 responseBody = {}; 229 } 230 231 // Try to get nonce from multiple sources 232 let newNonce = response.headers.get('DPoP-Nonce'); 233 234 // Also check for nonce in the response body (some PDSes return it there) 235 if (!newNonce && responseBody.nonce) { 236 newNonce = responseBody.nonce; 237 console.log('[TOKEN REFRESH] Found nonce in response body:', newNonce); 238 } 239 240 // Some servers use DPoP-Nonce header instead of nonce in body 241 if (!newNonce && response.headers.get('DPoP-Nonce')) { 242 newNonce = response.headers.get('DPoP-Nonce'); 243 console.log('[TOKEN REFRESH] Found DPoP-Nonce in response headers:', newNonce); 244 } 245 246 // Check for DPoP nonce error 247 const isNonceError = 248 responseBody.error === 'use_dpop_nonce' || 249 responseBody.error === 'invalid_dpop_proof' || 250 (responseBody.error_description && ( 251 responseBody.error_description.includes('nonce') || 252 responseBody.error_description.includes('DPoP') 253 )); 254 255 if (isNonceError && newNonce) { 256 console.log('[TOKEN REFRESH] Received nonce error, retrying with new nonce:', newNonce); 257 258 // Generate new DPoP token with the provided nonce 259 const retryDpopToken = await generateDPoPToken( 260 keyPair.privateKey, 261 publicKey, 262 'POST', 263 tokenEndpoint, 264 newNonce 265 ); 266 267 console.log('[TOKEN REFRESH] Retrying token refresh with new nonce'); 268 269 // Retry the request with the new nonce 270 const retryResponse = await fetch(tokenEndpoint, { 271 method: 'POST', 272 headers: { 273 'Content-Type': 'application/x-www-form-urlencoded', 274 'DPoP': retryDpopToken 275 }, 276 body: new URLSearchParams({ 277 'grant_type': 'refresh_token', 278 'refresh_token': refreshToken, 279 'client_id': 'https://flushes.app/oauth-client-metadata.json' 280 }) 281 }); 282 283 // Read the retry response body ONLY ONCE and store it 284 let retryData; 285 let retryErrorMessage = ''; 286 287 try { 288 // Try to parse as JSON first 289 retryData = await retryResponse.json(); 290 retryErrorMessage = JSON.stringify(retryData); 291 } catch (e) { 292 console.warn('[TOKEN REFRESH] Failed to parse retry response as JSON'); 293 retryData = {}; // Default to empty object 294 retryErrorMessage = 'Non-JSON response'; 295 } 296 297 if (!retryResponse.ok) { 298 console.error('[TOKEN REFRESH] Token refresh retry failed:', retryResponse.status, retryErrorMessage); 299 throw new Error(`Token refresh retry failed: ${retryResponse.status}, ${retryErrorMessage}`); 300 } 301 302 console.log('[TOKEN REFRESH] Successfully refreshed token on retry'); 303 304 // Store the new nonce for future requests 305 if (typeof localStorage !== 'undefined') { 306 localStorage.setItem('dpopNonce', newNonce); 307 } 308 309 // Return the new tokens and nonce 310 return { 311 accessToken: retryData.access_token, 312 refreshToken: retryData.refresh_token || refreshToken, 313 dpopNonce: newNonce 314 }; 315 } 316 } 317 318 // Read the response body ONLY ONCE and store it 319 let responseData; 320 let errorMessage = ''; 321 322 try { 323 // Try to parse as JSON first 324 responseData = await response.json(); 325 errorMessage = JSON.stringify(responseData); 326 } catch (e) { 327 // If JSON parsing fails, handle as regular text 328 console.warn('[TOKEN REFRESH] Failed to parse response as JSON, will use raw response'); 329 responseData = {}; // Default to empty object 330 errorMessage = 'Non-JSON response'; 331 } 332 333 if (!response.ok) { 334 console.error('[TOKEN REFRESH] Token refresh failed:', response.status, errorMessage); 335 throw new Error(`Token refresh failed: ${response.status}, ${errorMessage}`); 336 } 337 338 // Get any nonce from the response headers 339 const responseNonce = response.headers.get('DPoP-Nonce'); 340 341 console.log('[TOKEN REFRESH] Successfully refreshed access token'); 342 343 // Update the nonce in localStorage 344 if (responseNonce && typeof localStorage !== 'undefined') { 345 localStorage.setItem('dpopNonce', responseNonce); 346 } 347 348 // Return the new tokens and nonce 349 return { 350 accessToken: responseData.access_token, 351 refreshToken: responseData.refresh_token || refreshToken, 352 dpopNonce: responseNonce || dpopNonce 353 }; 354 } catch (error) { 355 console.error('[TOKEN REFRESH] Error refreshing access token:', error); 356 throw error; 357 } 358} 359 360// Check if authentication is valid by making a simple request 361export async function checkAuth( 362 accessToken: string, 363 keyPair: CryptoKeyPair, 364 did: string, 365 dpopNonce: string | null = null, 366 pdsEndpoint: string | null = null, 367 refreshToken: string | null = null // Add refresh token parameter 368): Promise<boolean> { 369 try { 370 if (!pdsEndpoint) { 371 console.error('No PDS endpoint provided for auth check'); 372 return false; 373 } 374 375 if (!did) { 376 console.error('No DID provided for auth check'); 377 return false; 378 } 379 380 // For API calls, use the actual PDS endpoint 381 // For API calls, always use the user's actual PDS endpoint 382 let authServer = pdsEndpoint; 383 384 // Special case for token refresh only (not normal API calls) 385 // Only bsky.network PDSes should redirect to public.api.bsky.app 386 if (pdsEndpoint.includes('bsky.network')) { 387 console.log('[AUTH CHECK] Will use public.api.bsky.app for OAuth on bsky.network PDS'); 388 authServer = 'https://public.api.bsky.app'; 389 } else { 390 console.log('[AUTH CHECK] Using the actual PDS endpoint for auth:', pdsEndpoint); 391 } 392 393 // TEMPORARILY DISABLED TOKEN REFRESH 394 // This fixes issues with third-party PDSs 395 const tokenExpired = isTokenExpired(accessToken); 396 if (tokenExpired) { 397 console.log('[AUTH CHECK] Token is expired but refresh is temporarily disabled'); 398 console.log('[AUTH CHECK] User may need to re-authenticate if operations fail'); 399 // Skip refresh and continue with current token 400 } 401 402 console.log('Checking auth with PDS endpoint:', pdsEndpoint); 403 404 // For API calls, use the actual PDS endpoint 405 const baseUrl = `${pdsEndpoint}/xrpc`; 406 407 // First, get the user's handle from their DID using repo.describeRepo 408 const describeRepoEndpoint = `${baseUrl}/com.atproto.repo.describeRepo`; 409 const describeRepoUrl = `${describeRepoEndpoint}?repo=${encodeURIComponent(did)}`; 410 411 console.log(`Checking user identity with: ${describeRepoUrl}`); 412 413 // We'll use repo.describeRepo first to get user info 414 const url = describeRepoUrl; 415 416 // Generate DPoP token with the full URL including query params 417 const publicKey = await exportJWK(keyPair.publicKey); 418 const dpopToken = await generateDPoPToken( 419 keyPair.privateKey, 420 publicKey, 421 'GET', 422 url, 423 dpopNonce || undefined, 424 accessToken // Pass the access token for ath claim 425 ); 426 427 console.log('Making auth check request to:', url); 428 429 // Make the request to check auth 430 const response = await fetch(url, { 431 method: 'GET', 432 headers: { 433 'Authorization': `DPoP ${accessToken}`, 434 'DPoP': dpopToken 435 } 436 }); 437 438 if (response.ok) { 439 console.log('Auth check successful!'); 440 return true; 441 } 442 443 // Log detailed error information and store the response body text ONCE 444 let responseText = ''; 445 // Define responseBody with appropriate type that includes optional error fields 446 let responseBody: { 447 error?: string; 448 error_description?: string; 449 message?: string; 450 [key: string]: any 451 } = {}; 452 453 try { 454 responseText = await response.text(); 455 console.error('Auth check response:', response.status, response.statusText); 456 console.error('Auth check error data:', responseText); 457 458 // Try to parse as JSON if it looks like JSON 459 if (responseText.trim().startsWith('{')) { 460 try { 461 responseBody = JSON.parse(responseText); 462 } catch (jsonError) { 463 console.warn('Error data is not valid JSON, using as text'); 464 } 465 } 466 } catch (parseError) { 467 console.error('Could not read response body:', parseError); 468 } 469 470 if (response.status === 401) { 471 // We already have the response body from above 472 console.log('Handling 401 error in auth check'); 473 474 const nonce = response.headers.get('DPoP-Nonce'); 475 if (nonce) { 476 console.log('[AUTH CHECK] Got nonce during auth check:', nonce); 477 478 // Store the nonce for future use 479 if (typeof localStorage !== 'undefined') { 480 localStorage.setItem('dpopNonce', nonce); 481 } 482 483 // Check if this is a nonce error 484 if ((responseBody?.error === 'use_dpop_nonce') || 485 (responseBody?.error_description && responseBody.error_description.includes('nonce'))) { 486 console.log('[AUTH CHECK] DPoP nonce error detected, retrying with new nonce'); 487 } 488 489 // Try again with the nonce, but prevent infinite recursion 490 return checkAuth(accessToken, keyPair, did, nonce, pdsEndpoint, refreshToken); 491 } 492 493 // If we have a refresh token, try to refresh the access token 494 if (refreshToken && !tokenExpired) { // Only try this if we didn't already try above 495 console.log('[AUTH CHECK] Auth failed with 401, attempting to refresh token...'); 496 497 try { 498 // Try to refresh the token with enhanced error handling 499 // Follow the same server selection logic as in refreshAccessToken 500 let refreshAuthServer = pdsEndpoint; 501 502 // For bsky.network PDSes, use public.api.bsky.app 503 if (pdsEndpoint.includes('bsky.network')) { 504 console.log('[AUTH CHECK] Will use public.api.bsky.app for bsky.network PDS'); 505 refreshAuthServer = 'https://public.api.bsky.app'; 506 } else if (pdsEndpoint.includes('public.api.bsky.app')) { 507 // Already using public.api.bsky.app 508 console.log('[AUTH CHECK] Will use public.api.bsky.app directly'); 509 } else { 510 // For third-party PDSes, use their own endpoint 511 console.log('[AUTH CHECK] Will use third-party PDS\'s own endpoint:', pdsEndpoint); 512 // Keep refreshAuthServer as the original PDS endpoint 513 514 // Ensure we update the PDS endpoint everywhere 515 if (typeof localStorage !== 'undefined') { 516 localStorage.setItem('pdsEndpoint', pdsEndpoint); 517 localStorage.setItem('bsky_auth_pdsEndpoint', pdsEndpoint); 518 } 519 } 520 521 const { accessToken: newAccessToken, refreshToken: newRefreshToken, dpopNonce: newNonce } = 522 await refreshAccessToken(refreshToken, keyPair, refreshAuthServer); 523 524 // Update tokens in localStorage 525 if (typeof localStorage !== 'undefined') { 526 localStorage.setItem('accessToken', newAccessToken); 527 localStorage.setItem('refreshToken', newRefreshToken); 528 if (newNonce) localStorage.setItem('dpopNonce', newNonce); 529 530 console.log('[AUTH CHECK] Tokens updated in localStorage during checkAuth'); 531 } 532 533 console.log('[AUTH CHECK] Token refreshed successfully, retrying auth check with new token'); 534 535 // Return the result of checkAuth with the new token 536 return checkAuth(newAccessToken, keyPair, did, newNonce || null, pdsEndpoint, newRefreshToken); 537 } catch (refreshError) { 538 console.error('[AUTH CHECK] Token refresh failed:', refreshError); 539 console.log('[AUTH CHECK] User needs to re-authenticate - session cannot be restored'); 540 } 541 } 542 } 543 544 console.error('Auth check failed with status:', response.status); 545 return false; 546 } catch (error) { 547 console.error('Error checking auth:', error); 548 return false; 549 } 550} 551 552// Make an authenticated request to the Bluesky API 553export async function makeAuthenticatedRequest( 554 endpoint: string, 555 method: string, 556 accessToken: string, 557 keyPair: CryptoKeyPair, 558 dpopNonce: string | null = null, 559 body?: any, 560 pdsEndpoint: string | null = null 561): Promise<any> { 562 // Use the PDS endpoint if provided, otherwise fall back to default 563 const baseUrl = pdsEndpoint ? `${pdsEndpoint}/xrpc` : DEFAULT_API_URL; 564 const url = `${baseUrl}/${endpoint}`; 565 566 console.log(`Making ${method} request to ${url} (PDS: ${pdsEndpoint || 'default'})`); 567 568 // If no nonce is provided, try to get one first 569 if (!dpopNonce) { 570 try { 571 // Make a HEAD request to get a nonce 572 const headResponse = await fetch(url, { 573 method: 'HEAD' 574 }); 575 576 const nonce = headResponse.headers.get('DPoP-Nonce'); 577 if (nonce) { 578 return makeAuthenticatedRequest(endpoint, method, accessToken, keyPair, nonce, body, pdsEndpoint); 579 } 580 } catch (err) { 581 console.warn('Failed to get nonce via HEAD request, continuing without it', err); 582 } 583 } 584 585 // Generate the DPoP token 586 const publicKey = await exportJWK(keyPair.publicKey); 587 const dpopToken = await generateDPoPToken( 588 keyPair.privateKey, 589 publicKey, 590 method, 591 url, 592 dpopNonce || undefined 593 ); 594 595 // Set headers 596 const headers: HeadersInit = { 597 'Authorization': `DPoP ${accessToken}`, 598 'DPoP': dpopToken, 599 'Content-Type': 'application/json' 600 }; 601 602 const requestOptions: RequestInit = { 603 method, 604 headers 605 }; 606 607 if (body) { 608 requestOptions.body = JSON.stringify(body); 609 } 610 611 // Make the request 612 const response = await fetch(url, requestOptions); 613 614 // Handle DPoP nonce errors or other 401 errors 615 if (response.status === 401) { 616 // Try to parse error response 617 let responseBody; 618 try { 619 responseBody = await response.json(); 620 } catch (e) { 621 responseBody = {}; 622 } 623 624 // Get the nonce from headers if available 625 const newDpopNonce = response.headers.get('DPoP-Nonce'); 626 627 // Check if this is a nonce error 628 if ( 629 (responseBody.error === 'use_dpop_nonce' || 630 (responseBody.error_description && responseBody.error_description.includes('nonce'))) && 631 newDpopNonce 632 ) { 633 // Store the nonce for future use 634 if (typeof localStorage !== 'undefined') { 635 console.log('[API REQUEST] Storing new DPoP nonce in localStorage:', newDpopNonce); 636 localStorage.setItem('dpopNonce', newDpopNonce); 637 } 638 639 console.log('[API REQUEST] Retrying request with new nonce:', newDpopNonce); 640 return makeAuthenticatedRequest(endpoint, method, accessToken, keyPair, newDpopNonce, body, pdsEndpoint); 641 } 642 643 // Other 401 error, possibly expired token 644 console.error('[API REQUEST] Request failed with 401 unauthorized:', responseBody); 645 646 // Include nonce in error message if available 647 if (newDpopNonce) { 648 console.log('[API REQUEST] 401 response included nonce:', newDpopNonce); 649 // Store the nonce even though we're not retrying now 650 if (typeof localStorage !== 'undefined') { 651 localStorage.setItem('dpopNonce', newDpopNonce); 652 } 653 } 654 655 throw new Error(`API request unauthorized: ${JSON.stringify(responseBody)}`); 656 } 657 658 // Handle other errors 659 if (!response.ok) { 660 let errorText; 661 try { 662 const errorJson = await response.json(); 663 errorText = JSON.stringify(errorJson); 664 } catch { 665 errorText = await response.text(); 666 } 667 throw new Error(`API request failed: ${response.status}, ${errorText}`); 668 } 669 670 // Parse JSON response if present 671 const contentType = response.headers.get('content-type'); 672 if (!contentType || !contentType.includes('application/json')) { 673 return null; 674 } 675 676 const result = await response.json(); 677 678 // Save any nonce for future requests 679 const returnNonce = response.headers.get('DPoP-Nonce'); 680 if (returnNonce && returnNonce !== dpopNonce) { 681 console.log('[API REQUEST] New DPoP nonce received in successful response:', returnNonce); 682 683 // Always store the latest nonce for future requests 684 if (typeof localStorage !== 'undefined') { 685 localStorage.setItem('dpopNonce', returnNonce); 686 console.log('[API REQUEST] Updated nonce in localStorage for future requests'); 687 } 688 } 689 690 return result; 691} 692 693// Get the user profile 694export async function getProfile( 695 accessToken: string, 696 keyPair: CryptoKeyPair, 697 dpopNonce: string | null = null, 698 handle: string = '', // Optional handle to resolve 699 pdsEndpoint: string | null = null 700): Promise<any> { 701 try { 702 // Generate a DPoP token for the profile request 703 const publicKey = await exportJWK(keyPair.publicKey); 704 705 // Use the PDS endpoint if available 706 const baseUrl = pdsEndpoint ? `${pdsEndpoint}/xrpc` : 'https://public.api.bsky.app/xrpc'; 707 708 // Step 1: If we have a DID, we want to get both the user's DID and handle 709 let endpoint; 710 let isDid = handle && handle.startsWith('did:'); 711 712 // First try to get the user's handle from the DID using PLC directory 713 if (isDid) { 714 try { 715 const plcResponse = await fetch(`https://plc.directory/${handle}/data`); 716 717 if (plcResponse.ok) { 718 const plcData = await plcResponse.json(); 719 if (plcData.alsoKnownAs && plcData.alsoKnownAs.length > 0) { 720 const handleUrl = plcData.alsoKnownAs[0]; 721 if (handleUrl.startsWith('at://')) { 722 // We found the handle! 723 const userHandle = handleUrl.substring(5); // Remove 'at://' 724 console.log(`PLC directory resolved DID ${handle} to handle ${userHandle}`); 725 726 // Return it immediately 727 return { did: handle, handle: userHandle }; 728 } 729 } 730 } 731 } catch (plcError) { 732 console.warn('Failed to resolve handle from PLC directory:', plcError); 733 } 734 735 // If we get here, we need to use describeRepo to get user info 736 endpoint = `${baseUrl}/com.atproto.repo.describeRepo?repo=${encodeURIComponent(handle)}`; 737 console.log(`Using describeRepo for DID ${handle} at ${endpoint}`); 738 } 739 // If we have a handle but no DID, we need to resolve the handle to a DID 740 else if (handle) { 741 endpoint = `${baseUrl}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 742 console.log(`Using resolveHandle for handle ${handle} at ${endpoint}`); 743 } 744 // If we have neither, we'll try to get the user's own info 745 else { 746 // Try to get the user's own repo info - note this only works on some PDS servers 747 endpoint = `${baseUrl}/com.atproto.repo.describeRepo`; 748 console.log(`Using describeRepo without params at ${endpoint}`); 749 } 750 751 // Generate the DPoP token with the access token for the ath claim 752 const dpopToken = await generateDPoPToken( 753 keyPair.privateKey, 754 publicKey, 755 'GET', 756 endpoint, 757 dpopNonce || undefined, 758 accessToken // Include access token for ath claim 759 ); 760 761 // Make the request via our proxy API 762 const response = await fetch('/api/bluesky/profile', { 763 method: 'POST', 764 headers: { 765 'Content-Type': 'application/json' 766 }, 767 body: JSON.stringify({ 768 accessToken, 769 dpopToken, 770 handle, // Include the handle in the request 771 pdsEndpoint // Include the PDS endpoint 772 }) 773 }); 774 775 // Even if the response isn't OK, we'll try to parse it 776 const responseData = await response.json(); 777 778 if (!response.ok) { 779 console.error('Profile fetch error:', responseData); 780 // Return a basic profile if we got an error 781 return { did: responseData.did || 'unknown_did', handle: responseData.handle || 'unknown' }; 782 } 783 784 console.log('Profile data from API:', responseData); 785 return responseData; 786 } catch (error) { 787 console.error('Error resolving handle:', error); 788 // If we fail to get the profile, return a basic object to avoid breaking the flow 789 // The user can still use the app and we'll use the DID as the identifier 790 return { handle: 'unknown' }; 791 } 792} 793 794// Create a flushing status record 795export async function createFlushingStatus( 796 accessToken: string, 797 keyPair: CryptoKeyPair, 798 did: string, 799 text: string, 800 emoji: string, 801 dpopNonce: string | null = null, 802 pdsEndpoint: string | null = null, 803 retryCount: number = 0, // Add retry counter 804 refreshToken: string | null = null // Add refresh token parameter 805): Promise<any> { 806 // Safety check: prevent infinite recursion 807 if (retryCount >= 3) { 808 throw new Error('Maximum retry attempts reached. Could not create status after 3 attempts.'); 809 } 810 811 console.log(`Creating flush status (attempt ${retryCount + 1}) for PDS: ${pdsEndpoint}`); 812 813 try { 814 // Validate inputs 815 if (!accessToken) throw new Error('Access token is required'); 816 if (!did) throw new Error('DID is required'); 817 if (!emoji) throw new Error('Emoji is required'); 818 819 // Format text to ensure it starts with "is" 820 let statusText = text ? text.trim() : ''; 821 822 // If text is empty, use default "is flushing" 823 if (!statusText) { 824 statusText = "is flushing"; 825 } 826 // If text doesn't start with "is", add it 827 else if (!statusText.toLowerCase().startsWith("is ")) { 828 statusText = `is ${statusText}`; 829 } 830 831 // Use the PDS endpoint if available - THIS IS CRITICAL FOR THIRD-PARTY PDSs 832 if (!pdsEndpoint) { 833 console.error('Missing PDS endpoint. This will likely fail.'); 834 } 835 836 console.log(`Using PDS endpoint for create record: ${pdsEndpoint}`); 837 838 const baseUrl = pdsEndpoint ? `${pdsEndpoint}/xrpc` : 'https://public.api.bsky.app/xrpc'; 839 const endpoint = `${baseUrl}/com.atproto.repo.createRecord`; 840 841 console.log(`Endpoint for create record: ${endpoint}`); 842 843 // Generate a DPoP token for the create request 844 const publicKey = await exportJWK(keyPair.publicKey); 845 846 // Generate token with appropriate claims for the request 847 const dpopToken = await generateDPoPToken( 848 keyPair.privateKey, 849 publicKey, 850 'POST', 851 endpoint, 852 dpopNonce || undefined, 853 accessToken // Pass the access token for ath claim 854 ); 855 856 console.log(`Sending flush record creation request to API proxy with PDS: ${pdsEndpoint}`); 857 858 // Make the request via our proxy API 859 const response = await fetch('/api/bluesky/flushing', { 860 method: 'POST', 861 headers: { 862 'Content-Type': 'application/json' 863 }, 864 body: JSON.stringify({ 865 accessToken, 866 dpopToken, 867 did, 868 text: statusText, // Use statusText which includes default if needed 869 emoji, 870 pdsEndpoint // Include the PDS endpoint 871 }) 872 }); 873 874 console.log(`Flush API response status: ${response.status}`); 875 876 // Handle response 877 if (response.ok) { 878 const result = await response.json(); 879 console.log('Flush record created successfully!'); 880 return result; 881 } 882 883 let errorData; 884 try { 885 errorData = await response.json(); 886 console.error('Error creating flush record:', errorData); 887 } catch (e) { 888 errorData = { error: 'unknown', status: response.status }; 889 console.error('Error parsing error response:', e); 890 } 891 892 // Handle nonce error with retry - THIS IS ESSENTIAL FOR PDS COMMUNICATION 893 if (response.status === 401 && errorData.error === 'use_dpop_nonce' && errorData.nonce) { 894 // This is normal operation - DPoP requires a nonce exchange 895 console.log('Received nonce from server, retrying request with new nonce:', errorData.nonce); 896 897 // Retry with the new nonce and increment retry counter 898 return createFlushingStatus( 899 accessToken, 900 keyPair, 901 did, 902 statusText, // Use statusText which includes default if needed 903 emoji, 904 errorData.nonce, 905 pdsEndpoint, 906 retryCount + 1, 907 refreshToken // Pass through the refresh token 908 ); 909 } 910 911 // TEMPORARILY DISABLE TOKEN REFRESH LOGIC - it's causing issues with third-party PDSes 912 // Just throw an error instead of attempting refresh 913 if (response.status === 401) { 914 console.error('Authentication expired or invalid. Please log out and log in again.'); 915 throw new Error('Authentication expired. Please log out and log in again.'); 916 } 917 918 // For other errors, throw with more details 919 if (errorData.message) { 920 throw new Error(`Status creation failed (${response.status}): ${errorData.message}`); 921 } else { 922 throw new Error(`Status creation failed with status ${response.status}`); 923 } 924 } catch (error) { 925 console.error('Error creating flushing status:', error); 926 throw error; 927 } 928}