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;