From c616ea9e8fc23fe3fb5777449a65a28834f565bc Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 18 Feb 2026 20:53:06 -0800 Subject: [PATCH] Harden web wallet session lifecycle in landing and store flows --- public/index.html | 115 ++++++++++++++++++++++++++++--- public/store/index.html | 149 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 243 insertions(+), 21 deletions(-) diff --git a/public/index.html b/public/index.html index fb3c8ff..f9d9884 100644 --- a/public/index.html +++ b/public/index.html @@ -472,6 +472,8 @@ const flowState = { currentIntent: null, sessionToken: '', sessionExpiresAt: '', + sessionWallet: '', + sessionRefreshInFlight: null, }; const continueAction = document.getElementById('continue-action'); @@ -519,6 +521,7 @@ function getStoredAcknowledgement() { if (parsed && (parsed.code || parsed.token)) { flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : ''; flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : ''; + flowState.sessionWallet = typeof parsed.wallet === 'string' ? parsed.wallet : ''; return parsed; } } catch (err) { @@ -609,7 +612,92 @@ function formatQuoteDisplay(quote) { return null; } -async function postJSON(url, payload) { +function clearFlowSession(reason) { + flowState.sessionToken = ''; + flowState.sessionExpiresAt = ''; + flowState.sessionWallet = ''; + if (reason) { + console.warn('wallet session cleared:', reason); + } +} + +function captureFlowSessionHeaders(res) { + const token = String(res.headers.get('x-edut-session') || '').trim(); + if (token) { + flowState.sessionToken = token; + } + const expiresAt = String(res.headers.get('x-edut-session-expires-at') || '').trim(); + if (expiresAt) { + flowState.sessionExpiresAt = expiresAt; + } +} + +function parseApiError(text, status) { + if (!text) { + return { message: 'HTTP ' + status, code: '' }; + } + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object') { + return { + message: String(parsed.error || ('HTTP ' + status)), + code: String(parsed.code || ''), + }; + } + } catch (err) { + // fall back to raw text + } + return { message: text, code: '' }; +} + +function isTerminalSessionCode(code) { + const normalized = String(code || '').toLowerCase(); + return normalized === 'wallet_session_invalid' || + normalized === 'wallet_session_expired' || + normalized === 'wallet_session_revoked' || + normalized === 'wallet_session_mismatch'; +} + +async function maybeRefreshFlowSession(wallet, requestUrl) { + if (!flowState.sessionToken || !wallet || !flowState.sessionExpiresAt) { + return; + } + if (String(requestUrl || '').indexOf('/secret/wallet/session/') === 0) { + return; + } + const expiresAt = Date.parse(flowState.sessionExpiresAt); + if (!Number.isFinite(expiresAt)) { + return; + } + if ((expiresAt - Date.now()) > (5 * 60 * 1000)) { + return; + } + if (flowState.sessionRefreshInFlight) { + await flowState.sessionRefreshInFlight; + return; + } + flowState.sessionRefreshInFlight = postJSON( + '/secret/wallet/session/refresh', + { wallet }, + { wallet, skipSessionRefresh: true }, + ).then(function (out) { + if (out && typeof out.session_token === 'string') { + flowState.sessionToken = out.session_token; + } + if (out && typeof out.session_expires_at === 'string') { + flowState.sessionExpiresAt = out.session_expires_at; + } + }).finally(function () { + flowState.sessionRefreshInFlight = null; + }); + await flowState.sessionRefreshInFlight; +} + +async function postJSON(url, payload, options) { + const opts = options || {}; + if (!opts.skipSessionRefresh) { + await maybeRefreshFlowSession(opts.wallet || flowState.sessionWallet || '', url); + } const headers = { 'Content-Type': 'application/json', }; @@ -622,11 +710,17 @@ async function postJSON(url, payload) { headers, body: JSON.stringify(payload), }); + captureFlowSessionHeaders(res); + const text = await res.text(); if (!res.ok) { - const detail = await res.text(); - throw new Error(detail || ('HTTP ' + res.status)); + const detail = parseApiError(text, res.status); + if (isTerminalSessionCode(detail.code)) { + clearFlowSession(detail.code); + } + throw new Error(detail.message); } - return res.json(); + if (!text) return {}; + return JSON.parse(text); } function resolveLanguage(input) { @@ -725,6 +819,10 @@ async function startWalletFlow() { throw new Error('Wallet connection was not approved.'); } const address = accounts[0]; + if (flowState.sessionWallet && flowState.sessionWallet.toLowerCase() !== address.toLowerCase()) { + clearFlowSession('wallet_changed'); + } + flowState.sessionWallet = address; const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' }); const chainId = Number.parseInt(chainIdHex, 16); @@ -734,7 +832,7 @@ async function startWalletFlow() { origin: window.location.origin, locale: localStorage.getItem('edut_lang') || 'en', chain_id: chainId, - }); + }, { wallet: address }); flowState.currentIntent = intent; const typedData = { @@ -781,16 +879,17 @@ async function startWalletFlow() { address, chain_id: chainId, signature, - }); + }, { wallet: address }); flowState.sessionToken = verification.session_token || ''; flowState.sessionExpiresAt = verification.session_expires_at || ''; + flowState.sessionWallet = address; setFlowStatus('membership_quoting', 'Preparing membership mint...', false); const quote = await postJSON('/secret/membership/quote', { designation_code: verification.designation_code || intent.designation_code || null, address, chain_id: chainId, - }); + }, { wallet: address }); const txParams = quote.tx || { from: address, @@ -823,7 +922,7 @@ async function startWalletFlow() { tx_hash: txHash, address, chain_id: chainId, - }); + }, { wallet: address }); if (confirmation.status !== 'membership_active') { throw new Error('Membership transaction did not activate.'); diff --git a/public/store/index.html b/public/store/index.html index ce6149f..389906f 100644 --- a/public/store/index.html +++ b/public/store/index.html @@ -217,7 +217,9 @@ offers: [], selectedOfferId: null, sessionToken: '', + sessionExpiresAt: '', sessionWallet: null, + sessionRefreshInFlight: null, }; const walletLabel = document.getElementById('wallet-label'); @@ -404,6 +406,9 @@ if (typeof parsed.session_token === 'string') { state.sessionToken = parsed.session_token.trim(); } + if (typeof parsed.session_expires_at === 'string') { + state.sessionExpiresAt = parsed.session_expires_at.trim(); + } if (typeof parsed.wallet === 'string') { state.sessionWallet = parsed.wallet.trim(); } @@ -412,6 +417,107 @@ } } + function clearSession(reason) { + state.sessionToken = ''; + state.sessionExpiresAt = ''; + state.sessionWallet = null; + if (reason) { + setLog('Wallet session cleared: ' + reason + '.'); + } + } + + function captureSessionHeaders(response) { + const token = String(response.headers.get('x-edut-session') || '').trim(); + if (!token) return; + state.sessionToken = token; + const expiresAt = String(response.headers.get('x-edut-session-expires-at') || '').trim(); + if (expiresAt) { + state.sessionExpiresAt = expiresAt; + } + if (state.wallet) { + state.sessionWallet = state.wallet; + } + } + + function parseApiErrorPayload(text, fallbackStatus) { + if (!text) { + return { error: 'HTTP ' + fallbackStatus, code: '' }; + } + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === 'object') { + return { + error: String(parsed.error || ('HTTP ' + fallbackStatus)), + code: String(parsed.code || ''), + }; + } + } catch (err) { + // non-json response + } + return { error: text, code: '' }; + } + + function isTerminalSessionCode(code) { + const normalized = String(code || '').toLowerCase(); + return normalized === 'wallet_session_invalid' || + normalized === 'wallet_session_expired' || + normalized === 'wallet_session_revoked' || + normalized === 'wallet_session_mismatch'; + } + + async function maybeRefreshSession(pathHint) { + if (!state.sessionToken || !state.wallet || !state.sessionExpiresAt) { + return; + } + if (String(pathHint || '').indexOf('/secret/wallet/session/') === 0) { + return; + } + const expiresAt = Date.parse(state.sessionExpiresAt); + if (!Number.isFinite(expiresAt)) { + return; + } + if ((expiresAt - Date.now()) > (5 * 60 * 1000)) { + return; + } + if (state.sessionRefreshInFlight) { + await state.sessionRefreshInFlight; + return; + } + state.sessionRefreshInFlight = (async function () { + const response = await fetch('/secret/wallet/session/refresh', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ wallet: state.wallet }), + }); + captureSessionHeaders(response); + const text = await response.text(); + if (!response.ok) { + const detail = parseApiErrorPayload(text, response.status); + if (isTerminalSessionCode(detail.code)) { + clearSession(detail.code); + } + throw new Error(detail.error); + } + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch (err) { + // ignore parse error for non-essential refresh payload + } + if (payload && typeof payload.session_token === 'string') { + state.sessionToken = payload.session_token.trim(); + } + if (payload && typeof payload.session_expires_at === 'string') { + state.sessionExpiresAt = payload.session_expires_at.trim(); + } + state.sessionWallet = state.wallet; + setLog('Wallet session refreshed.'); + })().finally(function () { + state.sessionRefreshInFlight = null; + }); + await state.sessionRefreshInFlight; + } + function authHeaders(extra) { const headers = Object.assign({}, extra || {}); if (state.sessionToken) { @@ -422,16 +528,42 @@ } async function fetchJson(url) { + await maybeRefreshSession(url); const response = await fetch(url, { method: 'GET', headers: authHeaders(), }); + captureSessionHeaders(response); if (!response.ok) { - throw new Error('HTTP ' + response.status); + const text = await response.text(); + const detail = parseApiErrorPayload(text, response.status); + if (isTerminalSessionCode(detail.code)) { + clearSession(detail.code); + } + throw new Error(detail.error); } return response.json(); } + async function postJson(url, payload) { + await maybeRefreshSession(url); + const response = await fetch(url, { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + captureSessionHeaders(response); + const text = await response.text(); + if (!response.ok) { + const detail = parseApiErrorPayload(text, response.status); + if (isTerminalSessionCode(detail.code)) { + clearSession(detail.code); + } + throw new Error(detail.error); + } + return text ? JSON.parse(text) : {}; + } + async function loadOffers() { try { const payload = await fetchJson('/store/offers.json'); @@ -518,8 +650,9 @@ state.wallet = accounts[0]; state.ownershipProof = null; if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) { - state.sessionToken = ''; + clearSession('wallet changed'); } + state.sessionWallet = state.wallet; setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.'); await refreshMembershipState(); } catch (err) { @@ -623,17 +756,7 @@ payload.ownership_proof = state.ownershipProof.signature; } - const response = await fetch('/marketplace/checkout/quote', { - method: 'POST', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('HTTP ' + response.status); - } - - const quotePayload = await response.json(); + const quotePayload = await postJson('/marketplace/checkout/quote', payload); const quoteId = quotePayload.quote_id || 'unknown'; const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown'; const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount;