diff --git a/src/App.css b/src/App.css index 233816f..77e977c 100644 --- a/src/App.css +++ b/src/App.css @@ -292,3 +292,80 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } .verifier-list-item-actions { align-self: flex-end; } .verifier-form-container { flex-direction: column; } } + +/* --- P5/P4/P7 additions --- */ + +/* P5: trust caveat near the primary action */ +.verifier-trust-caveat { + font-size: 0.9em; + color: var(--text); + border: 1px solid var(--card-border); + border-left: 4px solid var(--button-bg); + border-radius: 6px; + padding: 10px 12px; + margin: 14px 0 4px 0; + text-align: left; +} + +/* P4/P7: manage-view toolbar */ +.verifier-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.verifier-list-header h2 { margin: 0; } + +.verifier-manage-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.verifier-import-input { display: none; } + +/* P4: stale / gone badges */ +.verifier-stale-badge, +.verifier-gone-badge { + display: inline-block; + font-size: 0.85em; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + margin-top: 6px; + margin-right: 6px; +} + +.verifier-stale-badge { + background: rgba(255, 193, 7, 0.16); + color: #8a6d1a; + border: 1px solid #c9a227; +} +.verifier-gone-badge { + background: rgba(220, 53, 69, 0.12); + color: #b02a37; + border: 1px solid #dc3545; +} + +.verifier-stale-change { + font-size: 0.85em; + color: var(--text); + opacity: 0.85; + margin-top: 3px; +} + +.verifier-reissue-button { + background: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 6px; + padding: 8px 16px; + font-weight: 600; + margin-right: 8px; + cursor: pointer; +} +.verifier-reissue-button:hover { background: var(--button-hover-bg); } +.verifier-reissue-button:disabled { opacity: 0.6; cursor: not-allowed; } + diff --git a/src/components/Verifier/Verifier.js b/src/components/Verifier/Verifier.js index 9be340e..1e27d71 100644 --- a/src/components/Verifier/Verifier.js +++ b/src/components/Verifier/Verifier.js @@ -4,6 +4,34 @@ import { Agent } from '@atproto/api'; import VerifierForm from './VerifierForm'; import VerifierSuggestions from './VerifierSuggestions'; +// Chunk size for bulk writes via com.atproto.repo.applyWrites (P1) +const BULK_WRITE_CHUNK = 200; +// Chunk size for actor.getProfiles lookups during staleness checks (P4) +const PROFILE_CHECK_CHUNK = 25; + +// Shared pagination helper (P3): follows `cursor` until it is absent. +// `fn` is (params) => Promise<{ data }> for an agent call. +// `onPage(loadedCount)` is called after each page so the UI can show progress. +async function paginate(fn, params, onPage) { + const all = []; + let cursor; + const p = { ...params }; + do { + if (cursor) p.cursor = cursor; + const res = await fn(p); + const data = res?.data ?? res; + const listKey = Object.keys(data || {}).find(k => Array.isArray(data[k])); + const items = listKey ? data[listKey] : []; + all.push(...items); + if (onPage) onPage(all.length); + cursor = data?.cursor; + } while (cursor); + return all; +} + +// Derive an rkey from a record uri: at://did/collection/rkey +const rkeyFromUri = (uri) => (uri ? uri.split('/').pop() : null); + export default function Verifier() { const { session, loading: isAuthLoading, error: authError } = useAuth(); const [agent, setAgent] = useState(null); @@ -28,32 +56,51 @@ export default function Verifier() { const [showSuggestions, setShowSuggestions] = useState(false); const debounceRef = useRef(null); + // P2: load-once duplicate index. Map + const verificationIndexRef = useRef(new Map()); + + // P4: staleness check + re-issue + const [isCheckingChanges, setIsCheckingChanges] = useState(false); + const [isReissuing, setIsReissuing] = useState(false); + + // P7: import/export + const importFileRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + // --- Agent --- useEffect(() => { setAgent(session ? new Agent(session) : null); }, [session]); - // --- Data fetching --- - const fetchAllVerifications = useCallback(async () => { + // --- Data fetching (P2/P3: full cursor-loop, build the duplicate index) --- + const loadVerifications = useCallback(async () => { if (!agent || !session) return; setIsLoadingVerifications(true); - const all = []; try { - let cursor = null; - do { - const params = { repo: session.did, collection: 'app.bsky.graph.verification', limit: 100 }; - if (cursor) params.cursor = cursor; - const res = await agent.api.com.atproto.repo.listRecords(params); - if (res.data.records) { - all.push(...res.data.records.map(r => ({ - uri: r.uri, cid: r.cid, - handle: r.value.handle, displayName: r.value.displayName, - subject: r.value.subject, createdAt: r.value.createdAt, - }))); - } - cursor = res.data.cursor || null; - } while (cursor); - setVerifications(all); + const records = await paginate( + (p) => agent.api.com.atproto.repo.listRecords(p), + { repo: session.did, collection: 'app.bsky.graph.verification', limit: 100 } + ); + const map = new Map(); + const formatted = records.map(r => { + const value = r.value || {}; + const uri = r.uri; + map.set(value.subject, { + uri, + rkey: rkeyFromUri(uri), + handle: value.handle, + displayName: value.displayName, + createdAt: value.createdAt, + }); + return { + uri, cid: r.cid, + handle: value.handle, displayName: value.displayName, + subject: value.subject, createdAt: value.createdAt, + stale: false, gone: false, + }; + }); + verificationIndexRef.current = map; + setVerifications(formatted); } catch (error) { setStatusMessage(`Failed to load verifications: ${error.message || 'Unknown error'}`); setStatusType('error'); @@ -65,28 +112,22 @@ export default function Verifier() { const fetchUserLists = useCallback(async () => { if (!agent || !session) return; setIsLoadingLists(true); - const all = []; try { - let cursor = null; - do { - const res = await agent.api.app.bsky.graph.getLists({ actor: session.did, limit: 100, ...(cursor ? { cursor } : {}) }); - if (res.data.lists) { - all.push(...res.data.lists.filter(l => l.purpose === 'app.bsky.graph.defs#curatelist')); - } - cursor = res.data.cursor || null; - } while (cursor); - setUserLists(all); + const lists = await paginate( + (p) => agent.api.app.bsky.graph.getLists(p), + { actor: session.did, limit: 100 } + ); + setUserLists(lists.filter(l => l.purpose === 'app.bsky.graph.defs#curatelist')); } finally { setIsLoadingLists(false); } }, [agent, session]); useEffect(() => { - if (agent && session) { fetchAllVerifications(); fetchUserLists(); } - }, [agent, session, fetchAllVerifications, fetchUserLists]); + if (agent && session) { loadVerifications(); fetchUserLists(); } + }, [agent, session, loadVerifications, fetchUserLists]); // --- Derived data (memoized) --- - const verifiedDids = useMemo(() => new Set(verifications.map(v => v.subject)), [verifications]); const verifiedByHandle = useMemo(() => { const map = {}; verifications.forEach(v => { map[v.handle.toLowerCase()] = v; }); @@ -122,12 +163,21 @@ export default function Verifier() { const handleSuggestionSelect = (handle) => { setTargetHandle(handle); setShowSuggestions(false); setSuggestions([]); }; // --- Verification --- + // Writes one record and updates the local duplicate index immediately (P2). const writeVerification = useCallback(async (did, handle, displayName) => { - await agent.api.com.atproto.repo.createRecord({ + const record = { $type: 'app.bsky.graph.verification', subject: did, handle, displayName, createdAt: new Date().toISOString() }; + const res = await agent.api.com.atproto.repo.createRecord({ repo: session.did, collection: 'app.bsky.graph.verification', - record: { $type: 'app.bsky.graph.verification', subject: did, handle, displayName, createdAt: new Date().toISOString() }, + record, }); + if (res?.data?.uri) { + verificationIndexRef.current.set(did, { + uri: res.data.uri, rkey: rkeyFromUri(res.data.uri), + handle, displayName, createdAt: record.createdAt, + }); + } + return res; }, [agent, session]); const handleVerifyAccount = async (e) => { @@ -139,8 +189,9 @@ export default function Verifier() { const targetDid = profileRes.data.did; const targetDisplayName = profileRes.data.displayName || profileRes.data.handle; - if (verifiedDids.has(targetDid)) { - const existing = verifications.find(v => v.subject === targetDid); + // P2: check duplicates against the load-once index + if (verificationIndexRef.current.has(targetDid)) { + const existing = verificationIndexRef.current.get(targetDid); setStatusMessage(`@${targetHandle} is already verified (verified ${new Date(existing.createdAt).toLocaleString()}). See your verifications below.`); setStatusType('success'); return; @@ -157,7 +208,7 @@ export default function Verifier() { ); setStatusType('success'); setTargetHandle(''); - fetchAllVerifications(); + loadVerifications(); } catch (error) { setStatusMessage(`Verification failed: ${error.message || 'Unknown error'}`); setStatusType('error'); @@ -166,6 +217,7 @@ export default function Verifier() { } }; + // P1: bulk-verify via com.atproto.repo.applyWrites in chunks of 200 (P3: paginate full read) const handleVerifyList = async (e) => { e.preventDefault(); if (!agent || !session || !selectedListUri) return; @@ -174,33 +226,69 @@ export default function Verifier() { try { setStatusMessage('Fetching list members...'); setStatusType('success'); - const members = []; - let cursor = null; - do { - const res = await agent.api.app.bsky.graph.getList({ list: selectedListUri, limit: 100, ...(cursor ? { cursor } : {}) }); - if (res.data.items) members.push(...res.data.items.map(i => i.subject)); - cursor = res.data.cursor || null; - } while (cursor); + const members = await paginate( + (p) => agent.api.app.bsky.graph.getList(p), + { list: selectedListUri, limit: 100 }, + (loaded) => setStatusMessage(`Loaded ${loaded} members...`) + ); if (!members.length) { setStatusMessage('This list has no members.'); setStatusType('success'); return; } - let verified = 0, skipped = 0, failed = 0; - for (let i = 0; i < members.length; i++) { - const m = members[i]; - setBulkProgress({ current: i + 1, total: members.length, currentHandle: m.handle }); - if (verifiedDids.has(m.did)) { skipped++; continue; } + // P1: build the full pending array first (after P2 dedupe) + const pending = []; + let skipped = 0; + for (const m of members) { + if (verificationIndexRef.current.has(m.did)) { skipped++; continue; } + pending.push({ + $type: 'app.bsky.graph.verification', + subject: m.did, handle: m.handle, displayName: m.displayName || m.handle, + createdAt: new Date().toISOString(), + }); + } + + // P1: submit via applyWrites in chunks of 200 + let verified = 0, failed = 0; + const failedHandles = []; + for (let start = 0; start < pending.length; start += BULK_WRITE_CHUNK) { + const chunk = pending.slice(start, start + BULK_WRITE_CHUNK); + const chunkNo = Math.floor(start / BULK_WRITE_CHUNK) + 1; + const totalChunks = Math.ceil(pending.length / BULK_WRITE_CHUNK); + setBulkProgress({ current: Math.min(start + chunk.length, pending.length), total: pending.length, currentHandle: `chunk ${chunkNo}/${totalChunks}` }); try { - await writeVerification(m.did, m.handle, m.displayName || m.handle); - verifiedDids.add(m.did); verified++; - } catch { failed++; } - await new Promise(r => setTimeout(r, 250)); + const res = await agent.api.com.atproto.repo.applyWrites({ + repo: session.did, + writes: chunk.map(r => ({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.graph.verification', value: r })), + }); + // applyWrites returns one result per write in order; count successes/failures + (res?.data?.results || []).forEach((item, idx) => { + const r = chunk[idx]; + if (item?.create?.uri) { + verified++; + verificationIndexRef.current.set(r.subject, { + uri: item.create.uri, rkey: rkeyFromUri(item.create.uri), + handle: r.handle, displayName: r.displayName, createdAt: r.createdAt, + }); + } else { + failed++; + failedHandles.push(`@${r.handle}`); + } + }); + } catch (error) { + // Hard chunk failure: report the whole chunk's subjects as failed and continue + failed += chunk.length; + chunk.forEach(r => failedHandles.push(`@${r.handle}`)); + } } const listName = userLists.find(l => l.uri === selectedListUri)?.name || 'list'; - setStatusMessage(`Finished verifying "${listName}": ${verified} verified, ${skipped} already verified, ${failed} failed (${members.length} total).`); - setStatusType('success'); + let msg = `Finished verifying "${listName}": ${verified} verified, ${skipped} already verified, ${failed} failed (${members.length} total).`; + if (failedHandles.length) { + msg += ` Failed: ${failedHandles.slice(0, 10).join(', ')}${failedHandles.length > 10 ? ` (+${failedHandles.length - 10} more)` : ''}.`; + } + setStatusMessage(msg); + setStatusType(failed > 0 ? 'error' : 'success'); setSelectedListUri(''); - fetchAllVerifications(); + loadVerifications(); } catch (error) { setStatusMessage(`List verification failed: ${error.message || 'Unknown error'}`); setStatusType('error'); @@ -217,11 +305,13 @@ export default function Verifier() { setIsVerifying(true); try { await agent.api.com.atproto.repo.deleteRecord({ - repo: session.did, collection: 'app.bsky.graph.verification', rkey: verification.uri.split('/').pop(), + repo: session.did, collection: 'app.bsky.graph.verification', rkey: rkeyFromUri(verification.uri), }); + // P2: update the local duplicate index immediately + verificationIndexRef.current.delete(verification.subject); setStatusMessage(`Revoked verification for ${verification.handle}`); setStatusType('success'); - fetchAllVerifications(); + loadVerifications(); } catch (error) { setStatusMessage(`Revocation failed: ${error.message || 'Unknown error'}`); setStatusType('error'); @@ -230,6 +320,167 @@ export default function Verifier() { } }; + // P4: staleness check — compare current profiles against the P2 index (chunked ×25) + const checkForChanges = useCallback(async () => { + if (!agent || !session) return; + const index = verificationIndexRef.current; + const subjects = Array.from(index.keys()); + if (!subjects.length) { setStatusMessage('You have not verified any accounts yet.'); setStatusType('success'); return; } + setIsCheckingChanges(true); + const updated = {}; + const returned = new Set(); + for (let i = 0; i < subjects.length; i += PROFILE_CHECK_CHUNK) { + const chunk = subjects.slice(i, i + PROFILE_CHECK_CHUNK); + setStatusMessage(`Checking for changes... (${Math.min(i + chunk.length, subjects.length)}/${subjects.length})`); + try { + const res = await agent.api.app.bsky.actor.getProfiles({ actors: chunk }); + (res?.data?.profiles || []).forEach(p => { + const stored = index.get(p.did); + if (!stored) return; + returned.add(p.did); + const currentHandle = p.handle; + const currentDisplayName = p.displayName || currentHandle; + updated[p.did] = { + stale: currentHandle !== stored.handle || currentDisplayName !== stored.displayName, + gone: false, currentHandle, currentDisplayName, + }; + }); + } catch (error) { + // Hard failure for this batch — conservatively mark the batch as gone so the user can review + console.warn('getProfiles batch failed:', error.message); + chunk.forEach(did => { if (!updated[did]) updated[did] = { stale: false, gone: true, currentHandle: null, currentDisplayName: null }; }); + } + } + // Any subject not returned by getProfiles no longer resolves → gone + subjects.forEach(did => { + if (!updated[did] && !returned.has(did)) updated[did] = { stale: false, gone: true, currentHandle: null, currentDisplayName: null }; + }); + setVerifications(prev => prev.map(v => { + const c = updated[v.subject]; + return c ? { ...v, stale: c.stale, gone: c.gone, currentHandle: c.currentHandle, currentDisplayName: c.currentDisplayName } : v; + })); + const staleCount = Object.values(updated).filter(u => u.stale).length; + const goneCount = Object.values(updated).filter(u => u.gone).length; + setStatusMessage(`Staleness check complete. ${staleCount} stale, ${goneCount} gone.`); + setStatusType('success'); + setIsCheckingChanges(false); + }, [agent, session]); + + // P4: re-issue — update a record in place via putRecord on the SAME rkey + const reissueVerification = async (verification) => { + if (!agent || !session) return; + const stored = verificationIndexRef.current.get(verification.subject); + const rkey = stored?.rkey || verification.rkey || rkeyFromUri(verification.uri); + if (!rkey) { setStatusMessage(`Cannot re-issue: missing rkey for ${verification.handle}.`); setStatusType('error'); return; } + setIsReissuing(true); + try { + const record = { + $type: 'app.bsky.graph.verification', + subject: verification.subject, + handle: verification.currentHandle || verification.handle, + displayName: verification.currentDisplayName || verification.displayName, + createdAt: new Date().toISOString(), + }; + const res = await agent.api.com.atproto.repo.putRecord({ repo: session.did, collection: 'app.bsky.graph.verification', rkey, record }); + verificationIndexRef.current.set(verification.subject, { + uri: res?.data?.uri || verification.uri, rkey, + handle: record.handle, displayName: record.displayName, createdAt: record.createdAt, + }); + setStatusMessage(`Re-issued verification for ${verification.handle}.`); + setStatusType('success'); + loadVerifications(); + } catch (error) { + setStatusMessage(`Re-issue failed for ${verification.handle}: ${error.message || 'Unknown error'}`); + setStatusType('error'); + } finally { + setIsReissuing(false); + } + }; + + // P7: export all records from the P2 index as JSON + const handleExport = () => { + const index = verificationIndexRef.current; + const records = Array.from(index.entries()).map(([subject, entry]) => ({ + subject, handle: entry.handle, displayName: entry.displayName, createdAt: entry.createdAt, + })); + if (!records.length) { setStatusMessage('Nothing to export — you have not verified any accounts.'); setStatusType('success'); return; } + const handle = session?.handle || 'account'; + const date = new Date().toISOString().slice(0, 10); + const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `verifications-${handle}-${date}.json`; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + setStatusMessage(`Exported ${records.length} verification record(s).`); + setStatusType('success'); + }; + + // P7: import JSON → dedupe against the P2 index → bulk-create via the P1 applyWrites path + const handleImportFile = (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async () => { + try { + const data = JSON.parse(reader.result); + const records = Array.isArray(data) ? data : (data.records || []); + if (!Array.isArray(records) || !records.length) { setStatusMessage('Import: no verification records found in file.'); setStatusType('error'); return; } + const index = verificationIndexRef.current; + const newRecords = [], skipped = []; + records.forEach(r => { + const subject = r.subject || r.did; + if (!subject) return; + if (index.has(subject)) { skipped.push(subject); return; } + const handle = r.handle || ''; + newRecords.push({ $type: 'app.bsky.graph.verification', subject, handle, displayName: r.displayName || handle, createdAt: r.createdAt || new Date().toISOString() }); + }); + if (!newRecords.length) { setStatusMessage(`Import: 0 new records. All ${records.length} already verified (skipped).`); setStatusType('success'); return; } + if (!window.confirm(`Import ${newRecords.length} new verification record(s)? ${skipped.length} will be skipped (already verified).`)) { + setStatusMessage('Import cancelled.'); setStatusType('success'); return; + } + setIsImporting(true); + let successCount = 0, failureCount = 0; + const errors = []; + for (let start = 0; start < newRecords.length; start += BULK_WRITE_CHUNK) { + const chunk = newRecords.slice(start, start + BULK_WRITE_CHUNK); + setStatusMessage(`Importing... (${Math.min(start + chunk.length, newRecords.length)}/${newRecords.length})`); + try { + const res = await agent.api.com.atproto.repo.applyWrites({ + repo: session.did, + writes: chunk.map(r => ({ $type: 'com.atproto.repo.applyWrites#create', collection: 'app.bsky.graph.verification', value: r })), + }); + (res?.data?.results || []).forEach((item, idx) => { + const r = chunk[idx]; + if (item?.create?.uri) { + successCount++; + verificationIndexRef.current.set(r.subject, { uri: item.create.uri, rkey: rkeyFromUri(item.create.uri), handle: r.handle, displayName: r.displayName, createdAt: r.createdAt }); + } else { + failureCount++; + errors.push(`@${r.handle || r.subject}: ${item?.error?.message || item?.error || 'unknown error'}`); + } + }); + } catch (error) { + failureCount += chunk.length; + errors.push(...chunk.map(r => `@${r.handle || r.subject}: ${error.message || 'Unknown error'}`)); + } + } + if (failureCount) console.log('Import errors:', errors); + setStatusMessage(`Import complete. New: ${successCount}, Failed: ${failureCount}. ${skipped.length} skipped (already verified).`); + setStatusType(failureCount ? 'error' : 'success'); + loadVerifications(); + setIsImporting(false); + } catch (err) { + setStatusMessage(`Import failed: ${err.message || 'Invalid file'}`); + setStatusType('error'); + setIsImporting(false); + } + }; + reader.readAsText(file); + // reset input so the same file can be re-selected later + if (importFileRef.current) importFileRef.current.value = ''; + }; + // --- Render --- if (isAuthLoading) return

Loading authentication...

; if (authError) return

Authentication Error: {authError}. Please login.

; @@ -239,6 +490,8 @@ export default function Verifier() { ? new Date(verifiedByHandle[targetHandle.toLowerCase()].createdAt).toLocaleString() : ''; + const anyInProgress = isVerifying || isLoadingVerifications || isLoadingLists || isCheckingChanges || isReissuing || isImporting; + return (
-

Your Verifications

+
+

Your Verifications

+ {/* P4/P7: manage-view toolbar */} +
+ + + + +
+
+ {isLoadingVerifications ?

Loading...

: verifications.length === 0 ?

You haven't verified any accounts.

: (
    {verifications.map(v => ( -
  • +
  • {v.displayName} @{v.handle} + {v.stale && !v.gone && Stale} + {v.gone && Gone (no longer resolves)} + {v.stale && ( +
    + {`@${v.handle}`} → {`@${v.currentHandle || '?'}`} + {v.currentDisplayName ? ` • ${v.displayName} → ${v.currentDisplayName}` : ''} +
    + )}
    Verified: {new Date(v.createdAt).toLocaleString()}
    + {v.stale && !v.gone && ( + + )}
  • diff --git a/src/components/Verifier/VerifierForm.jsx b/src/components/Verifier/VerifierForm.jsx index 55b0a6b..48e2517 100644 --- a/src/components/Verifier/VerifierForm.jsx +++ b/src/components/Verifier/VerifierForm.jsx @@ -64,6 +64,12 @@ export default function VerifierForm({ + {/* P5: trust caveat near the primary action */} +

    + This writes a verification record to YOUR repo. Whether a badge appears in Bluesky or other apps + depends on whether that app trusts you as a verifier. Records are public. +

    + {mode === 'account' && isDuplicateHandle && (

    Already verified on {duplicateDate} — see your verifications below. diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js index 19ff05c..acdad88 100644 --- a/src/contexts/AuthContext.js +++ b/src/contexts/AuthContext.js @@ -3,23 +3,6 @@ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; export const AuthContext = createContext(null); -const clientMetadata = { - client_id: `https://verifier.psingletary.com/client-metadata.json`, - client_name: "Verifier", - client_uri: `https://verifier.psingletary.com`, - redirect_uris: [ - `https://verifier.psingletary.com/login/callback`, - `https://psingletary.tngl.io/verifier/login/callback`, - ], - logo_uri: `https://verifier.psingletary.com/favicon.ico`, - scope: "atproto transition:generic", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - application_type: "web", - dpop_bound_access_tokens: true -}; - export const AuthProvider = ({ children }) => { const [client, setClient] = useState(null); const [session, setSession] = useState(null); @@ -35,6 +18,13 @@ export const AuthProvider = ({ children }) => { setError(null); try { + // P6: single source of truth — load client metadata at startup + const metadataResponse = await fetch('/client-metadata.json'); + if (!metadataResponse.ok) { + throw new Error(`Failed to load client metadata: ${metadataResponse.status}`); + } + const clientMetadata = await metadataResponse.json(); + const oauthClient = new BrowserOAuthClient({ clientMetadata, handleResolver: 'https://public.api.bsky.app',