From 7ee25e48880a3973a91e6841645ac70adba5df98 Mon Sep 17 00:00:00 2001 From: solarpunked <45199352+solarpunked@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:06:10 +1100 Subject: [PATCH] Identity link on v1.5: a carried link counts for the key it names; the Vault is asked from a banner After the move to v1.5 the app checked for a link of its key there that it had made itself, so an install whose link was carried from v1.3 or v1.4 asked the Vault for a new one in the background, where the Vault's dialog appeared with no word from ProofPoll. - The check counts a link made on v1.5 or carried there for this key (v1.5 files a carried link only under the two keys its original names), and it must link the key to the identity this install is signed in with. - It runs once this start's agent links are carried (links_carried on the app status), and again on the link poll until the network answers. - When no link is found, a banner asks the person to confirm this device in the Vault; nothing asks the Vault in the background. - Re-linking on a newer network version no longer warns that the previous link could not be revoked: it stays on the older version. - Matrix: an install signed in on v1.3 has no v1.5 link before its move and one after it (carrier leg); an install signed in on v1.5 has its own. --- scripts/pp-matrix.mjs | 15 ++++ src-tauri/src/carry_move.rs | 18 +++++ src-tauri/src/commands.rs | 104 ++++++++++++++++++++++++-- src-tauri/src/harness.rs | 67 ++++++++++++----- src-tauri/src/lib.rs | 2 +- src/lib/holochain.ts | 12 ++- src/routes/layout.tsx | 142 ++++++++++++++++++++++++++---------- 7 files changed, 291 insertions(+), 69 deletions(-) diff --git a/scripts/pp-matrix.mjs b/scripts/pp-matrix.mjs index f6dcdfd..bc35709 100644 --- a/scripts/pp-matrix.mjs +++ b/scripts/pp-matrix.mjs @@ -197,6 +197,8 @@ async function migration() { const rep2 = again.data?.report || {}; record('A: a second run writes nothing new', again.ok && rep2.failed === 0 && (await op(A, { op: 'polls' })).data?.length === after.length && ((await op(A, { op: 'drafts' })).data || []).length === drafts15.length, again.error || JSON.stringify(rep2)); + const aLinked = await op(A, { op: 'linked_active' }); + record('A: v1.5 links its key to the identity (its own link from the sign-in)', aLinked.ok && aLinked.data === true, aLinked.error || JSON.stringify(aLinked.data)); } /** A restarted with every rendezvous check failing for ~90 s (debug switch): @@ -324,6 +326,15 @@ async function carrier() { for (const [b, w] of [[A, 'A'], [B, 'B'], [C, 'C'], [D, 'D']]) record(`${w}: ready`, await waitReady(b)); const keyOf = async (b) => (await status(b)).app?.agent_pub_key; const [aKey, bKey, cKey] = [await keyOf(A), await keyOf(B), await keyOf(C)]; + // B signs in on v1.3 as ProofPoll 0.4.x did: its only identity link is + // there. Once its move carries that link, v1.5 links its key to the + // identity, and the app has nothing to ask the Vault for. + const b13 = await op(B, { op: 'v13_link', client_id: CLIENT_ID }); + record('B: signed in on v1.3 as 0.4.x did (its only link there)', b13.ok, b13.error); + const bBefore = await op(B, { op: 'linked_active' }); + const bCarriedBefore = (await status(B)).app?.links_carried; + record('B: before its move, v1.5 links its key to no identity', bBefore.ok && bBefore.data === false && bCarriedBefore === false, + bBefore.error || JSON.stringify({ linked: bBefore.data, links_carried: bCarriedBefore })); const anon = await op(A, { op: 'v13_poll', title: `carrier anonymous ${nowS()}`, description: 'by an install that never updates', options: ['red', 'blue'], closes_at: nowS() + 7 * 86400 }); const pub = await op(A, { op: 'v13_poll', title: `carrier public ${nowS()}`, description: 'names shown', options: ['yes', 'no'], closes_at: nowS() + 7 * 86400, poll_type: 'Public' }); record('A: two v1.3 polls, one anonymous and one public', anon.ok && pub.ok, anon.error || pub.error); @@ -419,6 +430,10 @@ async function carrier() { record('B: sees the carried votes on both copies', await until(async () => ((await v15(B, 'get_poll_votes', anonCopy)) || []).length >= 3 && ((await v15(B, 'get_poll_votes', pubCopy)) || []).length >= 1, 300, 10000)); const mv = await op(B, { op: 'move15' }, 600); record("B: its own move runs, nothing refused", mv.ok && mv.data?.report?.failed === 0, mv.error || JSON.stringify(mv.data?.report)); + record('B: its move carried its links', (await status(B)).app?.links_carried === true); + let bAfter = { ok: false }; + record('B: its carried v1.3 link links its key to the identity on v1.5 (no new link to ask the Vault for)', + await until(async () => (bAfter = await op(B, { op: 'linked_active' })).ok && bAfter.data === true, 120, 5000), bAfter.error || JSON.stringify(bAfter.data)); const afterMove = (await v15(C, 'get_poll_votes', anonCopy)) || []; record('the anonymous poll still has three votes', afterMove.length === 3, JSON.stringify(by(afterMove))); await signin(B, 'B'); diff --git a/src-tauri/src/carry_move.rs b/src-tauri/src/carry_move.rs index 08c9e20..40fdaa0 100644 --- a/src-tauri/src/carry_move.rs +++ b/src-tauri/src/carry_move.rs @@ -32,6 +32,20 @@ use std::sync::Arc; const POLLS: &str = "polls"; const STATE_FILE: &str = "carry-move-v1_5.json"; +/// This start's agent links are on v1.5: a run carried every one of them, +/// or the move was already complete. Until then the identity link this +/// install made on an older version may not be on v1.5 yet, so nothing +/// decides from v1.5's links that a new one is needed. +static LINKS_CARRIED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +pub fn links_carried() -> bool { + LINKS_CARRIED.load(std::sync::atomic::Ordering::Relaxed) +} + +fn set_links_carried() { + LINKS_CARRIED.store(true, std::sync::atomic::Ordering::Relaxed); +} + /// What the move keeps between runs. #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)] pub struct MoveState { @@ -326,6 +340,7 @@ pub async fn run(state: &Arc) -> Result { let mut ms = MoveState::load(&state.data_dir); let mut report = Report::default(); if ms.complete { + set_links_carried(); return Ok(report); } let v13 = own_v13(state).await?; @@ -350,6 +365,9 @@ pub async fn run(state: &Arc) -> Result { }; count(r, &mut report.links, &mut report.waiting, &mut report.failed, "an agent link"); } + if report.waiting == 0 && report.failed == 0 { + set_links_carried(); + } for c in &plan.first { let r = match carried::check(c) { Ok(_) => call_hash(dest, "carry_poll", &CarryInput { carried: c.clone() }).await.map(|_| true), diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 62dc703..e5a6682 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -536,6 +536,8 @@ pub struct AppStatus { pub conductor_status: ConductorStatus, /// This launch was asked for by "Open as this identity". pub relaunched_into_identity: bool, + /// This start's agent links are on v1.5 (`carry_move::links_carried`). + pub links_carried: bool, } #[tauri::command] @@ -555,6 +557,7 @@ pub fn get_app_status(state: tauri::State<'_, std::sync::Arc>) -> AppS relaunched_into_identity: state .relaunched_into_identity .load(std::sync::atomic::Ordering::Relaxed), + links_carried: crate::carry_move::links_carried(), } } @@ -1494,7 +1497,14 @@ pub async fn commit_identity_link( if let Err(e) = call_zome(client, AGENT_LINKING_ZOME, "revoke_link", payload).await { - log::warn!("could not revoke the superseded identity link: {}", e); + // A re-link on a newer network version: the + // previous link is on the older one and stays + // there with it. + if e.contains("Entry not found") { + log::info!("the superseded identity link is on an older network version; left there"); + } else { + log::warn!("could not revoke the superseded identity link: {}", e); + } } } } @@ -1889,17 +1899,40 @@ async fn fold_siblings_on( } } -/// The agents linked to `agent_pub_key` on the active network (v1.5) only, -/// natively (a carried link does not count), for the launch check that -/// links this install's key there. `get_linked_agents` also counts the older -/// networks, so that check never ran. +/// Whether the active network (v1.5) links `agent_pub_key` to the identity +/// this install is signed in with: by a link made there, or by one carried +/// there. A carried link counts only for a key its original names (v1.5 +/// files it under those two keys alone and validates that), so a link made +/// for the install's other key never stands in for this one. Without a +/// saved link to name the identity, any link of this key there counts. Err +/// while that network does not answer: the caller asks again later rather +/// than deciding from nothing. Older networks are not read: a link there +/// does not let v1.5 peers see who wrote what. #[tauri::command] -pub async fn get_linked_agents_active( +pub async fn identity_linked_on_active( state: tauri::State<'_, std::sync::Arc>, agent_pub_key: String, -) -> Result, String> { +) -> Result { let agent = AgentPubKey::try_from(agent_pub_key).map_err(|e| format!("Invalid agent key: {:?}", e))?; - Ok(linked_agent_keys(state.inner(), &agent).await?.iter().map(|a| a.to_string()).collect()) + identity_linked_on_active_inner(state.inner(), &agent).await +} + +pub(crate) async fn identity_linked_on_active_inner( + state: &std::sync::Arc, + agent: &AgentPubKey, +) -> Result { + let linked = linked_on(state, "active", agent).await?.ok_or("The active network is not running yet")?; + let identity = load_identity_link(&state.data_dir).and_then(|l| parse_agent_pub_key_string(&l.vault_agent_pub_key).ok()); + Ok(links_this_identity(&linked, identity.as_ref())) +} + +/// By the key itself: the saved identity key comes from the Vault as text, +/// and its location bytes need not match the network's copy. +fn links_this_identity(linked: &[AgentPubKey], identity: Option<&AgentPubKey>) -> bool { + match identity { + Some(identity) => linked.iter().any(|k| k.get_raw_32() == identity.get_raw_32()), + None => !linked.is_empty(), + } } #[tauri::command] @@ -3172,6 +3205,34 @@ pub(crate) async fn harness_v13_vote(state: &AppState, poll: String, option_inde Ok(action_hash.to_string()) } +/// See `harness_v13_poll`: a sign-in as a 0.4.x install made it, the +/// identity link written on v1.3 and saved here, so the move's tests start +/// from an install whose only link is on an older version. +#[cfg(feature = "harness")] +pub(crate) async fn harness_v13_link(state: &AppState, vault_agent_pub_key: String, vault_signature: String) -> Result { + #[derive(serde::Serialize, Debug)] + struct CreateExternalLinkInput { + external_agent: AgentPubKey, + external_signature: Vec, + } + use base64::Engine; + let external_signature = base64::engine::general_purpose::STANDARD + .decode(&vault_signature) + .map_err(|e| format!("Invalid signature: {}", e))?; + let input = CreateExternalLinkInput { external_agent: parse_agent_pub_key_string(&vault_agent_pub_key)?, external_signature }; + let client = state.app_client_v1_3.lock().await; + let client = client.as_ref().ok_or("v1.3 is not running")?; + let result = call_zome(client, AGENT_LINKING_ZOME, "create_external_link", crate::hc::encode(&input)?).await?; + let action_hash: ActionHash = crate::hc::decode(&result)?; + let linked_at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64; + save_identity_link( + &state.data_dir, + &IdentityLinkData { vault_agent_pub_key: vault_agent_pub_key.clone(), entry_action_hash: action_hash.to_string(), linked_at }, + ); + crate::profiles::bind_profile(&state.device_root, &state.data_dir, &vault_agent_pub_key); + Ok(action_hash.to_string()) +} + /// See `harness_v13_poll`: a flag on a v1.3 poll, raised there with no /// identity gate. #[cfg(feature = "harness")] @@ -3266,3 +3327,30 @@ mod backup_tests { assert!(Copied06::from(&state).holds("EncryptedEntry", DRAFT_06, None)); } } + +#[cfg(test)] +mod active_link_tests { + use super::links_this_identity; + use holochain_types::prelude::AgentPubKey; + + #[test] + fn the_active_link_counts_for_the_signed_in_identity_only() { + let identity = AgentPubKey::from_raw_32(vec![7; 32]); + let other_identity = AgentPubKey::from_raw_32(vec![8; 32]); + assert!(links_this_identity(&[identity.clone()], Some(&identity))); + assert!(links_this_identity(&[other_identity.clone(), identity.clone()], Some(&identity))); + assert!(!links_this_identity(&[other_identity.clone()], Some(&identity)), "a link to an identity this install left does not count"); + assert!(!links_this_identity(&[], Some(&identity))); + + // The saved key arrives as text from the Vault: compared by the key + // itself, whatever its location bytes say. + let mut raw = identity.get_raw_39().to_vec(); + raw[35..39].copy_from_slice(&[0, 0, 0, 0]); + let as_saved = AgentPubKey::from_raw_39(raw); + assert!(links_this_identity(&[identity.clone()], Some(&as_saved))); + + // No saved link to name the identity: any link of the key counts. + assert!(links_this_identity(&[other_identity], None)); + assert!(!links_this_identity(&[], None)); + } +} diff --git a/src-tauri/src/harness.rs b/src-tauri/src/harness.rs index 534c659..3450632 100644 --- a/src-tauri/src/harness.rs +++ b/src-tauri/src/harness.rs @@ -405,6 +405,18 @@ async fn op(Ax(app): Ax, Json(body): Json) -> Reply { "identity" => ok(serde_json::to_value(commands::get_identity_link(state)).unwrap_or(Value::Null)), "migration" => to_value(commands::get_migration_status(state).await), "link" => link(&app, state, s(&body, "client_id")).await, + // A sign-in as 0.4.x made it: the link on v1.3 only. + "v13_link" => v13_link(state, s(&body, "client_id")).await, + // The layout's check: v1.5 links the key the active network runs + // as to the signed-in identity (own or carried link). + "linked_active" => { + let st = commands::get_app_status(state.clone()); + match st.agent_pub_key_07.or(st.agent_pub_key).map(holochain_types::prelude::AgentPubKey::try_from) { + Some(Ok(key)) => to_value(commands::identity_linked_on_active_inner(state.inner(), &key).await), + Some(Err(e)) => failed(format!("{:?}", e)), + None => failed("no local agent yet"), + } + } // As the UI asks it: from this install's own agent key. "agent_set" => { let local = commands::get_app_status(state.clone()).agent_pub_key; @@ -517,31 +529,52 @@ async fn backup(state: tauri::State<'_, Arc>, client_id: String) -> Re } } -/// The sign-in the identity page does through the SDK: the Vault's -/// `/link-identity` (the Vault the instance is pinned to), then the commit. -async fn link(app: &tauri::AppHandle, state: tauri::State<'_, Arc>, client_id: String) -> Reply { - let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else { - return failed("no local agent yet"); - }; +/// The Vault's signature for this install's key, as `/link-identity` gives +/// it to the SDK: (identity key, signature). +async fn vault_signs_link(agent: &str, client_id: &str) -> Result<(String, String), String> { let base = match crate::vault_probe::test_vault_port() { Some(p) => format!("http://127.0.0.1:{}", p), - None => return failed("no PROOFPOLL_TEST_VAULT_PORT"), + None => return Err("no PROOFPOLL_TEST_VAULT_PORT".into()), }; - let _ = app; - let resp = reqwest::Client::new() + let r = reqwest::Client::new() .post(format!("{}/link-identity", base)) .header("Origin", crate::vault_probe::webview_origin()) .json(&json!({ "app_name": "ProofPoll", "client_id": client_id, "app_agent_pub_key": agent })) .timeout(std::time::Duration::from_secs(120)) .send() - .await; - let v: Value = match resp { - Ok(r) if r.status().is_success() => r.json().await.unwrap_or(Value::Null), - Ok(r) => return failed(format!("link-identity {}: {}", r.status(), r.text().await.unwrap_or_default())), - Err(e) => return failed(e), + .await + .map_err(|e| e.to_string())?; + if !r.status().is_success() { + return Err(format!("link-identity {}: {}", r.status(), r.text().await.unwrap_or_default())); + } + let v: Value = r.json().await.unwrap_or(Value::Null); + match (v.get("vault_agent_pub_key").and_then(|x| x.as_str()), v.get("vault_signature").and_then(|x| x.as_str())) { + (Some(key), Some(sig)) => Ok((key.to_string(), sig.to_string())), + _ => Err(format!("link-identity answered without a key and signature: {}", v)), + } +} + +/// A sign-in as ProofPoll 0.4.x made it: the Vault signs this install's +/// key and the link is written on v1.3 (and saved here), nowhere else. +async fn v13_link(state: tauri::State<'_, Arc>, client_id: String) -> Reply { + let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else { + return failed("no local agent yet"); }; - let (Some(key), Some(sig)) = (v.get("vault_agent_pub_key").and_then(|x| x.as_str()), v.get("vault_signature").and_then(|x| x.as_str())) else { - return failed(format!("link-identity answered without a key and signature: {}", v)); + match vault_signs_link(&agent, &client_id).await { + Ok((key, sig)) => to_value(commands::harness_v13_link(&state, key.clone(), sig).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h }))), + Err(e) => failed(e), + } +} + +/// The sign-in the identity page does through the SDK: the Vault's +/// `/link-identity` (the Vault the instance is pinned to), then the commit. +async fn link(app: &tauri::AppHandle, state: tauri::State<'_, Arc>, client_id: String) -> Reply { + let Some(agent) = commands::get_app_status(state.clone()).agent_pub_key else { + return failed("no local agent yet"); }; - to_value(commands::commit_identity_link(state, key.to_string(), sig.to_string()).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h }))) + let _ = app; + match vault_signs_link(&agent, &client_id).await { + Ok((key, sig)) => to_value(commands::commit_identity_link(state, key.clone(), sig).await.map(|h| json!({ "vault_agent_pub_key": key, "link": h }))), + Err(e) => failed(e), + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e92018..ceac810 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -353,7 +353,7 @@ pub fn run() { commands::save_profile_cache, commands::commit_identity_link, commands::get_linked_agents, - commands::get_linked_agents_active, + commands::identity_linked_on_active, commands::get_my_agent_set, commands::get_identity_link, commands::revoke_identity_link, diff --git a/src/lib/holochain.ts b/src/lib/holochain.ts index 0bebf62..67e1e15 100644 --- a/src/lib/holochain.ts +++ b/src/lib/holochain.ts @@ -188,11 +188,15 @@ export async function getLinkedAgents( }); } -/** The agents linked on the active network only (v1.4), not v1.3 too. */ -export async function getLinkedAgentsActive( +/** + * Whether the active network (v1.5) links this key to the identity this + * install is signed in with, by a link made there or one carried there for + * this key. Rejects while that network does not answer. + */ +export async function identityLinkedOnActive( agentPubKey: string, -): Promise { - return invoke("get_linked_agents_active", { +): Promise { + return invoke("identity_linked_on_active", { agentPubKey, }); } diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx index da6f852..a90536f 100644 --- a/src/routes/layout.tsx +++ b/src/routes/layout.tsx @@ -7,7 +7,7 @@ import { sanitizeImageSrc } from "~/lib/sanitize"; import { setSignInIntent, vaultLinkOptions } from "~/lib/signin"; import { getLinkedAgents, - getLinkedAgentsActive, + identityLinkedOnActive, getIdentityLink, commitIdentityLink, revokeIdentityLink, @@ -44,6 +44,8 @@ interface AppStatus { | { status: "ready"; admin_port: number; app_port: number } | { status: "error"; message: string }; relaunched_into_identity?: boolean; + /** This start's agent links are on v1.5 (the move carried them). */ + links_carried?: boolean; } export default component$(() => { @@ -80,6 +82,14 @@ export default component$(() => { // Step 2 of an adopt/re-key: the key was restored, the sign-in hasn't // happened yet (the adopt-relink-pending marker is on disk). const relinkPending = useSignal(false); + // Signed in, but the active network has no link of this install's key to + // the identity, made there or carried there: other people cannot see + // that what it writes there is this person's. Asked for from the banner, + // never in the background (a Vault dialog nobody started reads as the + // Vault forgetting the app). + const linkNeeded = useSignal(false); + const linkConfirming = useSignal(false); + const linkConfirmFailed = useSignal(false); useVisibleTask$(({ cleanup }) => { let active = true; @@ -246,6 +256,28 @@ export default component$(() => { return "mismatch"; }; + // Once a session, after this start's agent links are on v1.5 (a check + // before the move carried them would find none): does the active + // network link this install's key to the identity? Asked again on the + // link poll's ticks until the network answers. + let activeLinkChecked = false; + let activeLinkChecking = false; + const checkActiveLink = async (linkKey: string) => { + if (activeLinkChecked || activeLinkChecking || linkState.value !== "linked") return; + activeLinkChecking = true; + try { + const s = await invoke("get_app_status"); + if (!s.links_carried) return; + const ok = await identityLinkedOnActive(linkKey); + activeLinkChecked = true; + linkNeeded.value = !ok; + } catch { + // The network did not answer: the next tick asks again. + } finally { + activeLinkChecking = false; + } + }; + const poll = async () => { while (active) { try { @@ -289,44 +321,10 @@ export default component$(() => { } } - // Migration race: Vault confirms the link but the new DNA's - // DHT doesn't have an entry yet. Recreate it in the - // background so peers can verify. Safe against account - // switches: commit_identity_link hard-refuses binding any - // identity other than the one already linked AND live. - // The check reads the active network only, and its own links - // only: counting older networks' or carried links, it never - // linked this key there. - if (state === "linked") { - const dhtLinked = await getLinkedAgentsActive(linkKey) - .then((a) => a.length > 0) - .catch(() => true); - if (!dhtLinked) { - const agentPubKey = linkKey; - import("@flowsta/holochain") - .then(async ({ linkFlowstaIdentity }) => { - try { - const result = await linkFlowstaIdentity({ - appName: "ProofPoll", - clientId: import.meta.env.VITE_FLOWSTA_CLIENT_ID, - localAgentPubKey: agentPubKey, - ...(await vaultLinkOptions()), - }); - if (result.success) { - await commitIdentityLink( - result.payload.vaultAgentPubKey, - result.payload.vaultSignature, - ); - console.log("[ProofPoll] DHT identity link re-created after migration"); - } - } catch { - // Vault dialog dismissed or identity guard refused — - // the local link still works for display - } - }) - .catch(() => {}); - } - } + // Signed in, but the active network may hold no link of this + // key (a key that never linked on an older version either): + // the banner offers it once the move's links are carried. + checkActiveLink(linkKey); } // Load profile: cache first, then Vault refresh. @@ -425,6 +423,14 @@ export default component$(() => { // side clears the marker in commit_identity_link). if (nowLinked) relinkPending.value = false; + // The active network's link: asked once linked (again after a + // switch or a sign-out, which can change the answer). + if (nowLinked) checkActiveLink(linkKey); + else { + activeLinkChecked = false; + linkNeeded.value = false; + } + // Fetch profile when linked but profile is missing. Cache display // is fine in `offline` too; refreshing the cache is linked-only // (Rust refuses it otherwise). @@ -507,6 +513,35 @@ export default component$(() => { setSignInIntent({ autoLink: true }); nav("/identity/"); }); + // The link banner's action: the Vault signs the link of the key the + // active network runs as, and it is written there. commit_identity_link + // refuses any identity but the one this install is linked to. + const handleConfirmLink = $(async () => { + const s = status.value; + const linkKey = s?.agent_pub_key_07 ?? s?.agent_pub_key; + if (!linkKey || linkConfirming.value) return; + linkConfirming.value = true; + linkConfirmFailed.value = false; + try { + const { linkFlowstaIdentity } = await import("@flowsta/holochain"); + const result = await linkFlowstaIdentity({ + appName: "ProofPoll", + clientId: import.meta.env.VITE_FLOWSTA_CLIENT_ID, + localAgentPubKey: linkKey, + ...(await vaultLinkOptions()), + }); + if (result.success) { + await commitIdentityLink(result.payload.vaultAgentPubKey, result.payload.vaultSignature); + linkNeeded.value = false; + } else { + linkConfirmFailed.value = true; + } + } catch { + linkConfirmFailed.value = true; + } finally { + linkConfirming.value = false; + } + }); // A switch mid-session: relaunch, and ProofPoll opens on the profile of // the identity the Vault holds now (picked at launch) instead of staying // read-only under the old name. @@ -842,6 +877,35 @@ export default component$(() => { )} + {linkNeeded.value && linkState.value === "linked" && ( +
+

+ Confirm this device in your Vault +

+

+ Once, so other people can see that the polls and votes you + make on this device are yours. +

+ {linkConfirmFailed.value && ( +

+ Your Vault did not confirm it. Try again with your Vault + open and unlocked. +

+ )} +
+ +
+
+ )} + )}