Harden web wallet session lifecycle in landing and store flows

This commit is contained in:
Joshua 2026-02-18 20:53:06 -08:00
parent 9517f13b45
commit c616ea9e8f
2 changed files with 243 additions and 21 deletions

View File

@ -472,6 +472,8 @@ const flowState = {
currentIntent: null, currentIntent: null,
sessionToken: '', sessionToken: '',
sessionExpiresAt: '', sessionExpiresAt: '',
sessionWallet: '',
sessionRefreshInFlight: null,
}; };
const continueAction = document.getElementById('continue-action'); const continueAction = document.getElementById('continue-action');
@ -519,6 +521,7 @@ function getStoredAcknowledgement() {
if (parsed && (parsed.code || parsed.token)) { if (parsed && (parsed.code || parsed.token)) {
flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : ''; flowState.sessionToken = typeof parsed.session_token === 'string' ? parsed.session_token : '';
flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : ''; flowState.sessionExpiresAt = typeof parsed.session_expires_at === 'string' ? parsed.session_expires_at : '';
flowState.sessionWallet = typeof parsed.wallet === 'string' ? parsed.wallet : '';
return parsed; return parsed;
} }
} catch (err) { } catch (err) {
@ -609,7 +612,92 @@ function formatQuoteDisplay(quote) {
return null; 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 = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
@ -622,11 +710,17 @@ async function postJSON(url, payload) {
headers, headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
captureFlowSessionHeaders(res);
const text = await res.text();
if (!res.ok) { if (!res.ok) {
const detail = await res.text(); const detail = parseApiError(text, res.status);
throw new Error(detail || ('HTTP ' + res.status)); if (isTerminalSessionCode(detail.code)) {
clearFlowSession(detail.code);
} }
return res.json(); throw new Error(detail.message);
}
if (!text) return {};
return JSON.parse(text);
} }
function resolveLanguage(input) { function resolveLanguage(input) {
@ -725,6 +819,10 @@ async function startWalletFlow() {
throw new Error('Wallet connection was not approved.'); throw new Error('Wallet connection was not approved.');
} }
const address = accounts[0]; 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 chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
const chainId = Number.parseInt(chainIdHex, 16); const chainId = Number.parseInt(chainIdHex, 16);
@ -734,7 +832,7 @@ async function startWalletFlow() {
origin: window.location.origin, origin: window.location.origin,
locale: localStorage.getItem('edut_lang') || 'en', locale: localStorage.getItem('edut_lang') || 'en',
chain_id: chainId, chain_id: chainId,
}); }, { wallet: address });
flowState.currentIntent = intent; flowState.currentIntent = intent;
const typedData = { const typedData = {
@ -781,16 +879,17 @@ async function startWalletFlow() {
address, address,
chain_id: chainId, chain_id: chainId,
signature, signature,
}); }, { wallet: address });
flowState.sessionToken = verification.session_token || ''; flowState.sessionToken = verification.session_token || '';
flowState.sessionExpiresAt = verification.session_expires_at || ''; flowState.sessionExpiresAt = verification.session_expires_at || '';
flowState.sessionWallet = address;
setFlowStatus('membership_quoting', 'Preparing membership mint...', false); setFlowStatus('membership_quoting', 'Preparing membership mint...', false);
const quote = await postJSON('/secret/membership/quote', { const quote = await postJSON('/secret/membership/quote', {
designation_code: verification.designation_code || intent.designation_code || null, designation_code: verification.designation_code || intent.designation_code || null,
address, address,
chain_id: chainId, chain_id: chainId,
}); }, { wallet: address });
const txParams = quote.tx || { const txParams = quote.tx || {
from: address, from: address,
@ -823,7 +922,7 @@ async function startWalletFlow() {
tx_hash: txHash, tx_hash: txHash,
address, address,
chain_id: chainId, chain_id: chainId,
}); }, { wallet: address });
if (confirmation.status !== 'membership_active') { if (confirmation.status !== 'membership_active') {
throw new Error('Membership transaction did not activate.'); throw new Error('Membership transaction did not activate.');

View File

@ -217,7 +217,9 @@
offers: [], offers: [],
selectedOfferId: null, selectedOfferId: null,
sessionToken: '', sessionToken: '',
sessionExpiresAt: '',
sessionWallet: null, sessionWallet: null,
sessionRefreshInFlight: null,
}; };
const walletLabel = document.getElementById('wallet-label'); const walletLabel = document.getElementById('wallet-label');
@ -404,6 +406,9 @@
if (typeof parsed.session_token === 'string') { if (typeof parsed.session_token === 'string') {
state.sessionToken = parsed.session_token.trim(); 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') { if (typeof parsed.wallet === 'string') {
state.sessionWallet = parsed.wallet.trim(); 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) { function authHeaders(extra) {
const headers = Object.assign({}, extra || {}); const headers = Object.assign({}, extra || {});
if (state.sessionToken) { if (state.sessionToken) {
@ -422,16 +528,42 @@
} }
async function fetchJson(url) { async function fetchJson(url) {
await maybeRefreshSession(url);
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
headers: authHeaders(), headers: authHeaders(),
}); });
captureSessionHeaders(response);
if (!response.ok) { 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(); 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() { async function loadOffers() {
try { try {
const payload = await fetchJson('/store/offers.json'); const payload = await fetchJson('/store/offers.json');
@ -518,8 +650,9 @@
state.wallet = accounts[0]; state.wallet = accounts[0];
state.ownershipProof = null; state.ownershipProof = null;
if (state.sessionWallet && state.wallet && state.sessionWallet.toLowerCase() !== state.wallet.toLowerCase()) { 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) + '.'); setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
await refreshMembershipState(); await refreshMembershipState();
} catch (err) { } catch (err) {
@ -623,17 +756,7 @@
payload.ownership_proof = state.ownershipProof.signature; payload.ownership_proof = state.ownershipProof.signature;
} }
const response = await fetch('/marketplace/checkout/quote', { const quotePayload = await postJson('/marketplace/checkout/quote', payload);
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 quoteId = quotePayload.quote_id || 'unknown'; const quoteId = quotePayload.quote_id || 'unknown';
const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown'; const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount; const total = quotePayload.total_amount || quotePayload.total_amount_atomic || amount;