Harden web wallet session lifecycle in landing and store flows
This commit is contained in:
parent
9517f13b45
commit
c616ea9e8f
@ -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.');
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user