document.addEventListener("DOMContentLoaded", async () => { if (window.location.pathname.includes("/callback")) { await getToken(); } if (window.location.pathname.includes("/profile")) { await getProfile(); } if (window.location.pathname.includes("/suggestions")) { await getSuggestions(); } }); /** * Resolves a handle to a DID using com.atproto.identity.resolveHandle. * @param {string} handle - The handle to resolve (e.g., "nandi.craves.food") * @returns {Promise} The DID */ async function resolveHandle(handle) { const response = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); if (!response.ok) { throw new Error(`Failed to resolve handle: ${response.statusText}`); } const data = await response.json(); return data.did; } /** * Gets the DID document from the PLC directory. * @param {string} did - The DID to look up * @returns {Promise} The DID document JSON */ async function getDidDocument(did) { const response = await fetch(`https://plc.directory/${did}`); if (!response.ok) { throw new Error(`Failed to get DID document: ${response.statusText}`); } return await response.json(); } /** * Extracts the PDS URL from a DID document. * @param {Object} didDoc - The DID document JSON * @returns {string} The PDS URL */ function extractPdsUrl(didDoc) { const services = didDoc.service || []; const service = services.find(s => s.type === "AtprotoPersonalDataServer"); if (!service || !service.serviceEndpoint) { throw new Error("PDS service endpoint not found in DID document"); } return service.serviceEndpoint; } /** * Gets OAuth protected resource information from a PDS. * @param {string} pdsUrl - The PDS URL * @returns {Promise} The OAuth protected resource JSON */ async function getOAuthProtectedResource(pdsUrl) { const url = pdsUrl.endsWith('/') ? pdsUrl : pdsUrl + '/'; const response = await fetch(`${url}.well-known/oauth-protected-resource`); if (!response.ok) { throw new Error(`Failed to get OAuth protected resource: ${response.statusText}`); } return await response.json(); } /** * Gets OAuth authorization server metadata. * @param {string} authServer - The authorization server URL * @returns {Promise} The OAuth authorization server metadata JSON */ async function getOAuthServerMetadata(authServer) { const url = authServer.endsWith('/') ? authServer : authServer + '/'; const response = await fetch(`${url}.well-known/oauth-authorization-server`); if (!response.ok) { throw new Error(`Failed to get OAuth server metadata: ${response.statusText}`); } return await response.json(); } /** * Resolves a handle and gets all necessary OAuth server information. * This function follows the same logic as didDoc in scratch.u. * @param {string} handle - The handle to resolve * @returns {Promise} Object containing pdsUrl, authServer, parUrl, authorizeUrl, and did */ async function resolveHandleAndGetOAuthInfo(handle) { // Step 1: Resolve handle to DID const did = await resolveHandle(handle); console.log("Resolved DID:", did); // Step 2: Get DID document const didDoc = await getDidDocument(did); console.log("DID document:", didDoc); // Step 3: Extract PDS URL const pdsUrl = extractPdsUrl(didDoc); console.log("PDS URL:", pdsUrl); // Store PDS URL for later use localStorage.setItem("pdsUrl", pdsUrl); localStorage.setItem("did", did); // Step 4: Get OAuth protected resource const protectedResource = await getOAuthProtectedResource(pdsUrl); console.log("OAuth protected resource:", protectedResource); // Step 5: Get authorization server const authServers = protectedResource.authorization_servers || []; if (authServers.length === 0) { throw new Error("No authorization servers found"); } const authServer = authServers[0]; console.log("Authorization server:", authServer); // Store auth server URL localStorage.setItem("authServer", authServer); // Step 6: Get OAuth server metadata const serverMeta = await getOAuthServerMetadata(authServer); console.log("OAuth server metadata:", serverMeta); // Step 7: Extract PAR endpoint const parUrl = serverMeta.pushed_authorization_request_endpoint; if (!parUrl) { throw new Error("PAR endpoint not found in server metadata"); } console.log("PAR URL:", parUrl); // Extract authorize URL (usually token_endpoint with /authorize) const authorizeUrl = serverMeta.authorization_endpoint || authServer + "/oauth/authorize"; return { did, pdsUrl, authServer, parUrl, authorizeUrl }; } try { document.getElementById("login").addEventListener("click", async () => { const loginButton = document.getElementById("login"); const handleInput = document.getElementById("handle"); try { loginButton.disabled = true; loginButton.textContent = "Preparing login..."; const handle = handleInput.value.trim(); if (!handle) { throw new Error("Please enter a handle"); } // Store handle for later use localStorage.setItem("userHandle", handle); // Resolve handle and get OAuth server info loginButton.textContent = "Resolving handle..."; const oauthInfo = await resolveHandleAndGetOAuthInfo(handle); await generatePkce(); await createJwk(); let state = randomNonce(); let codeChallenge = localStorage.getItem("codeChallenge") let params = { client_id: "https://codegod100.unison-services.cloud/s/oauth-test/metadata.json", response_type: "code", redirect_uri: "https://codegod100.unison-services.cloud/s/oauth-test/callback", state: state, scope: "atproto transition:generic", code_challenge: codeChallenge, code_challenge_method: "S256", login_hint: handle } loginButton.textContent = "Connecting..."; const response = await oauthRequest(oauthInfo.parUrl, params); console.log(response); // redirect to the authorization url using the response.request_uri with the params window.location.href = oauthInfo.authorizeUrl + "?" + new URLSearchParams(params).toString(); } catch (error) { console.error("Login error:", error); loginButton.disabled = false; loginButton.textContent = "Login"; alert("Login failed: " + error.message); } }); } catch (e) { // Element may not exist on this page } /** * Generates an ECDSA P-256 key pair and stores the private and public keys in localStorage. * Also performs a test signature to verify the key pair works. * @returns {Promise} */ async function createJwk() { let keyPair = await window.crypto.subtle.generateKey({ name: "ECDSA", "namedCurve": "P-256" }, true, ["sign", "verify"]) console.log(keyPair); let exported = await window.crypto.subtle.exportKey("jwk", keyPair.privateKey) console.log(exported); localStorage.setItem("privateKey", JSON.stringify(exported)); localStorage.setItem("publicKey", JSON.stringify(await window.crypto.subtle.exportKey("jwk", keyPair.publicKey))); let message = new TextEncoder().encode("Hello, world!") let signature = await window.crypto.subtle.sign({ "name": "ECDSA", "hash": "SHA-256" }, keyPair.privateKey, message) console.log(signature); let jwk = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey) console.log(jwk); } // const response = await fetch("https://example.org/post", { // method: "POST", // headers: { // "Content-Type": "application/x-www-form-urlencoded", // }, // // Automatically converted to "username=example&password=password" // body: new URLSearchParams({ username: "example", password: "password" }), // // … // }); // client_id=http%3A%2F%2Flocalhost%2F& // response_type=code& // redirect_uri=http%3A%2F%2F127.0.0.1%3A4000%2Fcallback& // state=abc123& // scope=atproto+transition%3Ageneric& // code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& // code_challenge_method=S256& // login_hint=alice.bsky.social /** * Creates and signs a DPoP (Demonstrating Proof-of-Possession) JWT token. * @param {string} url - The target URL for the request (htu claim) * @param {string} method - The HTTP method (htm claim), e.g., "GET" or "POST" * @param {string|null} [nonce] - The nonce value from the server (defaults to stored nonce) * @param {string|null} [token=null] - Optional token (accessToken or refreshToken) to hash and include in ath claim. If null and includeTokenHash is true, uses accessToken from localStorage. * @param {boolean} [includeTokenHash=false] - Whether to include the token hash (ath) in the JWT payload * @returns {Promise} The signed JWT token as a base64url-encoded string */ async function createSignedJwt(url, method, nonce = localStorage.getItem("nonce"), token = null, includeTokenHash = false) { let jwtHeader = { "typ": "dpop+jwt", "alg": "ES256", "jwk": JSON.parse(localStorage.getItem("publicKey")) } const epochSeconds = Math.floor(Date.now() / 1000); let jwtPayload = { "jti": randomNonce(), "htm": method, "htu": url, "iat": epochSeconds, "nonce": nonce } if (includeTokenHash) { // Use provided token, or fall back to accessToken from localStorage let tokenToHash = token || localStorage.getItem("accessToken") if (tokenToHash) { jwtPayload["ath"] = await hashAccessToken(tokenToHash) } } // stringify and base64url encode without padding let jwtHeaderBase64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(jwtHeader))) let jwtPayloadBase64 = base64urlEncode(new TextEncoder().encode(JSON.stringify(jwtPayload))) let message = new TextEncoder().encode(jwtHeaderBase64 + "." + jwtPayloadBase64) let privateKeyString = JSON.parse(localStorage.getItem("privateKey")) console.log(privateKeyString); let privateKey = await window.crypto.subtle.importKey("jwk", privateKeyString, { name: "ECDSA", "namedCurve": "P-256" }, true, ["sign"]) console.log(privateKey); let signature = await window.crypto.subtle.sign({ "name": "ECDSA", "hash": "SHA-256" }, privateKey, message) console.log(signature); let signatureBase64 = base64urlEncode(new Uint8Array(signature)) let jwt = jwtHeaderBase64 + "." + jwtPayloadBase64 + "." + signatureBase64 console.log(jwt); return jwt } /** * Makes an OAuth-style request with DPoP authentication. * Used for token exchange endpoints (POST with form-urlencoded body, no access token hash). * Automatically handles nonce retry logic if the server returns a nonce mismatch error. * @param {string} url - The OAuth endpoint URL * @param {Object} params - The form parameters to send in the request body * @param {string|null} [nonce] - The nonce value from the server (defaults to stored nonce) * @returns {Promise} The JSON response from the server */ async function oauthRequest(url, params, nonce = localStorage.getItem("nonce")) { let jwt = await createSignedJwt(url, "POST", nonce, null, false) const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "DPoP": jwt }, body: new URLSearchParams(params) }); // on initial request, there is no nonce because need from server, so grab nonce from response header and retry request with nonce in jwt payload nonce = response.headers.get("dpop-nonce"); localStorage.setItem("nonce", nonce); let responseBody = await response.json() // if response body json has "error_description" of "Invalid DPoP \"nonce\" type" retry with nonce we go from header let errorMessages = ['Invalid DPoP "nonce" type', 'DPoP "nonce" mismatch'] if (errorMessages.includes(responseBody.error_description) || errorMessages.includes(responseBody.message)) { return oauthRequest(url, params, nonce); } return responseBody } /** * Checks if an error response indicates an expired token. * @param {Object} responseBody - The JSON response body from the server * @returns {boolean} True if the error indicates an expired token */ function isExpiredTokenError(responseBody) { return responseBody.error === "invalid_token" && (responseBody.message && responseBody.message.includes('"exp" claim timestamp check failed')); } /** * Makes a PDS (Personal Data Server) style request with DPoP authentication. * Used for authenticated API calls (GET or POST with access token in Authorization header and ath in JWT). * Automatically handles nonce retry logic if the server returns a nonce mismatch error. * Automatically refreshes the access token if it has expired and retries the request. * @param {string} url - The PDS endpoint URL * @param {string} [method="GET"] - The HTTP method ("GET" or "POST") * @param {Object|null} [body=null] - Optional body parameters for POST requests (will be sent as form-urlencoded) * @param {string|null} [nonce] - The nonce value from the server (defaults to stored nonce) * @param {boolean} [hasRetried=false] - Internal flag to prevent infinite retry loops * @returns {Promise} The JSON response from the server * @throws {Error} If no access token is available in localStorage */ async function pdsRequest(url, method = "GET", body = null, nonce = localStorage.getItem("nonce"), hasRetried = false) { let accessToken = localStorage.getItem("accessToken") if (!accessToken) { throw new Error("Access token required for PDS requests") } let jwt = await createSignedJwt(url, method, nonce, accessToken, true) let headers = { "DPoP": jwt, "Authorization": "DPoP " + accessToken } let fetchOptions = { method: method, headers: headers } // Add body for POST requests if (method === "POST" && body) { headers["Content-Type"] = "application/x-www-form-urlencoded" fetchOptions.body = new URLSearchParams(body) } const response = await fetch(url, fetchOptions); // on initial request, there is no nonce because need from server, so grab nonce from response header and retry request with nonce in jwt payload nonce = response.headers.get("dpop-nonce"); localStorage.setItem("nonce", nonce); let responseBody = await response.json() // Check for expired token error and refresh if needed if (isExpiredTokenError(responseBody)) { if (!hasRetried) { console.log("Access token expired, refreshing..."); try { await refreshSession(); // Retry the original request with the new token return pdsRequest(url, method, body, nonce, true); } catch (refreshError) { throw new Error(`Token refresh failed: ${refreshError.message}. Please login again.`); } } else { throw new Error("Token refresh failed. Please login again."); } } // if response body json has "error_description" of "Invalid DPoP \"nonce\" type" retry with nonce we go from header let errorMessages = ['Invalid DPoP "nonce" type', 'DPoP "nonce" mismatch'] if (errorMessages.includes(responseBody.error_description) || errorMessages.includes(responseBody.message)) { return pdsRequest(url, method, body, nonce, hasRetried); } return responseBody } /** * Encodes a byte array to base64url format (RFC 4648 Section 5). * Base64url is base64 encoding with URL-safe characters (+ → -, / → _) and padding removed. * @param {Uint8Array} bytes - The byte array to encode * @returns {string} The base64url-encoded string */ function base64urlEncode(bytes) { return btoa(String.fromCharCode.apply(null, Array.from(bytes))) .replace(/\+/g, "-") // + → - .replace(/\//g, "_") // / → _ .replace(/=+$/, ""); // remove padding } /** * Generates a cryptographically secure random nonce and encodes it as base64url. * @param {number} [length=16] - The length of the random bytes to generate * @returns {string} A base64url-encoded random nonce */ function randomNonce(length = 16) { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return base64urlEncode(bytes) } /** * Exchanges an authorization code for an access token using the OAuth 2.0 authorization code flow. * Reads the authorization code from the URL query parameters and uses PKCE code verifier from localStorage. * Stores the received access token and refresh token in localStorage. * @returns {Promise} */ async function getToken() { const callbackMessage = document.getElementById("callback-message"); try { // Show initial loading message if (callbackMessage) { callbackMessage.innerHTML = `
Exchanging authorization code...
`; } // Get token endpoint from stored auth server const authServer = localStorage.getItem("authServer"); if (!authServer) { throw new Error("Authorization server not found. Please login again."); } const tokenEndpoint = authServer.endsWith('/') ? authServer + 'oauth/token' : authServer + '/oauth/token'; const routeParams = new URLSearchParams(window.location.search); const code = routeParams.get("code") console.log(code) let codeVerifier = localStorage.getItem("codeVerifier") let params = { client_id: "https://codegod100.unison-services.cloud/s/oauth-test/metadata.json", grant_type: "authorization_code", code: code, redirect_uri: "https://codegod100.unison-services.cloud/s/oauth-test/callback", code_verifier: codeVerifier } let response = await oauthRequest(tokenEndpoint, params) localStorage.setItem("accessToken", response.access_token) localStorage.setItem("refreshToken", response.refresh_token) // Store handle and did if provided in token response if (response.handle) { localStorage.setItem("handle", response.handle) } if (response.did) { localStorage.setItem("did", response.did) } console.log("response", response) // Show success message if (callbackMessage) { callbackMessage.innerHTML = `

Authorization Successful!

Your credentials have been stored securely. Redirecting to your profile...

`; } // Redirect to profile page after a brief delay setTimeout(() => { window.location.href = "./profile"; }, 2000); } catch (error) { console.error("Token exchange error:", error); if (callbackMessage) { callbackMessage.innerHTML = `

Authorization Failed

${escapeHtml(error.message)}

Return to login

`; } } } /** * Hashes an access token using SHA-256 and encodes the result as base64url. * Used for the 'ath' (access token hash) claim in DPoP JWTs. * @param {string} accessToken - The access token to hash * @returns {Promise} The base64url-encoded SHA-256 hash of the access token */ async function hashAccessToken(accessToken) { return base64urlEncode(new Uint8Array(await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)))) } /** * Generates PKCE (Proof Key for Code Exchange) parameters. * Creates a random code verifier and its corresponding SHA-256 hashed code challenge. * Stores both values in localStorage for use in the OAuth flow. * @returns {Promise} */ async function generatePkce() { let verifier = randomNonce(43) let challenge = base64urlEncode(new Uint8Array(await window.crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))) localStorage.setItem("codeVerifier", verifier) localStorage.setItem("codeChallenge", challenge) } /** * Renders profile data as HTML into the #profile element. * @param {Object} profile - The profile data object from Bluesky API */ function renderProfile(profile) { const profileElement = document.getElementById("profile"); if (!profileElement) { console.error("Profile element not found"); return; } let html = `
🎉

Mission Accomplished!

You've successfully authenticated with a handcrafted DPoP token and hit the Bluesky API. That's some serious cryptographic wizardry! 🔐✨

`; if (profile.avatar) { html += `Profile avatar`; } html += '
'; if (profile.displayName) { html += `

${escapeHtml(profile.displayName)}

`; } if (profile.handle) { html += `

@${escapeHtml(profile.handle)}

`; } if (profile.did) { html += `

DID: ${escapeHtml(profile.did)}

`; } if (profile.description) { html += `
${escapeHtml(profile.description)}
`; } if (profile.followersCount !== undefined) { html += `

Followers: ${profile.followersCount}

`; } if (profile.followsCount !== undefined) { html += `

Following: ${profile.followsCount}

`; } html += '
'; profileElement.innerHTML = html; } /** * Escapes HTML special characters to prevent XSS attacks. * @param {string} text - The text to escape * @returns {string} The escaped text */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Shows a loading spinner in the specified element or profile element. * @param {string} [message="Loading..."] - Optional loading message * @param {HTMLElement|null} [element=null] - Optional element to show loader in (defaults to #profile) */ function showLoader(message = "Loading...", element = null) { const targetElement = element || document.getElementById("profile") || document.getElementById("suggestions"); if (targetElement) { targetElement.innerHTML = `
${escapeHtml(message)}
`; } } /** * Gets the current session information including handle and did. * Calls com.atproto.server.getSession to retrieve session details. * @returns {Promise} The session information containing handle and did * @throws {Error} If the session cannot be retrieved */ async function getSession() { let accessToken = localStorage.getItem("accessToken") if (!accessToken) { throw new Error("Access token required to get session") } const pdsUrl = localStorage.getItem("pdsUrl"); if (!pdsUrl) { throw new Error("PDS URL not found. Please login again."); } const pdsBase = pdsUrl.endsWith('/') ? pdsUrl.slice(0, -1) : pdsUrl; let endpoint = `${pdsBase}/xrpc/com.atproto.server.getSession` let response = await pdsRequest(endpoint) // Store handle and did for future use if (response.handle) { localStorage.setItem("handle", response.handle) } if (response.did) { localStorage.setItem("did", response.did) } return response } /** * Refreshes the access token using the refresh token. * Calls the OAuth token endpoint with grant_type=refresh_token, similar to getToken. * Uses oauthRequest for DPoP authentication (no token hash needed). * Updates the stored access token and refresh token in localStorage. * Automatically handles nonce retry logic if the server returns a nonce mismatch error. * @returns {Promise} The JSON response containing new access_token and refresh_token * @throws {Error} If refresh token is not available */ async function refreshSession() { let refreshToken = localStorage.getItem("refreshToken") if (!refreshToken) { throw new Error("Refresh token required for session refresh") } // Get token endpoint from stored auth server const authServer = localStorage.getItem("authServer"); if (!authServer) { throw new Error("Authorization server not found. Please login again."); } const tokenEndpoint = authServer.endsWith('/') ? authServer + 'oauth/token' : authServer + '/oauth/token'; let params = { client_id: "https://codegod100.unison-services.cloud/s/oauth-test/metadata.json", grant_type: "refresh_token", refresh_token: refreshToken } let response = await oauthRequest(tokenEndpoint, params) // Update stored tokens if (response.access_token) { localStorage.setItem("accessToken", response.access_token) } if (response.refresh_token) { localStorage.setItem("refreshToken", response.refresh_token) } // Store handle and did if provided in token response if (response.handle) { localStorage.setItem("handle", response.handle) } if (response.did) { localStorage.setItem("did", response.did) } return response } /** * Retrieves a user profile from the Bluesky PDS using the authenticated API. * Renders the profile data into the #profile element on the page. * @returns {Promise} */ async function getProfile() { const profileElement = document.getElementById("profile"); try { showLoader("Loading profile..."); const pdsUrl = localStorage.getItem("pdsUrl"); const handle = localStorage.getItem("userHandle"); if (!pdsUrl || !handle) { throw new Error("PDS URL or handle not found. Please login again."); } const pdsBase = pdsUrl.endsWith('/') ? pdsUrl.slice(0, -1) : pdsUrl; let endpoint = `${pdsBase}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}` let response = await pdsRequest(endpoint) // Check if response has an error if (response.error) { console.error("Profile fetch error:", response); if (profileElement) { profileElement.innerHTML = `
Error loading profile: ${escapeHtml(response.error)}
`; } return; } // Render the profile data console.log("Profile data:", response); renderProfile(response); } catch (error) { console.error("Failed to fetch profile:", error); if (profileElement) { const errorMessage = escapeHtml(error.message); const needsLogin = errorMessage.includes("login") || errorMessage.includes("PDS URL") || errorMessage.includes("handle not found"); let errorHtml = `
⚠️

