Wire launcher assurance-aware membership and admin gates

This commit is contained in:
Joshua 2026-02-18 14:18:19 -08:00
parent 787c056f6b
commit 2e5ba323ba
5 changed files with 124 additions and 6 deletions

View File

@ -35,6 +35,7 @@ Top-level control surface:
4. `Governance status` 4. `Governance status`
5. Wallet/membership/designation/last-sync overview cards 5. Wallet/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`)
Advanced integration controls (collapsible): Advanced integration controls (collapsible):
@ -52,6 +53,13 @@ Wallet automation helpers remain available in advanced controls:
2. `Sign intent (EIP-712)` signs the current intent payload and fills `walletSignature`. 2. `Sign intent (EIP-712)` signs the current intent payload and fills `walletSignature`.
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.
Policy behavior in launcher shell:
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.
3. Assurance state is displayed independently from membership state in the top summary cards.
Run locally: Run locally:

View File

@ -7,6 +7,7 @@ const state = {
lastIntent: null, lastIntent: null,
lastQuote: null, lastQuote: null,
lastCheckoutQuote: null, lastCheckoutQuote: null,
lastStatus: null,
channelReady: false, channelReady: false,
}; };
@ -30,6 +31,28 @@ function normalizedAddress(value) {
return String(value || "").trim().toLowerCase(); return String(value || "").trim().toLowerCase();
} }
function normalizeAssurance(value) {
return String(value || "none").trim().toLowerCase() || "none";
}
function assuranceDisplay(value) {
const assurance = normalizeAssurance(value);
if (assurance === "onramp_attested") {
return "onramp_attested";
}
if (assurance === "crypto_direct_unattested") {
return "crypto_direct_unattested";
}
if (assurance === "sponsored_unattested") {
return "sponsored_unattested";
}
return "none";
}
function isOnrampAttested(value) {
return normalizeAssurance(value) === "onramp_attested";
}
function injectedProvider() { function injectedProvider() {
return globalThis.ethereum || null; return globalThis.ethereum || null;
} }
@ -66,11 +89,17 @@ function refreshOverview(statusPayload) {
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 || "-");
const assurance = assuranceDisplay(statusPayload.identity_assurance_level);
setSummary("summaryAssurance", assurance);
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
setSummary("summaryLastSync", nowISO()); setSummary("summaryLastSync", nowISO());
return; return;
} }
const designation = $("designationCode")?.value?.trim() || "-"; const designation = $("designationCode")?.value?.trim() || "-";
setSummary("summaryDesignation", designation); setSummary("summaryDesignation", designation);
const assurance = assuranceDisplay(state.lastStatus?.identity_assurance_level);
setSummary("summaryAssurance", assurance);
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
} }
function setFlowStatus(message) { function setFlowStatus(message) {
@ -361,6 +390,7 @@ async function onStatus() {
"GET", "GET",
`/secret/membership/status?wallet=${encodeURIComponent(requireWallet())}`, `/secret/membership/status?wallet=${encodeURIComponent(requireWallet())}`,
); );
state.lastStatus = out;
if (out.designation_code) { if (out.designation_code) {
$("designationCode").value = out.designation_code; $("designationCode").value = out.designation_code;
} }
@ -397,17 +427,50 @@ async function onQuote() {
} }
async function onConfirmMembership() { async function onConfirmMembership() {
const out = await request("POST", "/secret/membership/confirm", { const payload = {
designation_code: $("designationCode").value.trim(), designation_code: $("designationCode").value.trim(),
quote_id: $("quoteId").value.trim(), quote_id: $("quoteId").value.trim(),
tx_hash: $("confirmTxHash").value.trim(), tx_hash: $("confirmTxHash").value.trim(),
address: requireWallet(), address: requireWallet(),
chain_id: chainID(), chain_id: chainID(),
}); };
const assurance = $("membershipIdentityAssurance").value.trim();
const attestedBy = $("membershipIdentityAttestedBy").value.trim();
const attestationID = $("membershipIdentityAttestationId").value.trim();
if (assurance) {
payload.identity_assurance_level = assurance;
}
if (attestedBy) {
payload.identity_attested_by = attestedBy;
}
if (attestationID) {
payload.identity_attestation_id = attestationID;
}
const out = await request("POST", "/secret/membership/confirm", payload);
state.lastStatus = {
...(state.lastStatus || {}),
status: "active",
wallet: requireWallet(),
designation_code: out.designation_code || $("designationCode").value.trim(),
identity_assurance_level: out.identity_assurance_level || state.lastStatus?.identity_assurance_level || "none",
identity_attested_by: out.identity_attested_by || "",
identity_attestation_id: out.identity_attestation_id || "",
};
logLine("membership confirm", out); logLine("membership confirm", out);
return out; return out;
} }
async function requireMembershipState(actionLabel, opts = {}) {
const status = await onStatus();
if (String(status.status || "").toLowerCase() !== "active") {
throw new Error(`${actionLabel} requires active membership`);
}
if (opts.requireOnramp && !isOnrampAttested(status.identity_assurance_level)) {
throw new Error(`${actionLabel} requires onramp_attested identity assurance`);
}
return status;
}
async function waitForTxMined(txHash, timeoutMs = 120000, intervalMs = 3000) { async function waitForTxMined(txHash, timeoutMs = 120000, intervalMs = 3000) {
const provider = await requireProvider(); const provider = await requireProvider();
const started = Date.now(); const started = Date.now();
@ -478,8 +541,12 @@ async function onRunMembershipFlow() {
setFlowStatus("confirming membership with API"); setFlowStatus("confirming membership with API");
await confirmMembershipWithRetry(); await confirmMembershipWithRetry();
setFlowStatus("refreshing status"); setFlowStatus("refreshing status");
await onStatus(); const refreshed = await onStatus();
setFlowStatus("membership flow complete"); if (isOnrampAttested(refreshed.identity_assurance_level)) {
setFlowStatus("membership flow complete (attested)");
} else {
setFlowStatus("membership active (unattested)");
}
} }
async function onRegisterChannel() { async function onRegisterChannel() {
@ -530,6 +597,7 @@ async function onPollEvents() {
} }
async function onSupportTicket() { async function onSupportTicket() {
await requireMembershipState("owner support", { requireOnramp: true });
const out = await request("POST", "/member/channel/support/ticket", { const out = await request("POST", "/member/channel/support/ticket", {
wallet: requireWallet(), wallet: requireWallet(),
org_root_id: orgRootID(), org_root_id: orgRootID(),
@ -545,6 +613,7 @@ async function onSupportTicket() {
} }
async function onInstallToken() { async function onInstallToken() {
await requireMembershipState("governance install token", { requireOnramp: true });
const out = await request("POST", "/governance/install/token", { const out = await request("POST", "/governance/install/token", {
wallet: requireWallet(), wallet: requireWallet(),
org_root_id: orgRootID(), org_root_id: orgRootID(),
@ -747,7 +816,11 @@ async function onQuickRefresh() {
try { try {
await ensureChannelBinding(); await ensureChannelBinding();
await onPollEvents(); await onPollEvents();
setFlowStatus("status synced"); if (isOnrampAttested(status.identity_assurance_level)) {
setFlowStatus("status synced (admin-ready)");
} else {
setFlowStatus("status synced (member mode)");
}
} catch (err) { } catch (err) {
setFlowStatus("feed sync warning"); setFlowStatus("feed sync warning");
throw err; throw err;
@ -755,7 +828,13 @@ async function onQuickRefresh() {
} }
async function onQuickInstallStatus() { async function onQuickInstallStatus() {
await onInstallStatus(); const out = await onInstallStatus();
const assurance = assuranceDisplay(out.identity_assurance_level);
if (isOnrampAttested(assurance)) {
setFlowStatus("governance status synced (attested)");
} else {
setFlowStatus("governance status synced (attestation required)");
}
} }
async function onLeaseHeartbeat() { async function onLeaseHeartbeat() {

View File

@ -35,6 +35,14 @@
<h3>Designation</h3> <h3>Designation</h3>
<p id="summaryDesignation">-</p> <p id="summaryDesignation">-</p>
</article> </article>
<article class="stat">
<h3>Identity Assurance</h3>
<p id="summaryAssurance">unknown</p>
</article>
<article class="stat">
<h3>Admin Policy</h3>
<p id="summaryAdminPolicy">blocked</p>
</article>
<article class="stat"> <article class="stat">
<h3>Last Sync</h3> <h3>Last Sync</h3>
<p id="summaryLastSync">never</p> <p id="summaryLastSync">never</p>
@ -165,6 +173,25 @@
Confirm tx hash Confirm tx hash
<input id="confirmTxHash" placeholder="0x..." /> <input id="confirmTxHash" placeholder="0x..." />
</label> </label>
<div class="grid three">
<label>
Identity assurance (optional)
<select id="membershipIdentityAssurance">
<option value="">(auto)</option>
<option value="onramp_attested">onramp_attested</option>
<option value="crypto_direct_unattested">crypto_direct_unattested</option>
<option value="sponsored_unattested">sponsored_unattested</option>
</select>
</label>
<label>
Attested by (optional)
<input id="membershipIdentityAttestedBy" placeholder="moonpay" />
</label>
<label>
Attestation id (optional)
<input id="membershipIdentityAttestationId" placeholder="provider-session-id" />
</label>
</div>
</section> </section>
<section class="subpanel"> <section class="subpanel">

View File

@ -10,3 +10,5 @@
8. `L-008` Wallet onboarding creates local wallet without forcing seed phrase display. 8. `L-008` Wallet onboarding creates local wallet without forcing seed phrase display.
9. `L-009` Outgoing sends require biometric/PIN confirmation. 9. `L-009` Outgoing sends require biometric/PIN confirmation.
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.
12. `L-012` Owner support and governance install actions are blocked when assurance is not `onramp_attested`.

View File

@ -24,3 +24,5 @@ Launcher integrates with EDUT web/backend contracts as follows:
2. All install packages verified by hash and signature. 2. All install packages verified by hash and signature.
3. Membership and entitlement unknown state fails closed. 3. Membership and entitlement unknown state fails closed.
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.
6. Owner/admin launcher actions must require `identity_assurance_level=onramp_attested`.