/* global fetch */ const $ = (id) => document.getElementById(id); const state = { eventMap: new Map(), lastIntent: null, lastQuote: null, channelReady: false, }; function nowISO() { return new Date().toISOString(); } function baseURL() { return $("apiBase").value.trim().replace(/\/+$/, ""); } function wallet() { return $("walletAddress").value.trim(); } function chainID() { return Number($("chainId").value || 0); } function normalizedAddress(value) { return String(value || "").trim().toLowerCase(); } function injectedProvider() { return globalThis.ethereum || null; } async function requireProvider() { const provider = injectedProvider(); if (!provider) { throw new Error("no injected wallet provider found (expected window.ethereum)"); } return provider; } function utf8ToHex(value) { return `0x${Array.from(new TextEncoder().encode(String(value))) .map((b) => b.toString(16).padStart(2, "0")) .join("")}`; } function logLine(label, payload) { const log = $("log"); const line = `[${nowISO()}] ${label}\n${JSON.stringify(payload, null, 2)}\n\n`; log.textContent = line + log.textContent; } function setSummary(id, value) { const el = $(id); if (!el) return; el.textContent = value; } function refreshOverview(statusPayload) { const currentWallet = wallet(); setSummary("summaryWallet", currentWallet || "not connected"); if (statusPayload && typeof statusPayload === "object") { setSummary("summaryMembership", statusPayload.status || "unknown"); setSummary("summaryDesignation", statusPayload.designation_code || "-"); setSummary("summaryLastSync", nowISO()); return; } const designation = $("designationCode")?.value?.trim() || "-"; setSummary("summaryDesignation", designation); } function setFlowStatus(message) { const el = $("flowStatus"); if (el) { el.textContent = message; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function request(method, path, body) { const opts = { method, headers: { "Content-Type": "application/json" }, }; if (body !== undefined) { opts.body = JSON.stringify(body); } const res = await fetch(`${baseURL()}${path}`, opts); const text = await res.text(); let json = {}; if (text.trim() !== "") { try { json = JSON.parse(text); } catch { json = { raw: text }; } } if (!res.ok) { throw new Error(`${res.status} ${res.statusText}: ${JSON.stringify(json)}`); } return json; } function requireWallet() { const value = wallet(); if (!value) { throw new Error("wallet is required"); } return value; } function deviceID() { return $("deviceId").value.trim(); } function orgRootID() { return $("orgRootId").value.trim(); } function principalID() { return $("principalId").value.trim(); } function principalRole() { return $("principalRole").value.trim(); } function renderEvents(events) { const list = $("eventList"); list.innerHTML = ""; for (const evt of events) { state.eventMap.set(evt.event_id, evt); const card = document.createElement("article"); card.className = "event"; const title = document.createElement("h3"); title.textContent = `${evt.class} · ${evt.event_id}`; card.appendChild(title); const body = document.createElement("p"); body.textContent = evt.body || evt.title || ""; card.appendChild(body); const meta = document.createElement("div"); meta.className = "meta"; meta.textContent = `${evt.created_at} · scope=${evt.visibility_scope} · ack=${evt.requires_ack}`; card.appendChild(meta); if (evt.requires_ack) { const actions = document.createElement("div"); actions.className = "actions"; const button = document.createElement("button"); button.textContent = "Ack"; button.addEventListener("click", async () => { try { const out = await request("POST", `/member/channel/events/${evt.event_id}/ack`, { wallet: requireWallet(), device_id: deviceID(), acknowledged_at: nowISO(), }); logLine(`event ack ${evt.event_id}`, out); } catch (err) { logLine(`event ack ${evt.event_id} error`, { error: String(err) }); } }); actions.appendChild(button); card.appendChild(actions); } list.appendChild(card); } } function buildIntentTypedData(intent, origin) { return { types: { EIP712Domain: [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ], DesignationIntent: [ { name: "designationCode", type: "string" }, { name: "designationToken", type: "string" }, { name: "nonce", type: "string" }, { name: "issuedAt", type: "string" }, { name: "origin", type: "string" }, ], }, primaryType: "DesignationIntent", domain: { name: intent.domain_name, version: "1", chainId: Number(intent.chain_id), verifyingContract: intent.verifying_contract, }, message: { designationCode: intent.designation_code, designationToken: intent.display_token, nonce: intent.nonce, issuedAt: intent.issued_at, origin, }, }; } async function onConnectWallet() { const provider = await requireProvider(); const accounts = await provider.request({ method: "eth_requestAccounts" }); if (!accounts || accounts.length === 0) { throw new Error("wallet provider returned no accounts"); } $("walletAddress").value = normalizedAddress(accounts[0]); const chainHex = await provider.request({ method: "eth_chainId" }); const providerChainID = Number.parseInt(chainHex, 16); if (Number.isFinite(providerChainID) && providerChainID !== chainID()) { logLine("wallet chain mismatch", { configured_chain_id: chainID(), provider_chain_id: providerChainID, provider_chain_hex: chainHex, }); } else { logLine("wallet connected", { wallet: $("walletAddress").value, provider_chain_id: providerChainID, }); } refreshOverview(); } async function onSignIntent() { if (!state.lastIntent) { throw new Error("create intent before signing"); } const provider = await requireProvider(); const owner = requireWallet(); const typedData = buildIntentTypedData(state.lastIntent, $("walletOrigin").value.trim() || "https://edut.ai"); const typedDataJSON = JSON.stringify(typedData); let signature = ""; try { signature = await provider.request({ method: "eth_signTypedData_v4", params: [owner, typedDataJSON], }); } catch (primaryErr) { signature = await provider.request({ method: "eth_signTypedData", params: [owner, typedData], }); logLine("wallet sign intent fallback", { warning: String(primaryErr) }); } $("walletSignature").value = signature; logLine("wallet sign intent", { wallet: owner, designation_code: state.lastIntent.designation_code, signature, }); } async function onSignPayerProof() { const provider = await requireProvider(); const owner = requireWallet(); const payer = normalizedAddress($("payerWallet").value); if (!payer) { throw new Error("payer wallet is required to sign payer proof"); } const designationCode = $("designationCode").value.trim(); if (!designationCode) { throw new Error("designation code is required"); } const message = `EDUT-PAYER-AUTH:${designationCode}:${normalizedAddress(owner)}:${payer}:${chainID()}`; const messageHex = utf8ToHex(message); let signature = ""; try { signature = await provider.request({ method: "personal_sign", params: [messageHex, owner], }); } catch (primaryErr) { signature = await provider.request({ method: "personal_sign", params: [owner, messageHex], }); logLine("wallet payer proof fallback", { warning: String(primaryErr) }); } $("payerProof").value = signature; logLine("wallet signed payer proof", { wallet: owner, payer_wallet: payer, designation_code: designationCode, signature, }); } async function onSendMembershipTx() { if (!state.lastQuote || !state.lastQuote.tx) { throw new Error("request quote before sending transaction"); } const provider = await requireProvider(); const from = normalizedAddress(state.lastQuote.tx.from || requireWallet()); if (from !== normalizedAddress(requireWallet())) { throw new Error(`active wallet ${requireWallet()} does not match quote payer ${from}`); } const txRequest = { from, to: state.lastQuote.tx.to, data: state.lastQuote.tx.data, value: state.lastQuote.tx.value || "0x0", }; const txHash = await provider.request({ method: "eth_sendTransaction", params: [txRequest], }); $("confirmTxHash").value = txHash; logLine("membership tx sent", { quote_id: state.lastQuote.quote_id, tx_hash: txHash, payer_wallet: from, }); } async function onIntent() { const payload = { address: requireWallet(), origin: $("walletOrigin").value.trim() || "https://edut.ai", locale: $("walletLocale").value.trim() || "en", chain_id: chainID(), }; const out = await request("POST", "/secret/wallet/intent", payload); state.lastIntent = out; $("intentId").value = out.intent_id || ""; $("designationCode").value = out.designation_code || ""; $("displayToken").value = out.display_token || ""; logLine("wallet intent", out); refreshOverview(); } async function onVerify() { const out = await request("POST", "/secret/wallet/verify", { intent_id: $("intentId").value.trim(), address: requireWallet(), chain_id: chainID(), signature: $("walletSignature").value.trim(), }); if (out.designation_code) { $("designationCode").value = out.designation_code; } logLine("wallet verify", out); refreshOverview(); } async function onStatus() { const out = await request( "GET", `/secret/membership/status?wallet=${encodeURIComponent(requireWallet())}`, ); if (out.designation_code) { $("designationCode").value = out.designation_code; } logLine("membership status", out); refreshOverview(out); return out; } async function onQuote() { const payload = { designation_code: $("designationCode").value.trim(), address: requireWallet(), chain_id: chainID(), }; const payerWallet = $("payerWallet").value.trim(); const payerProof = $("payerProof").value.trim(); const sponsorOrgRoot = $("sponsorOrgRoot").value.trim(); if (payerWallet) { payload.payer_wallet = payerWallet; } if (payerProof) { payload.payer_proof = payerProof; } if (sponsorOrgRoot) { payload.sponsor_org_root_id = sponsorOrgRoot; } const out = await request("POST", "/secret/membership/quote", payload); state.lastQuote = out; $("quoteId").value = out.quote_id || ""; $("quoteValue").value = out.value || ""; $("quotePayer").value = out.payer_wallet || ""; logLine("membership quote", out); return out; } async function onConfirmMembership() { const out = await request("POST", "/secret/membership/confirm", { designation_code: $("designationCode").value.trim(), quote_id: $("quoteId").value.trim(), tx_hash: $("confirmTxHash").value.trim(), address: requireWallet(), chain_id: chainID(), }); logLine("membership confirm", out); return out; } async function waitForTxMined(txHash, timeoutMs = 120000, intervalMs = 3000) { const provider = await requireProvider(); const started = Date.now(); while (Date.now() - started < timeoutMs) { const receipt = await provider.request({ method: "eth_getTransactionReceipt", params: [txHash], }); if (receipt) { const statusHex = String(receipt.status || "").toLowerCase(); if (statusHex === "0x1" || statusHex === "1") { return receipt; } throw new Error(`membership transaction reverted: status=${receipt.status}`); } await sleep(intervalMs); } throw new Error(`membership transaction not mined within ${timeoutMs}ms`); } async function confirmMembershipWithRetry(maxAttempts = 8, intervalMs = 2500) { let lastErr = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const out = await onConfirmMembership(); return out; } catch (err) { lastErr = err; const message = String(err || ""); if (!message.includes("tx verification pending/failed")) { throw err; } setFlowStatus(`confirm pending (${attempt}/${maxAttempts})`); await sleep(intervalMs); } } throw lastErr || new Error("membership confirm failed"); } async function onRunMembershipFlow() { setFlowStatus("connecting wallet"); await onConnectWallet(); setFlowStatus("checking membership"); const status = await onStatus(); if (String(status.status || "").toLowerCase() === "active") { setFlowStatus("membership already active"); return; } setFlowStatus("creating intent"); await onIntent(); setFlowStatus("signing intent"); await onSignIntent(); setFlowStatus("verifying intent"); await onVerify(); setFlowStatus("quoting membership"); await onQuote(); setFlowStatus("sending membership transaction"); await onSendMembershipTx(); const txHash = $("confirmTxHash").value.trim(); if (!txHash) { throw new Error("missing tx hash after send"); } setFlowStatus("waiting for chain confirmation"); await waitForTxMined(txHash); setFlowStatus("confirming membership with API"); await confirmMembershipWithRetry(); setFlowStatus("refreshing status"); await onStatus(); setFlowStatus("membership flow complete"); } async function onRegisterChannel() { const out = await request("POST", "/member/channel/device/register", { wallet: requireWallet(), chain_id: chainID(), device_id: deviceID(), platform: $("platform").value.trim(), org_root_id: orgRootID(), principal_id: principalID(), principal_role: principalRole(), app_version: $("appVersion").value.trim(), push_provider: "none", }); state.channelReady = true; logLine("channel register", out); return out; } async function onUnregisterChannel() { const out = await request("POST", "/member/channel/device/unregister", { wallet: requireWallet(), device_id: deviceID(), }); state.channelReady = false; logLine("channel unregister", out); return out; } async function onPollEvents() { const cursor = $("eventCursor").value.trim(); const limit = $("eventLimit").value.trim() || "25"; const query = new URLSearchParams({ wallet: requireWallet(), device_id: deviceID(), limit, }); if (cursor) { query.set("cursor", cursor); } const out = await request("GET", `/member/channel/events?${query.toString()}`); if (out.next_cursor) { $("eventCursor").value = out.next_cursor; } renderEvents(out.events || []); logLine("channel poll", out); return out; } async function onSupportTicket() { const out = await request("POST", "/member/channel/support/ticket", { wallet: requireWallet(), org_root_id: orgRootID(), principal_id: principalID(), category: "admin_support", summary: $("supportSummary").value.trim(), context: { source: "launcher-shell", requested_at: nowISO(), }, }); logLine("support ticket", out); } async function onInstallToken() { const out = await request("POST", "/governance/install/token", { wallet: requireWallet(), org_root_id: orgRootID(), principal_id: principalID(), principal_role: principalRole(), device_id: deviceID(), launcher_version: $("appVersion").value.trim(), platform: $("platform").value.trim(), }); $("installToken").value = out.install_token || ""; $("entitlementId").value = out.entitlement_id || ""; $("runtimeVersion").value = out.package?.runtime_version || ""; $("packageHash").value = out.package?.package_hash || ""; logLine("install token", out); } async function onInstallConfirm() { const out = await request("POST", "/governance/install/confirm", { install_token: $("installToken").value.trim(), wallet: requireWallet(), device_id: deviceID(), entitlement_id: $("entitlementId").value.trim(), package_hash: $("packageHash").value.trim(), runtime_version: $("runtimeVersion").value.trim(), installed_at: nowISO(), launcher_receipt_hash: `receipt-${Date.now()}`, }); logLine("install confirm", out); } async function onInstallStatus() { const query = new URLSearchParams({ wallet: requireWallet(), device_id: deviceID(), }); const out = await request("GET", `/governance/install/status?${query.toString()}`); logLine("install status", out); return out; } async function ensureChannelBinding() { if (state.channelReady) { return; } await onRegisterChannel(); } async function onQuickConnect() { await onConnectWallet(); try { await onStatus(); } catch (err) { logLine("quick connect status warning", { error: String(err) }); } } async function onQuickActivate() { await onRunMembershipFlow(); try { await ensureChannelBinding(); await onPollEvents(); } catch (err) { logLine("quick activate feed warning", { error: String(err) }); } } async function onQuickRefresh() { const status = await onStatus(); if (String(status.status || "").toLowerCase() !== "active") { setFlowStatus("membership inactive"); return; } try { await ensureChannelBinding(); await onPollEvents(); setFlowStatus("status synced"); } catch (err) { setFlowStatus("feed sync warning"); throw err; } } async function onQuickInstallStatus() { await onInstallStatus(); } async function onLeaseHeartbeat() { const out = await request("POST", "/governance/lease/heartbeat", { wallet: requireWallet(), org_root_id: orgRootID(), principal_id: principalID(), device_id: deviceID(), }); logLine("lease heartbeat", out); } async function onOfflineRenew() { const out = await request("POST", "/governance/lease/offline-renew", { wallet: requireWallet(), org_root_id: orgRootID(), principal_id: principalID(), renewal_bundle: { bundle_id: `renew-${Date.now()}`, source: "launcher-shell", }, }); logLine("offline renew", out); } function bind(id, handler) { const el = $(id); if (!el) { return; } el.addEventListener("click", async () => { try { await handler(); } catch (err) { logLine(`${id} error`, { error: String(err) }); } }); } bind("btnQuickConnect", onQuickConnect); bind("btnQuickActivate", onQuickActivate); bind("btnQuickRefresh", onQuickRefresh); bind("btnQuickInstallStatus", onQuickInstallStatus); bind("btnConnectWallet", onConnectWallet); bind("btnRunMembershipFlow", onRunMembershipFlow); bind("btnIntent", onIntent); bind("btnSignIntent", onSignIntent); bind("btnVerify", onVerify); bind("btnStatus", onStatus); bind("btnQuote", onQuote); bind("btnSignPayerProof", onSignPayerProof); bind("btnSendMembershipTx", onSendMembershipTx); bind("btnConfirmMembership", onConfirmMembership); bind("btnRegisterChannel", onRegisterChannel); bind("btnUnregisterChannel", onUnregisterChannel); bind("btnPollEvents", onPollEvents); bind("btnSupportTicket", onSupportTicket); bind("btnInstallToken", onInstallToken); bind("btnInstallConfirm", onInstallConfirm); bind("btnInstallStatus", onInstallStatus); bind("btnLeaseHeartbeat", onLeaseHeartbeat); bind("btnOfflineRenew", onOfflineRenew); logLine("launcher shell ready", { api_base: baseURL(), chain_id: chainID(), }); refreshOverview();