The 1st decentralized social network for sharing when you're on the toilet. Post a "flush" today! Powered by the AT Protocol.
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}