Add launcher wallet session refresh and revoke flows

This commit is contained in:
Joshua 2026-02-18 20:46:02 -08:00
parent c92676194f
commit 87b6a321c4
3 changed files with 153 additions and 5 deletions

View File

@ -46,6 +46,7 @@ Advanced integration controls (collapsible):
5. Member channel register/poll primitives 5. Member channel register/poll primitives
6. Governance install + lease primitives (with explicit `operation_mode`) 6. Governance install + lease primitives (with explicit `operation_mode`)
7. Raw response log for deterministic troubleshooting 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: 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`. 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. 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`. 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: Policy behavior in launcher shell:

View File

@ -11,6 +11,7 @@ const state = {
channelReady: false, channelReady: false,
walletSessionToken: "", walletSessionToken: "",
walletSessionExpiresAt: "", walletSessionExpiresAt: "",
walletSessionRefreshInFlight: null,
}; };
function nowISO() { function nowISO() {
@ -62,7 +63,107 @@ function sessionSummary() {
if (!state.walletSessionExpiresAt) { if (!state.walletSessionExpiresAt) {
return "active"; return "active";
} }
const expires = Date.parse(state.walletSessionExpiresAt);
if (!Number.isFinite(expires)) {
return `active (exp ${state.walletSessionExpiresAt})`; 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() { function injectedProvider() {
@ -145,7 +246,10 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, 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" }; const headers = { "Content-Type": "application/json" };
if (state.walletSessionToken) { if (state.walletSessionToken) {
headers["X-Edut-Session"] = state.walletSessionToken; headers["X-Edut-Session"] = state.walletSessionToken;
@ -159,6 +263,7 @@ async function request(method, path, body) {
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} }
const res = await fetch(`${baseURL()}${path}`, opts); const res = await fetch(`${baseURL()}${path}`, opts);
captureSessionHeaders(res);
const text = await res.text(); const text = await res.text();
let json = {}; let json = {};
if (text.trim() !== "") { if (text.trim() !== "") {
@ -169,7 +274,16 @@ async function request(method, path, body) {
} }
} }
if (!res.ok) { 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; return json;
} }
@ -291,9 +405,7 @@ async function onConnectWallet() {
const nextWallet = normalizedAddress(accounts[0]); const nextWallet = normalizedAddress(accounts[0]);
$("walletAddress").value = nextWallet; $("walletAddress").value = nextWallet;
if (previousWallet && previousWallet !== nextWallet) { if (previousWallet && previousWallet !== nextWallet) {
state.walletSessionToken = ""; clearWalletSession("wallet_changed", {
state.walletSessionExpiresAt = "";
logLine("wallet session reset", {
previous_wallet: previousWallet, previous_wallet: previousWallet,
next_wallet: nextWallet, next_wallet: nextWallet,
}); });
@ -444,6 +556,36 @@ async function onVerify() {
refreshOverview(); 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() { async function onStatus() {
const out = await request( const out = await request(
"GET", "GET",
@ -943,6 +1085,8 @@ bind("btnRunMembershipFlow", onRunMembershipFlow);
bind("btnIntent", onIntent); bind("btnIntent", onIntent);
bind("btnSignIntent", onSignIntent); bind("btnSignIntent", onSignIntent);
bind("btnVerify", onVerify); bind("btnVerify", onVerify);
bind("btnRefreshSession", onRefreshSession);
bind("btnRevokeSession", onRevokeSession);
bind("btnStatus", onStatus); bind("btnStatus", onStatus);
bind("btnQuote", onQuote); bind("btnQuote", onQuote);
bind("btnSignPayerProof", onSignPayerProof); bind("btnSignPayerProof", onSignPayerProof);

View File

@ -101,6 +101,8 @@
<button id="btnIntent">Create intent</button> <button id="btnIntent">Create intent</button>
<button id="btnSignIntent">Sign intent (EIP-712)</button> <button id="btnSignIntent">Sign intent (EIP-712)</button>
<button id="btnVerify">Verify signature</button> <button id="btnVerify">Verify signature</button>
<button id="btnRefreshSession">Refresh session</button>
<button id="btnRevokeSession">Revoke session</button>
<button id="btnStatus">Membership status</button> <button id="btnStatus">Membership status</button>
</div> </div>
<div class="grid three"> <div class="grid three">