launcher/app/app.js

766 lines
22 KiB
JavaScript

/* global fetch */
const $ = (id) => document.getElementById(id);
const state = {
eventMap: new Map(),
lastIntent: null,
lastQuote: null,
lastCheckoutQuote: 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 onListOffers() {
const out = await request("GET", "/marketplace/offers");
logLine("marketplace offers", out);
return out;
}
async function onCheckoutQuote() {
const payload = {
wallet: requireWallet(),
offer_id: $("checkoutOfferId").value.trim(),
org_root_id: orgRootID(),
principal_id: principalID(),
principal_role: principalRole(),
include_membership_if_missing: true,
};
const payerWallet = $("payerWallet").value.trim();
const payerProof = $("payerProof").value.trim();
if (payerWallet) {
payload.payer_wallet = payerWallet;
}
if (payerProof) {
payload.ownership_proof = payerProof;
}
const out = await request("POST", "/marketplace/checkout/quote", payload);
state.lastCheckoutQuote = out;
$("checkoutQuoteId").value = out.quote_id || "";
$("checkoutTotal").value = out.total_amount || "";
logLine("marketplace checkout quote", out);
return out;
}
async function onCheckoutConfirm() {
const quoteID = $("checkoutQuoteId").value.trim();
if (!quoteID) {
throw new Error("checkout quote id is required");
}
const txHash = $("checkoutTxHash").value.trim();
if (!txHash) {
throw new Error("checkout tx hash is required");
}
const offerID = state.lastCheckoutQuote?.offer_id || $("checkoutOfferId").value.trim();
const out = await request("POST", "/marketplace/checkout/confirm", {
quote_id: quoteID,
wallet: requireWallet(),
offer_id: offerID,
org_root_id: orgRootID(),
principal_id: principalID(),
principal_role: principalRole(),
tx_hash: txHash,
chain_id: chainID(),
});
$("checkoutEntitlementId").value = out.entitlement_id || "";
logLine("marketplace checkout confirm", out);
return out;
}
async function onListEntitlements() {
const out = await request("GET", `/marketplace/entitlements?wallet=${encodeURIComponent(requireWallet())}`);
logLine("marketplace entitlements", 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);
bind("btnListOffers", onListOffers);
bind("btnCheckoutQuote", onCheckoutQuote);
bind("btnCheckoutConfirm", onCheckoutConfirm);
bind("btnListEntitlements", onListEntitlements);
logLine("launcher shell ready", {
api_base: baseURL(),
chain_id: chainID(),
});
refreshOverview();