diff --git a/README.md b/README.md index 55518ac..d456cbf 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Advanced integration controls (collapsible): 5. Member channel register/poll primitives 6. Governance install + lease primitives (with explicit `operation_mode`) 7. Raw response log for deterministic troubleshooting +8. Wallet session lifecycle controls (manual refresh/revoke + automatic pre-expiry refresh) Wallet automation helpers remain available in advanced controls: @@ -55,6 +56,7 @@ Wallet automation helpers remain available in advanced controls: 4. `Send membership tx` submits the quote transaction via `eth_sendTransaction` and fills `confirmTxHash`. 5. Membership confirm can optionally attach on-ramp attestation fields (`identity_assurance_level`, `identity_attested_by`, `identity_attestation_id`) for provider-integrated flows. 6. Wallet verify returns a session token; launcher forwards it on marketplace/member/governance API calls via bearer + `X-Edut-Session`. +7. Launcher proactively refreshes wallet sessions before expiry and clears local session state on terminal session errors (`invalid`, `expired`, `revoked`, `mismatch`). Policy behavior in launcher shell: diff --git a/app/app.js b/app/app.js index d645059..af50b10 100644 --- a/app/app.js +++ b/app/app.js @@ -11,6 +11,7 @@ const state = { channelReady: false, walletSessionToken: "", walletSessionExpiresAt: "", + walletSessionRefreshInFlight: null, }; function nowISO() { @@ -62,9 +63,109 @@ function sessionSummary() { if (!state.walletSessionExpiresAt) { return "active"; } + const expires = Date.parse(state.walletSessionExpiresAt); + if (!Number.isFinite(expires)) { + return `active (exp ${state.walletSessionExpiresAt})`; + } + const remainingMs = expires - Date.now(); + if (remainingMs <= 0) { + return `expired (${state.walletSessionExpiresAt})`; + } + if (remainingMs <= 5 * 60 * 1000) { + return `expiring soon (${state.walletSessionExpiresAt})`; + } return `active (exp ${state.walletSessionExpiresAt})`; } +function clearWalletSession(reason, payload = {}) { + if (!state.walletSessionToken && !state.walletSessionExpiresAt) { + return; + } + const previousToken = state.walletSessionToken; + const previousExpiry = state.walletSessionExpiresAt; + state.walletSessionToken = ""; + state.walletSessionExpiresAt = ""; + if (reason) { + logLine("wallet session cleared", { + reason, + previous_token_preview: previousToken ? `${previousToken.slice(0, 8)}...` : "", + previous_expiry: previousExpiry || "", + ...payload, + }); + } + refreshOverview(); +} + +function captureSessionHeaders(res) { + const token = String(res.headers.get("x-edut-session") || "").trim(); + const expiresAt = String(res.headers.get("x-edut-session-expires-at") || "").trim(); + if (!token) { + return; + } + state.walletSessionToken = token; + if (expiresAt) { + state.walletSessionExpiresAt = expiresAt; + } +} + +function isTerminalSessionErrorCode(code) { + const normalized = String(code || "").trim().toLowerCase(); + return normalized === "wallet_session_invalid" + || normalized === "wallet_session_expired" + || normalized === "wallet_session_revoked" + || normalized === "wallet_session_mismatch"; +} + +async function maybeRefreshSession(path) { + if (!state.walletSessionToken) { + return; + } + if (String(path || "").startsWith("/secret/wallet/session/")) { + return; + } + if (!state.walletSessionExpiresAt) { + return; + } + const expiresAt = Date.parse(state.walletSessionExpiresAt); + if (!Number.isFinite(expiresAt)) { + return; + } + const refreshWindowMs = 5 * 60 * 1000; + if ((expiresAt - Date.now()) > refreshWindowMs) { + return; + } + if (state.walletSessionRefreshInFlight) { + await state.walletSessionRefreshInFlight; + return; + } + const currentWallet = normalizedAddress(wallet()); + if (!currentWallet) { + return; + } + state.walletSessionRefreshInFlight = (async () => { + const out = await request( + "POST", + "/secret/wallet/session/refresh", + { wallet: currentWallet }, + { skipSessionPreflight: true }, + ); + if (out.session_token) { + state.walletSessionToken = String(out.session_token).trim(); + } + if (out.session_expires_at) { + state.walletSessionExpiresAt = String(out.session_expires_at).trim(); + } + logLine("wallet session auto-refresh", { + wallet: currentWallet, + session_expires_at: state.walletSessionExpiresAt || "unknown", + }); + refreshOverview(); + })().finally(() => { + state.walletSessionRefreshInFlight = null; + }); + await state.walletSessionRefreshInFlight; +} + function injectedProvider() { return globalThis.ethereum || null; } @@ -145,7 +246,10 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function request(method, path, body) { +async function request(method, path, body, options = {}) { + if (!options.skipSessionPreflight) { + await maybeRefreshSession(path); + } const headers = { "Content-Type": "application/json" }; if (state.walletSessionToken) { headers["X-Edut-Session"] = state.walletSessionToken; @@ -159,6 +263,7 @@ async function request(method, path, body) { opts.body = JSON.stringify(body); } const res = await fetch(`${baseURL()}${path}`, opts); + captureSessionHeaders(res); const text = await res.text(); let json = {}; if (text.trim() !== "") { @@ -169,7 +274,16 @@ async function request(method, path, body) { } } if (!res.ok) { - throw new Error(`${res.status} ${res.statusText}: ${JSON.stringify(json)}`); + const errorMessage = String(json?.error || res.statusText || "request failed"); + const errorCode = String(json?.code || ""); + if (isTerminalSessionErrorCode(errorCode)) { + clearWalletSession(errorCode, { path, message: errorMessage }); + } + const err = new Error(`${res.status} ${res.statusText}: ${errorMessage}`); + err.status = res.status; + err.code = errorCode; + err.payload = json; + throw err; } return json; } @@ -291,9 +405,7 @@ async function onConnectWallet() { const nextWallet = normalizedAddress(accounts[0]); $("walletAddress").value = nextWallet; if (previousWallet && previousWallet !== nextWallet) { - state.walletSessionToken = ""; - state.walletSessionExpiresAt = ""; - logLine("wallet session reset", { + clearWalletSession("wallet_changed", { previous_wallet: previousWallet, next_wallet: nextWallet, }); @@ -444,6 +556,36 @@ async function onVerify() { refreshOverview(); } +async function onRefreshSession() { + const out = await request( + "POST", + "/secret/wallet/session/refresh", + { wallet: requireWallet() }, + { skipSessionPreflight: true }, + ); + if (out.session_token) { + state.walletSessionToken = String(out.session_token).trim(); + } + if (out.session_expires_at) { + state.walletSessionExpiresAt = String(out.session_expires_at).trim(); + } + logLine("wallet session refresh", out); + refreshOverview(); + return out; +} + +async function onRevokeSession() { + const out = await request( + "POST", + "/secret/wallet/session/revoke", + { wallet: requireWallet() }, + { skipSessionPreflight: true }, + ); + logLine("wallet session revoke", out); + clearWalletSession("manual_revoke", { wallet: requireWallet() }); + return out; +} + async function onStatus() { const out = await request( "GET", @@ -943,6 +1085,8 @@ bind("btnRunMembershipFlow", onRunMembershipFlow); bind("btnIntent", onIntent); bind("btnSignIntent", onSignIntent); bind("btnVerify", onVerify); +bind("btnRefreshSession", onRefreshSession); +bind("btnRevokeSession", onRevokeSession); bind("btnStatus", onStatus); bind("btnQuote", onQuote); bind("btnSignPayerProof", onSignPayerProof); diff --git a/app/index.html b/app/index.html index f01916f..b0ac1a3 100644 --- a/app/index.html +++ b/app/index.html @@ -101,6 +101,8 @@ + +