Compare commits

...

10 Commits

8 changed files with 513 additions and 32 deletions

View File

@ -33,9 +33,11 @@ Top-level control surface:
2. `Activate membership` 2. `Activate membership`
3. `Refresh status + feed` 3. `Refresh status + feed`
4. `Governance status` 4. `Governance status`
5. Wallet/membership/designation/last-sync overview cards 5. Wallet/session/membership/designation/last-sync overview cards
6. Pull-first updates feed + support ticket action 6. Pull-first updates feed + support ticket action
7. Identity assurance visibility (`none` / `crypto_direct_unattested` / `sponsored_unattested` / `onramp_attested`) 7. Identity assurance visibility (`none` / `crypto_direct_unattested` / `sponsored_unattested` / `onramp_attested`)
8. Explicit operator-visible mode toggles (`Human mode` / `Auto mode`) synced to governance `operation_mode`
9. Wallet utility actions (`Refresh balances`, `Copy address`) with native + USDC balance visibility
Advanced integration controls (collapsible): Advanced integration controls (collapsible):
@ -44,8 +46,9 @@ Advanced integration controls (collapsible):
3. Membership quote + confirm primitives 3. Membership quote + confirm primitives
4. Marketplace offer list + checkout quote/send/confirm primitives 4. Marketplace offer list + checkout quote/send/confirm primitives
5. Member channel register/poll primitives 5. Member channel register/poll primitives
6. Governance install + lease primitives 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:
@ -54,12 +57,16 @@ Wallet automation helpers remain available in advanced controls:
3. `Sign payer proof` signs distinct-payer ownership proof and fills `payerProof`. 3. `Sign payer proof` signs distinct-payer ownership proof and fills `payerProof`.
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`.
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:
1. Membership is required for all member-channel polling flows. 1. Membership is required for all member-channel polling flows.
2. `onramp_attested` identity assurance is required for owner support-ticket and governance install-token actions. 2. `onramp_attested` identity assurance is required for owner support-ticket and governance install-token actions.
3. Assurance state is displayed independently from membership state in the top summary cards. 3. Assurance state is displayed independently from membership state in the top summary cards.
4. Owner-only buttons are UI-disabled until both membership is active and assurance is `onramp_attested`.
5. Governance activation evidence must carry explicit signing authority class (`identity_human` or delegated).
Run locally: Run locally:

View File

@ -9,6 +9,11 @@ const state = {
lastCheckoutQuote: null, lastCheckoutQuote: null,
lastStatus: null, lastStatus: null,
channelReady: false, channelReady: false,
walletSessionToken: "",
walletSessionExpiresAt: "",
walletSessionRefreshInFlight: null,
walletBalanceNative: "",
walletBalanceUSDC: "",
}; };
function nowISO() { function nowISO() {
@ -53,6 +58,122 @@ function isOnrampAttested(value) {
return normalizeAssurance(value) === "onramp_attested"; return normalizeAssurance(value) === "onramp_attested";
} }
function sessionSummary() {
if (!state.walletSessionToken) {
return "none";
}
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 walletBalanceSummary() {
const native = state.walletBalanceNative || "-- ETH";
const usdc = state.walletBalanceUSDC || "-- USDC";
return `${native} | ${usdc}`;
}
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() {
return globalThis.ethereum || null; return globalThis.ethereum || null;
} }
@ -71,6 +192,41 @@ function utf8ToHex(value) {
.join("")}`; .join("")}`;
} }
function normalizeHexQuantity(value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw.startsWith("0x")) {
return null;
}
return raw;
}
function formatUnitsHex(hexValue, decimals, precision = 6) {
const hex = normalizeHexQuantity(hexValue);
if (!hex) {
return null;
}
let bigint;
try {
bigint = BigInt(hex);
} catch {
return null;
}
const unit = 10n ** BigInt(Math.max(0, Number(decimals) || 0));
const whole = bigint / unit;
const fraction = bigint % unit;
if (fraction === 0n) {
return whole.toString();
}
const padded = fraction.toString().padStart(Number(decimals), "0");
const trimmed = padded.slice(0, Math.max(1, precision)).replace(/0+$/, "");
return trimmed ? `${whole.toString()}.${trimmed}` : whole.toString();
}
function encodeAddressWord(address) {
const normalized = normalizedAddress(address).replace(/^0x/, "");
return normalized.padStart(64, "0");
}
function logLine(label, payload) { function logLine(label, payload) {
const log = $("log"); const log = $("log");
const line = `[${nowISO()}] ${label}\n${JSON.stringify(payload, null, 2)}\n\n`; const line = `[${nowISO()}] ${label}\n${JSON.stringify(payload, null, 2)}\n\n`;
@ -83,9 +239,29 @@ function setSummary(id, value) {
el.textContent = value; el.textContent = value;
} }
function setButtonDisabled(id, disabled) {
const el = $(id);
if (!el) return;
el.disabled = Boolean(disabled);
}
function refreshActionLocks(statusPayload) {
const effective = statusPayload && typeof statusPayload === "object" ? statusPayload : state.lastStatus;
const membershipActive = String(effective?.status || "").toLowerCase() === "active";
const attested = isOnrampAttested(effective?.identity_assurance_level);
const ownerActionReady = membershipActive && attested;
setButtonDisabled("btnSupportTicket", !ownerActionReady);
setButtonDisabled("btnInstallToken", !ownerActionReady);
setButtonDisabled("btnQuickInstallStatus", !membershipActive);
}
function refreshOverview(statusPayload) { function refreshOverview(statusPayload) {
const currentWallet = wallet(); const currentWallet = wallet();
setSummary("summaryWallet", currentWallet || "not connected"); setSummary("summaryWallet", currentWallet || "not connected");
setSummary("summaryFunds", walletBalanceSummary());
setSummary("summarySession", sessionSummary());
refreshModeUI();
if (statusPayload && typeof statusPayload === "object") { if (statusPayload && typeof statusPayload === "object") {
setSummary("summaryMembership", statusPayload.status || "unknown"); setSummary("summaryMembership", statusPayload.status || "unknown");
setSummary("summaryDesignation", statusPayload.designation_code || "-"); setSummary("summaryDesignation", statusPayload.designation_code || "-");
@ -93,6 +269,7 @@ function refreshOverview(statusPayload) {
setSummary("summaryAssurance", assurance); setSummary("summaryAssurance", assurance);
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked"); setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
setSummary("summaryLastSync", nowISO()); setSummary("summaryLastSync", nowISO());
refreshActionLocks(statusPayload);
return; return;
} }
const designation = $("designationCode")?.value?.trim() || "-"; const designation = $("designationCode")?.value?.trim() || "-";
@ -100,6 +277,7 @@ function refreshOverview(statusPayload) {
const assurance = assuranceDisplay(state.lastStatus?.identity_assurance_level); const assurance = assuranceDisplay(state.lastStatus?.identity_assurance_level);
setSummary("summaryAssurance", assurance); setSummary("summaryAssurance", assurance);
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked"); setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
refreshActionLocks();
} }
function setFlowStatus(message) { function setFlowStatus(message) {
@ -113,15 +291,24 @@ 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" };
if (state.walletSessionToken) {
headers["X-Edut-Session"] = state.walletSessionToken;
headers.Authorization = `Bearer ${state.walletSessionToken}`;
}
const opts = { const opts = {
method, method,
headers: { "Content-Type": "application/json" }, headers,
}; };
if (body !== undefined) { if (body !== undefined) {
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() !== "") {
@ -132,7 +319,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;
} }
@ -161,6 +357,38 @@ function principalRole() {
return $("principalRole").value.trim(); return $("principalRole").value.trim();
} }
function operationMode() {
return $("operationMode").value.trim() || "human_manual";
}
function normalizeOperationMode(value) {
return String(value || "").trim().toLowerCase() === "worker_auto" ? "worker_auto" : "human_manual";
}
function refreshModeUI() {
const mode = normalizeOperationMode(operationMode());
setSummary("summaryMode", mode);
const humanBtn = $("btnModeHuman");
const autoBtn = $("btnModeAuto");
if (humanBtn) {
humanBtn.classList.toggle("mode-active", mode === "human_manual");
}
if (autoBtn) {
autoBtn.classList.toggle("mode-active", mode === "worker_auto");
}
}
function setOperationMode(mode, source = "ui") {
const normalized = normalizeOperationMode(mode);
const select = $("operationMode");
if (select) {
select.value = normalized;
}
refreshModeUI();
logLine("operation mode set", { mode: normalized, source });
return normalized;
}
function renderEvents(events) { function renderEvents(events) {
const list = $("eventList"); const list = $("eventList");
list.innerHTML = ""; list.innerHTML = "";
@ -241,12 +469,20 @@ function buildIntentTypedData(intent, origin) {
} }
async function onConnectWallet() { async function onConnectWallet() {
const previousWallet = normalizedAddress($("walletAddress").value);
const provider = await requireProvider(); const provider = await requireProvider();
const accounts = await provider.request({ method: "eth_requestAccounts" }); const accounts = await provider.request({ method: "eth_requestAccounts" });
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {
throw new Error("wallet provider returned no accounts"); throw new Error("wallet provider returned no accounts");
} }
$("walletAddress").value = normalizedAddress(accounts[0]); const nextWallet = normalizedAddress(accounts[0]);
$("walletAddress").value = nextWallet;
if (previousWallet && previousWallet !== nextWallet) {
clearWalletSession("wallet_changed", {
previous_wallet: previousWallet,
next_wallet: nextWallet,
});
}
const chainHex = await provider.request({ method: "eth_chainId" }); const chainHex = await provider.request({ method: "eth_chainId" });
const providerChainID = Number.parseInt(chainHex, 16); const providerChainID = Number.parseInt(chainHex, 16);
if (Number.isFinite(providerChainID) && providerChainID !== chainID()) { if (Number.isFinite(providerChainID) && providerChainID !== chainID()) {
@ -261,9 +497,74 @@ async function onConnectWallet() {
provider_chain_id: providerChainID, provider_chain_id: providerChainID,
}); });
} }
try {
await onRefreshBalances();
} catch (err) {
logLine("wallet balance refresh warning", { error: String(err) });
}
refreshOverview(); refreshOverview();
} }
async function onRefreshBalances() {
const address = normalizedAddress(requireWallet());
const provider = await requireProvider();
const nativeHex = await provider.request({
method: "eth_getBalance",
params: [address, "latest"],
});
const nativeDisplay = formatUnitsHex(nativeHex, 18, 6);
if (nativeDisplay) {
state.walletBalanceNative = `${nativeDisplay} ETH`;
}
const usdcToken = normalizedAddress($("usdcTokenAddress")?.value || "");
if (usdcToken && /^0x[a-f0-9]{40}$/.test(usdcToken)) {
const balanceOfSelector = "0x70a08231";
const decimalsSelector = "0x313ce567";
const balanceCallData = `${balanceOfSelector}${encodeAddressWord(address)}`;
const usdcHex = await provider.request({
method: "eth_call",
params: [{ to: usdcToken, data: balanceCallData }, "latest"],
});
let decimals = 6;
try {
const decimalsHex = await provider.request({
method: "eth_call",
params: [{ to: usdcToken, data: decimalsSelector }, "latest"],
});
const parsed = Number(BigInt(String(decimalsHex || "0x6")));
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 36) {
decimals = parsed;
}
} catch {
// keep default 6 when token decimals call is unavailable
}
const usdcDisplay = formatUnitsHex(usdcHex, decimals, 2);
if (usdcDisplay) {
state.walletBalanceUSDC = `${usdcDisplay} USDC`;
}
} else if (usdcToken) {
state.walletBalanceUSDC = "invalid token";
}
refreshOverview();
logLine("wallet balances refreshed", {
wallet: address,
native: state.walletBalanceNative || "",
usdc: state.walletBalanceUSDC || "",
});
}
async function onCopyWallet() {
const address = requireWallet();
if (!navigator?.clipboard?.writeText) {
throw new Error("clipboard API unavailable");
}
await navigator.clipboard.writeText(address);
setFlowStatus("wallet address copied");
logLine("wallet copied", { wallet: address });
}
async function onSignIntent() { async function onSignIntent() {
if (!state.lastIntent) { if (!state.lastIntent) {
throw new Error("create intent before signing"); throw new Error("create intent before signing");
@ -348,7 +649,7 @@ async function onSendMembershipTx() {
params: [txRequest], params: [txRequest],
}); });
$("confirmTxHash").value = txHash; $("confirmTxHash").value = txHash;
logLine("membership tx sent", { logLine("EDUT ID tx sent", {
quote_id: state.lastQuote.quote_id, quote_id: state.lastQuote.quote_id,
tx_hash: txHash, tx_hash: txHash,
payer_wallet: from, payer_wallet: from,
@ -381,10 +682,48 @@ async function onVerify() {
if (out.designation_code) { if (out.designation_code) {
$("designationCode").value = out.designation_code; $("designationCode").value = out.designation_code;
} }
state.walletSessionToken = String(out.session_token || "").trim();
state.walletSessionExpiresAt = String(out.session_expires_at || "").trim();
logLine("wallet verify", out); logLine("wallet verify", out);
if (state.walletSessionToken) {
logLine("wallet session active", {
wallet: requireWallet(),
session_expires_at: state.walletSessionExpiresAt || "unknown",
});
}
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",
@ -394,7 +733,7 @@ async function onStatus() {
if (out.designation_code) { if (out.designation_code) {
$("designationCode").value = out.designation_code; $("designationCode").value = out.designation_code;
} }
logLine("membership status", out); logLine("EDUT ID status", out);
refreshOverview(out); refreshOverview(out);
return out; return out;
} }
@ -422,7 +761,7 @@ async function onQuote() {
$("quoteId").value = out.quote_id || ""; $("quoteId").value = out.quote_id || "";
$("quoteValue").value = out.value || ""; $("quoteValue").value = out.value || "";
$("quotePayer").value = out.payer_wallet || ""; $("quotePayer").value = out.payer_wallet || "";
logLine("membership quote", out); logLine("EDUT ID quote", out);
return out; return out;
} }
@ -456,14 +795,14 @@ async function onConfirmMembership() {
identity_attested_by: out.identity_attested_by || "", identity_attested_by: out.identity_attested_by || "",
identity_attestation_id: out.identity_attestation_id || "", identity_attestation_id: out.identity_attestation_id || "",
}; };
logLine("membership confirm", out); logLine("EDUT ID confirm", out);
return out; return out;
} }
async function requireMembershipState(actionLabel, opts = {}) { async function requireMembershipState(actionLabel, opts = {}) {
const status = await onStatus(); const status = await onStatus();
if (String(status.status || "").toLowerCase() !== "active") { if (String(status.status || "").toLowerCase() !== "active") {
throw new Error(`${actionLabel} requires active membership`); throw new Error(`${actionLabel} requires active EDUT ID`);
} }
if (opts.requireOnramp && !isOnrampAttested(status.identity_assurance_level)) { if (opts.requireOnramp && !isOnrampAttested(status.identity_assurance_level)) {
throw new Error(`${actionLabel} requires onramp_attested identity assurance`); throw new Error(`${actionLabel} requires onramp_attested identity assurance`);
@ -507,17 +846,17 @@ async function confirmMembershipWithRetry(maxAttempts = 8, intervalMs = 2500) {
await sleep(intervalMs); await sleep(intervalMs);
} }
} }
throw lastErr || new Error("membership confirm failed"); throw lastErr || new Error("EDUT ID confirmation failed");
} }
async function onRunMembershipFlow() { async function onRunMembershipFlow() {
setFlowStatus("connecting wallet"); setFlowStatus("connecting wallet");
await onConnectWallet(); await onConnectWallet();
setFlowStatus("checking membership"); setFlowStatus("checking EDUT ID");
const status = await onStatus(); const status = await onStatus();
if (String(status.status || "").toLowerCase() === "active") { if (String(status.status || "").toLowerCase() === "active") {
setFlowStatus("membership already active"); setFlowStatus("EDUT ID already active");
return; return;
} }
@ -527,9 +866,9 @@ async function onRunMembershipFlow() {
await onSignIntent(); await onSignIntent();
setFlowStatus("verifying intent"); setFlowStatus("verifying intent");
await onVerify(); await onVerify();
setFlowStatus("quoting membership"); setFlowStatus("quoting EDUT ID");
await onQuote(); await onQuote();
setFlowStatus("sending membership transaction"); setFlowStatus("sending EDUT ID transaction");
await onSendMembershipTx(); await onSendMembershipTx();
const txHash = $("confirmTxHash").value.trim(); const txHash = $("confirmTxHash").value.trim();
@ -538,14 +877,14 @@ async function onRunMembershipFlow() {
} }
setFlowStatus("waiting for chain confirmation"); setFlowStatus("waiting for chain confirmation");
await waitForTxMined(txHash); await waitForTxMined(txHash);
setFlowStatus("confirming membership with API"); setFlowStatus("confirming EDUT ID with API");
await confirmMembershipWithRetry(); await confirmMembershipWithRetry();
setFlowStatus("refreshing status"); setFlowStatus("refreshing status");
const refreshed = await onStatus(); const refreshed = await onStatus();
if (isOnrampAttested(refreshed.identity_assurance_level)) { if (isOnrampAttested(refreshed.identity_assurance_level)) {
setFlowStatus("membership flow complete (attested)"); setFlowStatus("EDUT ID flow complete (attested)");
} else { } else {
setFlowStatus("membership active (unattested)"); setFlowStatus("EDUT ID active (unattested)");
} }
} }
@ -638,6 +977,7 @@ async function onInstallConfirm() {
entitlement_id: $("entitlementId").value.trim(), entitlement_id: $("entitlementId").value.trim(),
package_hash: $("packageHash").value.trim(), package_hash: $("packageHash").value.trim(),
runtime_version: $("runtimeVersion").value.trim(), runtime_version: $("runtimeVersion").value.trim(),
operation_mode: operationMode(),
installed_at: nowISO(), installed_at: nowISO(),
launcher_receipt_hash: `receipt-${Date.now()}`, launcher_receipt_hash: `receipt-${Date.now()}`,
}); });
@ -810,7 +1150,7 @@ async function onQuickActivate() {
async function onQuickRefresh() { async function onQuickRefresh() {
const status = await onStatus(); const status = await onStatus();
if (String(status.status || "").toLowerCase() !== "active") { if (String(status.status || "").toLowerCase() !== "active") {
setFlowStatus("membership inactive"); setFlowStatus("EDUT ID inactive");
return; return;
} }
try { try {
@ -860,6 +1200,16 @@ async function onOfflineRenew() {
logLine("offline renew", out); logLine("offline renew", out);
} }
async function onModeHuman() {
setOperationMode("human_manual", "quick_toggle");
setFlowStatus("mode set: human_manual");
}
async function onModeAuto() {
setOperationMode("worker_auto", "quick_toggle");
setFlowStatus("mode set: worker_auto");
}
function bind(id, handler) { function bind(id, handler) {
const el = $(id); const el = $(id);
if (!el) { if (!el) {
@ -878,11 +1228,17 @@ bind("btnQuickConnect", onQuickConnect);
bind("btnQuickActivate", onQuickActivate); bind("btnQuickActivate", onQuickActivate);
bind("btnQuickRefresh", onQuickRefresh); bind("btnQuickRefresh", onQuickRefresh);
bind("btnQuickInstallStatus", onQuickInstallStatus); bind("btnQuickInstallStatus", onQuickInstallStatus);
bind("btnRefreshBalances", onRefreshBalances);
bind("btnCopyWallet", onCopyWallet);
bind("btnModeHuman", onModeHuman);
bind("btnModeAuto", onModeAuto);
bind("btnConnectWallet", onConnectWallet); bind("btnConnectWallet", onConnectWallet);
bind("btnRunMembershipFlow", onRunMembershipFlow); 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);
@ -904,6 +1260,14 @@ bind("btnCheckoutConfirm", onCheckoutConfirm);
bind("btnRunCheckoutFlow", onRunCheckoutFlow); bind("btnRunCheckoutFlow", onRunCheckoutFlow);
bind("btnListEntitlements", onListEntitlements); bind("btnListEntitlements", onListEntitlements);
const operationModeSelect = $("operationMode");
if (operationModeSelect) {
operationModeSelect.addEventListener("change", () => {
setOperationMode(operationModeSelect.value, "advanced_select");
});
}
refreshModeUI();
logLine("launcher shell ready", { logLine("launcher shell ready", {
api_base: baseURL(), api_base: baseURL(),
chain_id: chainID(), chain_id: chainID(),

View File

@ -3,23 +3,27 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EDUT Launcher</title> <title>EDUT ID Manager</title>
<link rel="stylesheet" href="./style.css" /> <link rel="stylesheet" href="./style.css" />
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
<header class="hero"> <header class="hero">
<h1>EDUT Launcher</h1> <h1>EDUT ID Manager</h1>
<p>Deterministic infrastructure control surface.</p> <p>Deterministic identity, access, and control surface.</p>
</header> </header>
<section class="panel"> <section class="panel">
<h2>Control Surface</h2> <h2>Control Surface</h2>
<div class="actions"> <div class="actions">
<button id="btnQuickConnect">Connect wallet</button> <button id="btnQuickConnect">Connect wallet</button>
<button id="btnQuickActivate">Activate membership</button> <button id="btnQuickActivate">Activate EDUT ID</button>
<button id="btnQuickRefresh">Refresh status + feed</button> <button id="btnQuickRefresh">Refresh status + feed</button>
<button id="btnQuickInstallStatus">Governance status</button> <button id="btnQuickInstallStatus">Governance status</button>
<button id="btnRefreshBalances">Refresh balances</button>
<button id="btnCopyWallet">Copy address</button>
<button id="btnModeHuman" class="mode-active">Human mode</button>
<button id="btnModeAuto">Auto mode</button>
</div> </div>
<p id="flowStatus" class="flow-status">ready</p> <p id="flowStatus" class="flow-status">ready</p>
<div class="grid two"> <div class="grid two">
@ -28,15 +32,27 @@
<p id="summaryWallet">not connected</p> <p id="summaryWallet">not connected</p>
</article> </article>
<article class="stat"> <article class="stat">
<h3>Membership</h3> <h3>Session</h3>
<p id="summarySession">none</p>
</article>
<article class="stat">
<h3>Wallet Funds</h3>
<p id="summaryFunds">-- ETH | -- USDC</p>
</article>
<article class="stat">
<h3><span class="term-inline">EDUT ID<details class="inline-help"><summary aria-label="What is EDUT ID?">?</summary><p>EDUT ID is your one-time identity credential used for checkout, activation, and ownership proof.</p></details></span></h3>
<p id="summaryMembership">unknown</p> <p id="summaryMembership">unknown</p>
</article> </article>
<article class="stat"> <article class="stat">
<h3>Designation</h3> <h3>Mode</h3>
<p id="summaryMode">human_manual</p>
</article>
<article class="stat">
<h3><span class="term-inline">Designation<details class="inline-help"><summary aria-label="What is designation?">?</summary><p>Designation is the protocol reference created during wallet intent and carried through activation evidence.</p></details></span></h3>
<p id="summaryDesignation">-</p> <p id="summaryDesignation">-</p>
</article> </article>
<article class="stat"> <article class="stat">
<h3>Identity Assurance</h3> <h3><span class="term-inline">Identity Assurance<details class="inline-help"><summary aria-label="What is identity assurance?">?</summary><p>Assurance level records how identity was attested, such as direct crypto flow or on-ramp attestation.</p></details></span></h3>
<p id="summaryAssurance">unknown</p> <p id="summaryAssurance">unknown</p>
</article> </article>
<article class="stat"> <article class="stat">
@ -86,18 +102,24 @@
Chain ID Chain ID
<input id="chainId" type="number" value="84532" /> <input id="chainId" type="number" value="84532" />
</label> </label>
<label>
USDC token address
<input id="usdcTokenAddress" value="0x036cbd53842c5426634e7929541ec2318f3dcf7e" />
</label>
</div> </div>
</section> </section>
<section class="subpanel"> <section class="subpanel">
<h2>Membership Flow Controls</h2> <h2>EDUT ID Flow Controls</h2>
<div class="actions"> <div class="actions">
<button id="btnRunMembershipFlow">Run membership flow</button> <button id="btnRunMembershipFlow">Run EDUT ID flow</button>
<button id="btnConnectWallet">Connect wallet</button> <button id="btnConnectWallet">Connect wallet</button>
<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="btnStatus">Membership status</button> <button id="btnRefreshSession">Refresh session</button>
<button id="btnRevokeSession">Revoke session</button>
<button id="btnStatus">EDUT ID status</button>
</div> </div>
<div class="grid three"> <div class="grid three">
<label> <label>
@ -138,8 +160,8 @@
<div class="actions"> <div class="actions">
<button id="btnQuote">Get quote</button> <button id="btnQuote">Get quote</button>
<button id="btnSignPayerProof">Sign payer proof</button> <button id="btnSignPayerProof">Sign payer proof</button>
<button id="btnSendMembershipTx">Send membership tx</button> <button id="btnSendMembershipTx">Send EDUT ID tx</button>
<button id="btnConfirmMembership">Confirm membership tx</button> <button id="btnConfirmMembership">Confirm EDUT ID tx</button>
</div> </div>
<div class="grid three"> <div class="grid three">
<label> <label>
@ -310,6 +332,13 @@
<input id="runtimeVersion" /> <input id="runtimeVersion" />
</label> </label>
</div> </div>
<label>
Operation mode
<select id="operationMode">
<option value="human_manual">human_manual</option>
<option value="worker_auto">worker_auto</option>
</select>
</label>
<label> <label>
Package hash Package hash
<input id="packageHash" /> <input id="packageHash" />

View File

@ -53,6 +53,51 @@ body {
color: var(--accent); color: var(--accent);
} }
.term-inline {
display: inline-flex;
align-items: center;
gap: 6px;
}
.inline-help {
position: relative;
display: inline-block;
}
.inline-help > summary {
list-style: none;
width: 16px;
height: 16px;
border: 1px solid var(--line);
border-radius: 50%;
text-align: center;
line-height: 14px;
font-size: 10px;
color: var(--muted);
cursor: pointer;
user-select: none;
background: #0f1828;
}
.inline-help > summary::-webkit-details-marker {
display: none;
}
.inline-help > p {
position: absolute;
top: 20px;
left: 0;
width: min(300px, 80vw);
border: 1px solid var(--line);
border-radius: 8px;
background: #0f1828;
color: var(--text);
padding: 8px;
font-size: 11px;
line-height: 1.45;
z-index: 30;
}
.note { .note {
margin: 0 0 10px; margin: 0 0 10px;
color: var(--muted); color: var(--muted);
@ -149,10 +194,22 @@ button:hover {
filter: brightness(1.08); filter: brightness(1.08);
} }
button.mode-active {
border-color: #4ed380;
background: #1f4a33;
color: #e0ffe9;
}
button:active { button:active {
transform: translateY(1px); transform: translateY(1px);
} }
button:disabled {
cursor: not-allowed;
filter: grayscale(0.5);
opacity: 0.55;
}
.event-list { .event-list {
display: grid; display: grid;
gap: 8px; gap: 8px;

View File

@ -12,3 +12,4 @@
10. `L-010` Primary wallet screens render USD-first balances and plain-language history. 10. `L-010` Primary wallet screens render USD-first balances and plain-language history.
11. `L-011` Launcher must surface `identity_assurance_level` separately from membership state. 11. `L-011` Launcher must surface `identity_assurance_level` separately from membership state.
12. `L-012` Owner support and governance install actions are blocked when assurance is not `onramp_attested`. 12. `L-012` Owner support and governance install actions are blocked when assurance is not `onramp_attested`.
13. `L-013` Launcher emits signing authority class in governance activation evidence and defaults owner-driven activation to `identity_human`.

View File

@ -18,6 +18,24 @@ Launcher integrates with EDUT web/backend contracts as follows:
12. `GET /governance/install/status` 12. `GET /governance/install/status`
13. `GET /member/channel/events` 13. `GET /member/channel/events`
## Wallet Session Contract
1. `POST /secret/wallet/verify` returns `session_token` and `session_expires_at`.
2. Launcher must attach session token on wallet-scoped calls using:
- `Authorization: Bearer <session_token>` (preferred)
- `X-Edut-Session: <session_token>` (compatibility)
3. Wallet change must clear cached session token before further calls.
4. Endpoints that require membership/admin authority can fail with:
- `wallet_session_required`
- `wallet_session_invalid`
- `wallet_session_expired`
- `wallet_session_mismatch`
## Runtime Mode Signal
1. Launcher install-confirm payload carries `operation_mode` (`human_manual` or `worker_auto`).
2. Mode signal is deterministic evidence input for governance activation policy and receipt hashing.
## Deterministic Requirements ## Deterministic Requirements
1. No runtime activation without entitlement proof. 1. No runtime activation without entitlement proof.
@ -26,3 +44,4 @@ Launcher integrates with EDUT web/backend contracts as follows:
4. Event inbox polling remains canonical even if push unavailable. 4. Event inbox polling remains canonical even if push unavailable.
5. Identity assurance is evaluated independently from membership state. 5. Identity assurance is evaluated independently from membership state.
6. Owner/admin launcher actions must require `identity_assurance_level=onramp_attested`. 6. Owner/admin launcher actions must require `identity_assurance_level=onramp_attested`.
7. Governance activation evidence must include signing authority class (`identity_human` vs delegated).

View File

@ -7,6 +7,7 @@
3. Governance install path fails closed on invalid evidence. 3. Governance install path fails closed on invalid evidence.
4. Marketplace and status APIs are called with app-session auth. 4. Marketplace and status APIs are called with app-session auth.
5. Wallet v1 acceptance criteria pass (`docs/wallet-v1-product-spec.md`). 5. Wallet v1 acceptance criteria pass (`docs/wallet-v1-product-spec.md`).
6. Owner/admin actions block unless `identity_assurance_level=onramp_attested`.
## Blockers ## Blockers
@ -14,3 +15,4 @@
2. Any path that leaks private key material. 2. Any path that leaks private key material.
3. Any path that bypasses entitlement checks for governance activation. 3. Any path that bypasses entitlement checks for governance activation.
4. Any launch flow that exposes seed phrase by default. 4. Any launch flow that exposes seed phrase by default.
5. Any owner support/install action that proceeds without required identity assurance.

View File

@ -127,6 +127,8 @@ Technical details are available only in expanded view:
3. Recovery path must exist but remain opt-in in onboarding. 3. Recovery path must exist but remain opt-in in onboarding.
4. Sensitive operations fail closed on secure storage errors. 4. Sensitive operations fail closed on secure storage errors.
5. Wallet export (seed/private key) requires explicit authenticated flow. 5. Wallet export (seed/private key) requires explicit authenticated flow.
6. AI/delegated automation must never use the human identity signer key directly.
7. Any delegated signing authority must be explicit, scoped, and revocable.
## Asset/Display Model ## Asset/Display Model