Failed to Load Profile

${errorMessage}

`; // Add login link if error mentions login if (needsLogin) { errorHtml += ` Return to Login `; } errorHtml += `
`; profileElement.innerHTML = errorHtml; } } } /** * Renders suggested actors as HTML into the #suggestions element. * @param {Array} suggestions - Array of actor profile objects from Bluesky API */ function renderSuggestions(suggestions) { const suggestionsElement = document.getElementById("suggestions"); if (!suggestionsElement) { console.error("Suggestions element not found"); return; } let html = `
👥

Suggested Accounts

Discover accounts to follow on Bluesky

`; if (!suggestions || suggestions.length === 0) { html += `
No suggestions available at this time.
`; } else { suggestions.forEach(actor => { // Use handle if available, otherwise use DID for the profile URL const profileIdentifier = actor.handle || actor.did; const profileUrl = profileIdentifier ? `https://bsky.app/profile/${encodeURIComponent(profileIdentifier)}` : '#'; html += `
${actor.avatar ? `Avatar` : '
👤
'}
${actor.displayName ? `

${escapeHtml(actor.displayName)}

` : ''} ${actor.handle ? `

@${escapeHtml(actor.handle)}

` : ''} ${actor.followersCount !== undefined ? `

${actor.followersCount.toLocaleString()} followers

` : ''}
${actor.description ? `

${escapeHtml(actor.description)}

` : ''}
`; }); } html += '
'; suggestionsElement.innerHTML = html; } /** * Retrieves suggested actors from the Bluesky PDS using the authenticated API. * Renders the suggestions into the #suggestions element on the page. * @returns {Promise} */ async function getSuggestions() { const suggestionsElement = document.getElementById("suggestions"); try { showLoader("Loading suggestions...", suggestionsElement); const pdsUrl = localStorage.getItem("pdsUrl"); if (!pdsUrl) { throw new Error("PDS URL not found. Please login again."); } const pdsBase = pdsUrl.endsWith('/') ? pdsUrl.slice(0, -1) : pdsUrl; let endpoint = `${pdsBase}/xrpc/app.bsky.actor.getSuggestions` let response = await pdsRequest(endpoint) // Check if response has an error if (response.error) { console.error("Suggestions fetch error:", response); if (suggestionsElement) { suggestionsElement.innerHTML = `
Error loading suggestions: ${escapeHtml(response.error)}
`; } return; } // Render the suggestions data console.log("Suggestions data:", response); const actors = response.actors || []; renderSuggestions(actors); } catch (error) { console.error("Failed to fetch suggestions:", error); if (suggestionsElement) { const errorMessage = escapeHtml(error.message); const needsLogin = errorMessage.includes("login") || errorMessage.includes("PDS URL"); let errorHtml = `
⚠️

Failed to Load Suggestions

${errorMessage}

`; if (needsLogin) { errorHtml += ` Return to Login `; } errorHtml += `
`; suggestionsElement.innerHTML = errorHtml; } } }