experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(lex): short-circuit on auth expiry, surface re-authentication UI, stop polling

+254 -4
+94 -3
features/lex/atproto.js
··· 10 10 const api = hasPeekAPI ? window.app : null; 11 11 12 12 // ============================================================================ 13 + // Auth-expired signal (module-local) 14 + // ============================================================================ 15 + 16 + /** 17 + * Set once a refresh attempt determines the refresh token itself is invalid 18 + * or the session JWT is permanently expired. Subsequent xrpc* calls 19 + * short-circuit until clearAuthExpired() is called (after a fresh login). 20 + */ 21 + let authExpired = false; 22 + 23 + export class AuthExpiredError extends Error { 24 + constructor(message = 'AT Protocol session expired — sign in again') { 25 + super(message); 26 + this.name = 'AuthExpiredError'; 27 + this.authExpired = true; 28 + } 29 + } 30 + 31 + export function isAuthExpired() { 32 + return authExpired; 33 + } 34 + 35 + export function clearAuthExpired() { 36 + authExpired = false; 37 + } 38 + 39 + /** 40 + * Inspect a refresh-failure error to decide whether it's a permanent 41 + * "auth expired" signal (refresh token revoked / JWT exp claim failed) 42 + * vs a transient failure (network blip, 5xx, DPoP nonce hiccup). 43 + * 44 + * Returns true for definitive expiry markers; false otherwise. 45 + * 46 + * @param {Error} err 47 + * @returns {boolean} 48 + */ 49 + function isExpiredAuthError(err) { 50 + const msg = (err && err.message) ? String(err.message) : ''; 51 + // JWT exp claim failure surfaces verbatim from the auth server. 52 + if (msg.includes('"exp" claim') || msg.includes('exp claim')) return true; 53 + // Standard OAuth error codes for revoked / invalid refresh token. 54 + if (/\binvalid_grant\b/.test(msg)) return true; 55 + if (/\binvalid_token\b/.test(msg)) return true; 56 + if (/\bunauthorized_client\b/.test(msg)) return true; 57 + // 4xx from token endpoint (caught by tokenExchange's status formatting). 58 + // tokenExchange throws "Token exchange failed (NNN)" for unknown bodies. 59 + const m = msg.match(/Token exchange failed \((\d+)\)/); 60 + if (m) { 61 + const status = parseInt(m[1], 10); 62 + if (status === 400 || status === 401 || status === 403) return true; 63 + } 64 + return false; 65 + } 66 + 67 + /** 68 + * Wrapper around refreshOAuthSession() that flips the authExpired flag 69 + * when the failure looks like a permanent expiry. Returns the refreshed 70 + * session on success; throws on failure (caller decides how to react, 71 + * but should consult isAuthExpired() afterward). 72 + */ 73 + async function tryRefreshSession(session) { 74 + try { 75 + const refreshed = await refreshOAuthSession(session); 76 + return refreshed; 77 + } catch (err) { 78 + if (isExpiredAuthError(err)) { 79 + authExpired = true; 80 + } 81 + throw err; 82 + } 83 + } 84 + 85 + // ============================================================================ 13 86 // Public API (unauthenticated) 14 87 // ============================================================================ 15 88 ··· 461 534 // 13. Export DPoP keypair for storage 462 535 const dpopKeyPairJwk = await exportKeyPair(dpopKeyPair); 463 536 537 + // Successful login clears any prior expired state. 538 + authExpired = false; 539 + 464 540 return { 465 541 did, 466 542 handle, ··· 566 642 * @returns {Promise<Object>} Response data 567 643 */ 568 644 export async function xrpcGet(session, nsid, params = {}, onSessionRefresh = null) { 645 + if (authExpired) throw new AuthExpiredError(); 646 + 569 647 const qs = new URLSearchParams(); 570 648 for (const [k, v] of Object.entries(params)) { 571 649 if (v !== undefined && v !== null) qs.set(k, String(v)); ··· 603 681 // Retry once on 401 (expired token) 604 682 if (res.status === 401 && session.refreshToken) { 605 683 try { 606 - const refreshed = await refreshOAuthSession(session); 684 + const refreshed = await tryRefreshSession(session); 607 685 Object.assign(session, refreshed); 608 686 if (onSessionRefresh) onSessionRefresh(refreshed); 609 687 ··· 631 709 } 632 710 } 633 711 } catch (refreshErr) { 712 + if (authExpired) { 713 + throw new AuthExpiredError(); 714 + } 634 715 console.error('[atproto] Token refresh failed:', refreshErr.message); 635 716 } 636 717 } ··· 654 735 * @returns {Promise<Object>} Response data 655 736 */ 656 737 export async function xrpcPost(session, nsid, body, onSessionRefresh = null) { 738 + if (authExpired) throw new AuthExpiredError(); 739 + 657 740 const url = `${session.pdsUrl}/xrpc/${nsid}`; 658 741 const dpopKeyPair = await importKeyPair(session.dpopKeyPairJwk); 659 742 const ath = await computeAth(session.accessToken); ··· 689 772 // Retry once on 401 (expired token) 690 773 if (res.status === 401 && session.refreshToken) { 691 774 try { 692 - const refreshed = await refreshOAuthSession(session); 775 + const refreshed = await tryRefreshSession(session); 693 776 Object.assign(session, refreshed); 694 777 if (onSessionRefresh) onSessionRefresh(refreshed); 695 778 ··· 723 806 } 724 807 } 725 808 } catch (refreshErr) { 809 + if (authExpired) { 810 + throw new AuthExpiredError(); 811 + } 726 812 console.error('[atproto] Token refresh failed:', refreshErr.message); 727 813 } 728 814 } ··· 744 830 * @returns {Promise<Object>} - { blob: { $type: "blob", ref: { $link }, mimeType, size } } 745 831 */ 746 832 export async function uploadBlob(session, data, mimeType, onSessionRefresh = null) { 833 + if (authExpired) throw new AuthExpiredError(); 834 + 747 835 const url = `${session.pdsUrl}/xrpc/com.atproto.repo.uploadBlob`; 748 836 const dpopKeyPair = await importKeyPair(session.dpopKeyPairJwk); 749 837 const ath = await computeAth(session.accessToken); ··· 779 867 // Token refresh retry 780 868 if (res.status === 401 && session.refreshToken) { 781 869 try { 782 - const refreshed = await refreshOAuthSession(session); 870 + const refreshed = await tryRefreshSession(session); 783 871 Object.assign(session, refreshed); 784 872 if (onSessionRefresh) onSessionRefresh(refreshed); 785 873 ··· 795 883 body: data, 796 884 }); 797 885 } catch (refreshErr) { 886 + if (authExpired) { 887 + throw new AuthExpiredError(); 888 + } 798 889 console.error('[atproto] Token refresh failed:', refreshErr.message); 799 890 } 800 891 }
+160 -1
features/lex/home.js
··· 31 31 getRecord, 32 32 deleteRecord, 33 33 getLatestCommit, 34 + AuthExpiredError, 35 + isAuthExpired, 36 + clearAuthExpired, 34 37 } from './atproto.js'; 35 38 36 39 const api = window.app; ··· 49 52 */ 50 53 async function discoverCollections() { 51 54 if (!currentSession) return; 55 + if (isAuthExpired()) return; 52 56 try { 53 57 const collections = await getCollections(currentSession, onSessionRefresh); 54 58 knownCollectionNsids = collections || []; 55 59 console.log(`[lex] Discovered ${knownCollectionNsids.length} collections for noun query`); 56 60 } catch (err) { 61 + if (err instanceof AuthExpiredError) { 62 + handleAuthExpired(); 63 + return; 64 + } 57 65 console.warn('[lex] Failed to discover collections for noun query:', err); 58 66 } 59 67 } ··· 235 243 currentSession = null; 236 244 currentProfile = null; 237 245 knownCollectionNsids = []; 246 + clearAuthExpired(); 247 + removeAuthExpiredUi(); 238 248 if (api) { 239 249 try { 240 250 await api.settings.set(STORAGE_KEY, null); ··· 251 261 updateFormTemplateUploader(); 252 262 saveSession(); 253 263 } 264 + 265 + // ============================================================================ 266 + // Auth-expired UI 267 + // ============================================================================ 268 + 269 + let authExpiredUiShown = false; 270 + let authExpiredReauthing = false; 271 + 272 + /** 273 + * Stop polling and switch the content area to a "session expired" placeholder. 274 + * Idempotent — safe to call from every catch site that detects expiry. 275 + */ 276 + function handleAuthExpired() { 277 + if (authExpiredUiShown) return; 278 + authExpiredUiShown = true; 279 + 280 + console.warn('[lex] Auth session expired — stopping sync poll, prompting re-authentication'); 281 + stopSyncPoll(); 282 + renderAuthExpiredUi(); 283 + } 284 + 285 + /** 286 + * Render a centered "Session expired" message with a Re-authenticate button 287 + * over the active panel. Removed once re-auth succeeds. 288 + */ 289 + function renderAuthExpiredUi() { 290 + const accountViewEl = document.getElementById('account-view'); 291 + if (!accountViewEl) return; 292 + 293 + // Avoid duplicate overlay 294 + let overlay = document.getElementById('lex-auth-expired-overlay'); 295 + if (!overlay) { 296 + overlay = document.createElement('div'); 297 + overlay.id = 'lex-auth-expired-overlay'; 298 + overlay.style.cssText = [ 299 + 'position:absolute', 300 + 'inset:0', 301 + 'display:flex', 302 + 'flex-direction:column', 303 + 'align-items:center', 304 + 'justify-content:center', 305 + 'gap:12px', 306 + 'background:rgba(20,20,20,0.92)', 307 + 'color:#eee', 308 + 'z-index:50', 309 + 'padding:24px', 310 + 'text-align:center', 311 + ].join(';'); 312 + overlay.innerHTML = ` 313 + <div style="font-size:16px;font-weight:600">Session expired</div> 314 + <div style="font-size:13px;opacity:0.8;max-width:340px"> 315 + Your AT Protocol session has expired. Sign in again to continue. 316 + </div> 317 + <button id="lex-reauth-btn" class="btn btn-primary" style="margin-top:8px"> 318 + Re-authenticate 319 + </button> 320 + <div id="lex-reauth-status" style="font-size:12px;opacity:0.7;min-height:1em"></div> 321 + `; 322 + // Ensure parent is positioned for absolute child 323 + if (getComputedStyle(accountViewEl).position === 'static') { 324 + accountViewEl.style.position = 'relative'; 325 + } 326 + accountViewEl.appendChild(overlay); 327 + 328 + const btn = overlay.querySelector('#lex-reauth-btn'); 329 + btn.addEventListener('click', triggerReauthenticate); 330 + } 331 + } 332 + 333 + function removeAuthExpiredUi() { 334 + const overlay = document.getElementById('lex-auth-expired-overlay'); 335 + if (overlay) overlay.remove(); 336 + authExpiredUiShown = false; 337 + } 338 + 339 + /** 340 + * Re-run the OAuth login flow for the existing handle. Re-uses the 341 + * existing loginWithOAuth() path; on success clears the expired flag, 342 + * persists the new session, and resumes the sync poll. 343 + */ 344 + async function triggerReauthenticate() { 345 + if (authExpiredReauthing) return; 346 + if (!currentSession || !currentSession.handle) { 347 + // No handle to retry with — fall back to full logout/login. 348 + await clearSession(); 349 + clearAuthExpired(); 350 + removeAuthExpiredUi(); 351 + state.authenticated = false; 352 + showLoginView(); 353 + return; 354 + } 355 + 356 + authExpiredReauthing = true; 357 + const btn = document.getElementById('lex-reauth-btn'); 358 + const statusEl = document.getElementById('lex-reauth-status'); 359 + if (btn) { 360 + btn.disabled = true; 361 + btn.textContent = 'Connecting...'; 362 + } 363 + if (statusEl) statusEl.textContent = ''; 364 + 365 + try { 366 + const handle = currentSession.handle; 367 + const refreshed = await loginWithOAuth(handle); 368 + currentSession = refreshed; 369 + lexiconTemplate.setSession(currentSession); 370 + updateFormTemplateUploader(); 371 + 372 + // Refresh profile in background (best-effort). 373 + try { 374 + const profile = await getProfile(currentSession.did); 375 + if (profile) { 376 + currentProfile = profile; 377 + state.profile = profile; 378 + } 379 + } catch {} 380 + 381 + await saveSession(); 382 + clearAuthExpired(); 383 + removeAuthExpiredUi(); 384 + 385 + // Resume polling and refresh the active panel's data. 386 + startSyncPoll(); 387 + console.log('[lex] Re-authenticated as', currentSession.handle); 388 + } catch (err) { 389 + console.error('[lex] Re-authentication failed:', err); 390 + if (statusEl) statusEl.textContent = err.message || 'Re-authentication failed'; 391 + if (btn) { 392 + btn.disabled = false; 393 + btn.textContent = 'Re-authenticate'; 394 + } 395 + } finally { 396 + authExpiredReauthing = false; 397 + } 398 + } 254 399 255 400 /** 256 401 * Update the form template's blob upload function with the current session. ··· 468 613 } 469 614 return { changed: false, rev: currentRev }; 470 615 } catch (err) { 616 + if (err instanceof AuthExpiredError || isAuthExpired()) { 617 + handleAuthExpired(); 618 + return { changed: false, rev: null, expired: true }; 619 + } 471 620 console.warn('[lex] Failed to check repo rev:', err); 472 621 return { changed: false, rev: null }; 473 622 } ··· 502 651 */ 503 652 async function syncPoll() { 504 653 if (!currentSession) return; 654 + if (isAuthExpired()) { 655 + handleAuthExpired(); 656 + return; 657 + } 505 658 506 - const { changed } = await checkForChanges(currentSession); 659 + const { changed, expired } = await checkForChanges(currentSession); 660 + if (expired) return; 661 + 507 662 lastSyncTime = Date.now(); 508 663 updateSyncIndicator(); 509 664 ··· 1161 1316 // Update "new" commands with repo collections 1162 1317 onCollectionsChanged(counts.map(c => c.nsid)); 1163 1318 } catch (err) { 1319 + if (err instanceof AuthExpiredError) { 1320 + handleAuthExpired(); 1321 + return; 1322 + } 1164 1323 collectionsListEl.innerHTML = `<div class="error">${escapeHtml(err.message)}</div>`; 1165 1324 } 1166 1325 }