diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05819cd3..476670d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,29 @@ jobs: cargo check -p openvtc-core --locked --no-default-features --features arbitrary cargo test -p openvtc-core --locked --features arbitrary --lib + # The `#[ignore]`d tests (#537): the e2e suites that boot an in-process + # mediator or VTA (~1-30 s each). Policy: a test is `#[ignore]`d only for + # being slow or needing an in-process service — never for being flaky or + # broken — and every one of them gates PRs here. The coverage job also runs + # them, but it has no threshold, so a failure there did not fail a PR. + test-ignored: + name: Test (ignored e2e suites) + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: ./.github/actions/add-swap + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev libdbus-1-dev + - run: cargo test --workspace --locked -- --ignored + test-dev-overrides: name: Test (dev-overrides) runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8382c4..a5085c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Joining a community no longer freezes the rest of the app** + ([#529](https://github.com/OpenVTC/openvtc/issues/529)). The join runs as a job on the + main loop instead of a nested event loop, so saves, inbound messages, VTA supervision + and timers carry on while it runs. Esc, F10 or an error cancels a resolve, discovery or + launch at once, and the steps already taken are undone in reverse — except once the + join request is being sent: then the exit waits for it, so the request is recorded and + the persona the community now holds is kept. Messages from that community wait until + the request is recorded, so a fast answer is not dropped. A join job that + panics reports "Joining a community failed (internal error)." +- **The runtime loop is a `Runtime` struct with one method per event** + ([#528](https://github.com/OpenVTC/openvtc/issues/528)), and its arms are tested on + their own. Rooms and elections share one community request/reply engine, and the + replies awaited by thread are held in a `PendingReplies` table the runtime owns + instead of three process-wide statics + ([#534](https://github.com/OpenVTC/openvtc/issues/534)). Every TUI save goes through the + save scheduler, and background work is spawned through one `Job` / `dispatch` shape + ([#535](https://github.com/OpenVTC/openvtc/issues/535), in part). +- **Less work per loop turn** ([#555](https://github.com/OpenVTC/openvtc/issues/555), + [#556](https://github.com/OpenVTC/openvtc/issues/556), + [#565](https://github.com/OpenVTC/openvtc/issues/565), + [#557](https://github.com/OpenVTC/openvtc/issues/557)). The published state is shared + (`Arc`) rather than copied three times a turn, and two idle timers no longer + re-publish it. Config sync no longer reads did-git-sign's files or re-checks every VRC + for post-quantum proofs; persona labels, membership counts and relationship keys are + looked up by map. Personas load three at a time at startup, and one that fails no + longer stops the rest (their progress lines can interleave). VTA retries are jittered, + and `OPENVTC_DEBUG_LOG` is rotated once it passes 50 MiB. +- **CI runs the `#[ignore]`d end-to-end suites on every PR** + ([#537](https://github.com/OpenVTC/openvtc/issues/537)); CONTRIBUTING states when a + test may be ignored. + - **One confirmation rule** ([#579](https://github.com/OpenVTC/openvtc/issues/579)). A destructive or outward-facing action is armed by its key and committed by `y` alone; Enter — the key that opened the view — now cancels like any other key. Every prompt ends "y: confirm · any other key: @@ -150,6 +181,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **A failed join submit no longer deletes a persona the join reused** + ([#529](https://github.com/OpenVTC/openvtc/issues/529)). Only a persona the join itself + minted is rolled back. +- **A standalone persona mint is written to disk in one save**, including its origin + context ([#535](https://github.com/OpenVTC/openvtc/issues/535)), and the save no longer + blocks the event loop. + - **Nothing essential is clipped at 80x24** ([#576](https://github.com/OpenVTC/openvtc/issues/576)): all twelve menu items fit (the activity log shrinks to 4 rows below 32 rows; the menu scrolls to the selection on anything shorter, and is wide enough for its longest item); the footer row leads diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18106b15..c6f482d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,11 @@ cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace + cargo test --workspace -- --ignored # the e2e suites (in-process mediator / VTA) ``` + A test is `#[ignore]`d only because it is slow or boots an in-process + service, never because it is flaky or broken, and its `ignore = "…"` reason + says which. CI's `test-ignored` job runs every one of them and gates the PR. 4. **Open the PR** with a clear title and description summarizing the change 5. **Link related issues** using `Closes #123` or `Fixes #123` in the PR body 6. **Respond to review feedback** promptly diff --git a/docs/design/tui-architecture.md b/docs/design/tui-architecture.md index 2b09ce65..4a3dfcda 100644 --- a/docs/design/tui-architecture.md +++ b/docs/design/tui-architecture.md @@ -79,7 +79,8 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`): | `persona_actions.rs` | The identity pane. `apply(&mut State, &PersonaAction)` is pure and returns a `PersonaEffect` naming any network work; the loop spawns it and folds the result back through `PersonaOutcome::apply`. Reads and writes share one `DispatchDomain`. | | `didcomm.rs` | `DIDCommService` integration: service start, listener config/ids, the `DIDCommEvent` enum, lifecycle logging. | | `main_page/` | Maps `Config` → display state (`sync_from_config`), menu/content models. | -| `setup_wizard.rs`, `setup_sequence/`, `setup_*_actions.rs`, `join.rs`, `join_flow.rs` | The setup and join sub-flows (each runs its own scoped `select!` loop, then returns control to `main_loop`). | +| `setup_wizard.rs`, `setup_sequence/`, `setup_*_actions.rs` | The setup sub-flow (runs its own scoped `select!` loop, then returns control to `main_loop`). | +| `join.rs`, `join_flow.rs`, `join_flow/driver.rs` | The join flow. `join.rs` is its page state; `join_flow.rs` its pure page logic and the bounded sends it makes while waiting on a community; `driver.rs` the `JoinDriver` state machine the runtime loop (and the State-A loop) drives — no loop of its own (see §4). | | `dispatch_util.rs` | Shared helpers + test fixtures (`test_config`). | **Protocol logic does not live here.** The pure validators, handlers (join-receipt, @@ -102,11 +103,16 @@ or disk I/O parks the *whole* UI. Two mechanisms keep the loop live: for network-bound actions (mediator reconnect, relationship create/ping/remove, inbox accept/reject, context-DID delete): -1. The action's loop arm claims a `DispatchDomain` via - `InFlight::try_begin` (a per-domain busy-guard; a second action on a busy - domain is rejected with a status, not queued). -2. Cheap loop-thread prep builds a `…Job`; `spawn_dispatch` runs the job's **I/O - only** off-thread. +1. Cheap loop-thread prep builds a `…Job` from owned values: a type + implementing `background_dispatch::Job`, which names its `DispatchDomain` + and its **I/O only** (`execute`, returning the `DispatchOutcome`). +2. `background_dispatch::dispatch(job, in_flight, dispatch_tx, on_busy)` claims + the job's domain via `InFlight::try_begin` (a per-domain busy-guard) and + spawns it off-thread. A job for a busy domain is dropped unrun and + `on_busy` gets the busy message, to show or to remember as a deferred + retry — a second action is rejected or deferred, never run alongside. Work + due only once the claim succeeded (locking an overlay, arming a view on a + thread id) is done by the caller when `dispatch` returns `true`. 3. The job returns a `DispatchOutcome` over an mpsc the loop owns. The dedicated `Some(outcome) = dispatch_rx.recv()` arm calls `apply_outcome`, which mutates `State`/`Config` **on the loop thread** and clears the busy-flag. Mutation @@ -130,6 +136,38 @@ DID: the busy-guard is per-domain). Inside the job the DIDs resolve with bounded concurrency (a sliding window of 4). The job does I/O only; `apply_outcome` folds the verified `(did, name)` results into `Config::agent_names` on the loop thread. +**The join flow** ([`join_flow/driver.rs`](../../openvtc/src/state_handler/join_flow/driver.rs)) — +the largest user of the pattern, and the one that used to break the rule: it +ran its own nested `select!` loop that the runtime loop awaited inline, so +saves, VTA supervision, inbound messages and timers all stopped while the join +screen was up (#529). It is now a `JoinDriver` state machine the loop drives: + +1. While a join is open the loop's action arm hands every action to + `JoinDriver::on_action`; page logic runs there, on the loop. +2. Network work — resolving an agent name, reading a community (invitations, + manifest, DID document), and each stage of a launch — runs as one job at a + time on `DispatchDomain::Join`. The result comes back as + `DispatchOutcome::Join` and progress lines as `ProgressUpdate::Join`, both + carrying the job's generation; the loop gives them to the driver *before* + `apply_outcome`, and the driver ignores any that are not for the job it is + waiting on (an aborted one). +3. Join actions that arrive while a job is in flight are queued and replayed + in order when it finishes. `JoinCancel`, `Exit` and `UXError` are not: they + abort the job at once (`AbortHandle`; the driver frees the domain). +4. A launch is a saga (`openvtc_core::join::saga::JoinSaga`): context + registered → persona minted → listener up → submitted → persisted, with + on-loop applies between off-loop stages. Any failure, cancel or interrupt + undoes the launch from `JoinSaga::compensations` — remove the listener it + installed, forget the persona it minted (never a reused one) — rather than + from wherever a dropped future happened to stop. + +Short, bounded sends (asking a community for its requirements, a challenge, a +wallet session — each behind a 15 s timeout) and the VTA reads behind the +Answers page stay inline awaits, as elsewhere in the runtime. Everything the +join does over the network, to disk or through a live messaging account goes +through the `JoinIo` trait, so tests drive whole launches offline and fail them +at each stage. + ## 5. Recipes ### Add a keybinding @@ -154,9 +192,12 @@ the verified `(did, name)` results into `Config::agent_names` on the loop thread - **Local business logic** → add an arm that calls into the matching `*_actions::dispatch`. - **Network/blocking work** → follow the background-dispatch pattern (§4): - claim a `DispatchDomain`, `spawn_dispatch` the I/O, and apply the result in - `apply_outcome` (extend `DispatchOutcome` if it is a new kind of work). -4. If the handler mutates `Config`, call `save.mark_dirty()` (not an inline save). + implement `Job` for the work, hand it to `background_dispatch::dispatch`, + and apply the result in `apply_outcome` (extend `DispatchOutcome` if it is + a new kind of work). +4. If the handler mutates `Config`, call `save.mark_dirty()` (not an inline + save); where the change must be on disk before success is reported, await + `save.flush(&config)`. Every TUI save goes through the `SaveScheduler`. ### Add a (DIDComm) message type diff --git a/docs/design/vetting-process.md b/docs/design/vetting-process.md index d36a6aa8..7990e003 100644 --- a/docs/design/vetting-process.md +++ b/docs/design/vetting-process.md @@ -1412,12 +1412,11 @@ Following the existing patterns in - **cancel**. - An application that meets the published requirements joins as its own persona, and the page says its statements are presented with the join. - - When the book does not know, the community is asked. The join flow cannot - hear the answer itself: its loop selects only on actions and interrupts, and - inbound messages are read by the runtime loop's own arm, which is parked - while the flow runs. + - When the book does not know, the community is asked. The join flow does + not hear the answer itself: its driver takes actions and its own jobs' + results, and inbound messages are read by the runtime loop's own arm. - So the flow sends `manifest/0.2` as the active persona, with the send - bounded at 15 seconds, and returns `JoinExit::AwaitRequirements`. + bounded at 15 seconds, and returns `JoinStep::AwaitRequirements`. - The runtime loop keeps the join screen up in an *asking* state and goes on reading inbound messages. It enters the flow again when one of these happens: @@ -1654,7 +1653,7 @@ modern-signature subgraph only (O7). | D23 | Which criterion applies | The one whose `requirementsDigest` the applicant names in `extensions`; otherwise the first vetting criterion | | D24 | Session binding details | Match-code tag `vetting-session-match/v1\0`, distinct from `vtc-personhood-match/v1\0`. `eligibilityVp` carries `nonce` = the `vetting/request` document `id` and `domain` = the applicant's `joinDid` | | D25 | Answers to questions put to a community | **Matched by request document id, in memory only.** A directory page, a stored profile, a resent grant or a manifest reaches whoever asked. A `trust-task-error` is claimed only when it answers one of these questions, so the join handler keeps every other error. A question unanswered after 30 s is reported (R1.2) | -| D26 | How the join flow learns whether a community vets | **From the book, else by asking and handing the wait to the runtime loop** (`JoinExit::AwaitRequirements`, 15 s). The flow's own loop cannot hear inbound messages, so it never waits there, and every wait ends on a message or a deadline | +| D26 | How the join flow learns whether a community vets | **From the book, else by asking and handing the wait to the runtime loop** (`JoinStep::AwaitRequirements`, 15 s). The join driver does not hear inbound messages, so it never waits there, and every wait ends on a message or a deadline | | D27 | Vetter profile | **Opt-in listing; the last profile sent is kept as JSON with the community's answer.** A first profile is unlisted and accepts what `VetterPolicy` accepts. The SDK's `check_shape` is the only validator | | D28 | Checking a vetter's grant for revocation | **After a verified acceptance, as a background job, https-only with finite timeouts.** Recorded per request as active, revoked or unknown, and unknown is never shown as unrevoked. Advisory; the community checks again when it decides | | D29 | Community branding | **Kept from every manifest; presentation only.** The accent is drawn as a literal-colour swatch. The display name is used only when there is no membership name or verified agent name. The logo is not fetched | diff --git a/docs/openvtc-tool-commands.md b/docs/openvtc-tool-commands.md index 883eb8c6..97abe1fe 100644 --- a/docs/openvtc-tool-commands.md +++ b/docs/openvtc-tool-commands.md @@ -61,7 +61,7 @@ None of these appear in `--help`. | `OPENVTC_THEME` | Theme for one session. | | `OPENVTC_NVIM` | Path to Neovim for `theme import nvim:`, if `nvim` is not on `PATH`. | | `NO_COLOR` | Draw without colour. | -| `OPENVTC_DEBUG_LOG` | Write debug logs to this file. | +| `OPENVTC_DEBUG_LOG` | Write debug logs to this file (appended; moved to `.1` at startup once over 50 MiB). | | `OPENVTC_FRIENDLY_NAME` | Display name override. | | `OPENVTC_VTA_URL`, `OPENVTC_VTA_DID`, `OPENVTC_MEDIATOR_DID` | Honoured only by builds with the `dev-overrides` feature. Release builds ignore them. | diff --git a/openvtc-core/Cargo.toml b/openvtc-core/Cargo.toml index 7edbc344..929dd9ec 100644 --- a/openvtc-core/Cargo.toml +++ b/openvtc-core/Cargo.toml @@ -69,7 +69,6 @@ affinidi-messaging-sdk = { workspace = true } futures-util = { workspace = true } agent-names.workspace = true base64.workspace = true -bip39.workspace = true chrono.workspace = true dirs.workspace = true didwebvh-rs.workspace = true @@ -108,6 +107,8 @@ byteorder = { workspace = true, optional = true } trust-tasks-build.workspace = true [dev-dependencies] +# Mnemonic fixtures in the BIP32 tests. +bip39.workspace = true # Scratch directories for the git-workspace tests (a real clone, read back). tempfile = "3" criterion = { version = "0.8", features = ["html_reports"] } diff --git a/openvtc-core/src/config/keys.rs b/openvtc-core/src/config/keys.rs index d79f5995..5929837f 100644 --- a/openvtc-core/src/config/keys.rs +++ b/openvtc-core/src/config/keys.rs @@ -137,6 +137,9 @@ impl Config { /// (via the TDK Ed25519 routine), under its assertionMethod verification /// method. Used when joining under a different/fresh DID than the invited one. /// + /// [`Self::subject_signer`] then [`SubjectSigner::sign`], for a caller that + /// holds the config across the VTA round trip. + /// /// Errors if `subject_did` is not one of our personas (we can't prove /// control of a key we don't hold). pub async fn build_subject_linkage( @@ -146,6 +149,20 @@ impl Config { vic_id: &str, presenter_did: &str, ) -> Result { + self.subject_signer(subject_did)? + .sign(vta_client, vic_id, presenter_did) + .await + } + + /// Read what signing a subject-linkage proof as `subject_did` needs out of + /// the config: its assertionMethod verification method and where its + /// signing key comes from. No I/O — a key derived or imported locally is + /// resolved here; a VTA-managed one is fetched by [`SubjectSigner::sign`], + /// which needs no config and so can run off the caller's loop (#529). + /// + /// Errors if `subject_did` is not one of our personas, has no cached DID + /// document or assertionMethod, or has no signing key this config can reach. + pub fn subject_signer(&self, subject_did: &str) -> Result { // The subject persona's assertionMethod VM id (the VTC resolves it to // verify the signature). let persona = self @@ -169,24 +186,21 @@ impl Config { OpenVTCError::Config("subject persona has no assertionMethod".to_string()) })? .to_string(); - - let seed = self.persona_signing_seed(subject_did, vta_client).await?; - crate::join::sign_subject_linkage(&seed, vm, vic_id, presenter_did) + let key = self.persona_signing_key(subject_did)?; + Ok(SubjectSigner { vm, key }) } - /// Resolve a persona's Ed25519 signing-key seed (32 bytes) by DID, from + /// Where a persona's Ed25519 signing key comes from, found by DID, from /// whichever backing the key uses (BIP32-derived / imported / VTA-managed). - /// Mirrors the per-key resolution in [`Self::load_persona_secrets`]. - async fn persona_signing_seed( - &self, - persona_did: &str, - vta_client: Option<&vta_sdk::client::VtaClient>, - ) -> Result<[u8; 32], OpenVTCError> { + /// A local key is resolved here; a VTA-managed one is named, for + /// [`SubjectSigner::sign`] to fetch. Mirrors the per-key resolution in + /// [`Self::load_persona_secrets`]. + fn persona_signing_key(&self, persona_did: &str) -> Result { for (key_id, ki) in &self.key_info { if !key_id.starts_with(persona_did) || !matches!(ki.purpose, KeyTypes::PersonaSigning) { continue; } - let secret = match &ki.path { + return match &ki.path { KeySourceMaterial::Derived { path } => { let KeyBackend::Bip32 { root, .. } = &self.key_backend else { return Err(OpenVTCError::Config( @@ -194,29 +208,20 @@ impl Config { )); }; root.get_secret_from_path(path, KeyPurpose::Signing) + .map(SigningKey::Local) .map_err(|e| { OpenVTCError::Secret(format!("derive subject signing key: {e}")) - })? + }) } KeySourceMaterial::Imported { seed } => { Secret::from_multibase(seed.expose_secret(), None) - .map_err(|e| OpenVTCError::Secret(format!("imported signing key: {e}")))? + .map(SigningKey::Local) + .map_err(|e| OpenVTCError::Secret(format!("imported signing key: {e}"))) } KeySourceMaterial::VtaManaged { key_id: vta_key_id } => { - let client = vta_client.ok_or_else(|| { - OpenVTCError::Config( - "VTA-managed signing key requires a VTA client".to_string(), - ) - })?; - let resp = client.get_key_secret(vta_key_id).await.map_err(|e| { - OpenVTCError::Config(format!("fetch subject signing key: {e}")) - })?; - secret_from_vta_response(&resp, KeyPurpose::Signing)? + Ok(SigningKey::Vta(vta_key_id.clone())) } }; - return secret.get_private_bytes().try_into().map_err(|_| { - OpenVTCError::Secret("signing key is not a 32-byte Ed25519 seed".to_string()) - }); } Err(OpenVTCError::Config(format!( "no signing key found for persona {persona_did}" @@ -320,7 +325,7 @@ impl Config { /// them apart. Previously both were `OpenVTCError::Config` and either one /// failed the entire profile load. pub(crate) async fn regenerate_persona_keys( - tdk: &mut TDK, + tdk: &TDK, sc: &SecuredConfig, key_backend: &KeyBackend, doc: &Document, @@ -441,6 +446,57 @@ async fn secret_for_vm( Ok(Some(secret)) } +/// Everything signing a subject-linkage proof needs, read out of a [`Config`] +/// by [`Config::subject_signer`] so the signing — and, for a VTA-managed key, +/// the round trip that fetches it — can run without the config. +pub struct SubjectSigner { + /// The subject persona's assertionMethod verification method. + vm: String, + key: SigningKey, +} + +/// Where a subject's signing key comes from. +enum SigningKey { + /// Derived or imported: already in hand. + Local(Secret), + /// Held by the VTA under this key id. + Vta(String), +} + +impl SubjectSigner { + /// Sign the subject-linkage proof authorizing `presenter_did` to redeem + /// the invitation `vic_id`. A VTA-managed key is fetched through + /// `vta_client`, which it then requires. + pub async fn sign( + &self, + vta_client: Option<&vta_sdk::client::VtaClient>, + vic_id: &str, + presenter_did: &str, + ) -> Result { + let fetched; + let secret = match &self.key { + SigningKey::Local(secret) => secret, + SigningKey::Vta(vta_key_id) => { + let client = vta_client.ok_or_else(|| { + OpenVTCError::Config( + "VTA-managed signing key requires a VTA client".to_string(), + ) + })?; + let resp = client + .get_key_secret(vta_key_id) + .await + .map_err(|e| OpenVTCError::Config(format!("fetch subject signing key: {e}")))?; + fetched = secret_from_vta_response(&resp, KeyPurpose::Signing)?; + &fetched + } + }; + let seed: [u8; 32] = secret.get_private_bytes().try_into().map_err(|_| { + OpenVTCError::Secret("signing key is not a 32-byte Ed25519 seed".to_string()) + })?; + crate::join::sign_subject_linkage(&seed, self.vm.clone(), vic_id, presenter_did) + } +} + /// Converts a VTA `GetKeySecretResponse` into a TDK `Secret`. /// /// Supports Ed25519 (signing/authentication) and X25519 (encryption) key types. @@ -484,3 +540,24 @@ pub(crate) fn secret_from_vta_response( ))), } } + +#[cfg(test)] +mod tests { + use crate::test_support::test_config; + + /// The split read refuses exactly what the one-call form refused, before + /// any signing: a subject that is none of our personas. + #[tokio::test] + async fn a_stranger_subject_is_refused_by_both_forms() { + let config = test_config(); + let Err(split) = config.subject_signer("did:example:stranger") else { + panic!("a stranger has no signer"); + }; + let joined = config + .build_subject_linkage("did:example:stranger", None, "urn:vic:1", "did:example:me") + .await + .expect_err("a stranger cannot be signed for"); + assert_eq!(split.to_string(), joined.to_string()); + assert!(split.to_string().contains("is not one of your personas")); + } +} diff --git a/openvtc-core/src/config/loading.rs b/openvtc-core/src/config/loading.rs index 4f8d3421..17e0c885 100644 --- a/openvtc-core/src/config/loading.rs +++ b/openvtc-core/src/config/loading.rs @@ -14,6 +14,7 @@ use crate::{ use affinidi_tdk::{TDK, messaging::profiles::ATMProfile}; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; use ed25519_dalek_bip32::ExtendedSigningKey; +use futures_util::StreamExt; use secrecy::{ExposeSecret, SecretBox, SecretString}; use std::collections::BTreeMap; use tracing::{debug, info, warn}; @@ -125,6 +126,206 @@ fn record_degraded( }); } +/// How many personas are hydrated at once at startup. Each is a few VTA +/// round-trips (and a DID resolve when uncached); a small bound keeps a +/// many-persona account from bursting the VTA (R1.4). +const PERSONA_HYDRATE_CONCURRENCY: usize = 3; + +/// Run `f` over `items` at most `limit` at a time, and return the results in +/// the order of `items` — not the order they finished in — so whatever is done +/// with them afterwards is deterministic. +async fn map_bounded_in_order(items: I, limit: usize, mut f: F) -> Vec +where + I: IntoIterator, + F: FnMut(I::Item) -> Fut, + Fut: std::future::Future, +{ + let mut results: Vec<(usize, T)> = + futures_util::stream::iter(items.into_iter().enumerate().map(|(index, item)| { + let fut = f(item); + async move { (index, fut.await) } + })) + .buffer_unordered(limit.max(1)) + .collect() + .await; + results.sort_by_key(|(index, _)| *index); + results.into_iter().map(|(_, result)| result).collect() +} + +/// One persona as the account records it, for hydration: id, DID, mediator, +/// cached DID document, and a did:scid persona's DID URL with `src`. +type PersonaSeed = ( + PersonaId, + std::sync::Arc, + String, + Option, + Option, +); + +/// What every persona's hydration shares. All shared references: nothing here +/// is changed per persona, so several can run at once. +struct HydrateContext<'a> { + tdk: &'a TDK, + sc: &'a SecuredConfig, + key_backend: &'a KeyBackend, + vta_client: Option<&'a vta_sdk::client::VtaClient>, + on_progress: Option>, +} + +impl HydrateContext<'_> { + fn progress(&self, major: &str, sub: &str) { + if let Some(f) = self.on_progress { + f(major, sub); + } + } +} + +/// What hydrating one persona came to. +enum PersonaHydration { + /// Keys loaded and a messaging profile registered. + Loaded(Box), + /// This persona cannot be brought up this session; the others are not + /// affected. Recorded by the caller, in account order. + Degraded(DegradedReason), +} + +/// Bring one persona up: its DID document (cached, else resolved), its keys +/// into the TDK, and its messaging profile registered with ATM. A fault in the +/// persona is [`PersonaHydration::Degraded`]; `Err` is only for a fault no +/// persona can work around (the ATM service missing), which fails the load. +async fn hydrate_persona( + ctx: &HydrateContext<'_>, + seed: &PersonaSeed, + profile_label: String, +) -> Result { + let (persona_id, persona_did, persona_mediator, cached_document, did_locator) = seed; + let tdk = ctx.tdk; + // A did:scid persona's bare DID resolves only through a source + // the resolver holds, and the resolver forgets its sources on + // restart: seed it with the one recorded at mint (VTI-KEY-162). + // It is also the DID the persona sends as, so a peer meeting it + // for the first time can resolve it (VTI-KEY-163). + if let Some(locator) = did_locator { + tdk.did_resolver().scid_sources().add_source(locator); + } + let presented_did: &str = did_locator.as_deref().unwrap_or(persona_did.as_str()); + + // PERF #3: prefer the persisted persona DID document over a fresh + // network resolve (~1s). did:webvh docs change rarely between + // launches; a stale doc only matters if the persona rotated keys + // out-of-band — rare, and recoverable on the next mint/resolve. + let document = if let Some(doc) = cached_document.clone() { + ctx.progress("Identity", "Loading DID document (cached)"); + doc + } else { + ctx.progress("Identity", "Resolving DID document"); + match tdk.did_resolver().resolve(presented_did).await { + Ok(resolved) => resolved.doc, + // Isolated, not fatal: this persona has no usable + // document, but the others may. + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::DocumentUnresolvable { + detail: e.to_string(), + }, + )); + } + } + }; + + // Final mediator resolution. If neither the persona record nor + // the account carried a mediator, recover it from the DID + // document itself — the persona DID was minted with a + // DIDCommMessaging service whose endpoint IS the mediator, so it + // is always authoritative. Without this the persona listener + // fails with "No Mediator is configured" and reconnect-loops. + let persona_mediator = if persona_mediator.is_empty() { + match super::did::mediator_from_document(&document) { + Some(m) => { + warn!( + persona = %persona_did, + mediator = %m, + "recovered persona mediator from its DID document" + ); + m + } + None => { + warn!( + persona = %persona_did, + "persona has no mediator anywhere (record, account, \ + or DID document) — its listener will not connect" + ); + persona_mediator.clone() + } + } + } else { + persona_mediator.clone() + }; + + ctx.progress("Identity", "Fetching persona keys"); + // THE isolation point. A persona recorded in the account with no + // `key_info` — the signature of a save that wrote the config file + // and then died before the credential store — used to return here + // and fail the ENTIRE profile: every other persona, every + // community, every relationship. Now it costs exactly the persona + // that has the fault. + if let Err(reason) = + Config::regenerate_persona_keys(tdk, ctx.sc, ctx.key_backend, &document, ctx.vta_client) + .await + { + return Ok(PersonaHydration::Degraded(reason)); + } + + ctx.progress("Identity", "Building messaging profiles"); + // The ATM service itself is account-wide, not per persona: if it + // is missing, no persona can work and degrading each in turn + // would report the same fault N times as though N things broke. + let atm = tdk + .atm + .clone() + .ok_or_else(|| OpenVTCError::Config("TDK ATM service not initialized".to_string()))?; + let persona_profile = match ATMProfile::new( + &atm, + Some(profile_label), + presented_did.to_string(), + Some(persona_mediator.clone()), + ) + .await + { + Ok(profile) => profile, + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::MessagingUnavailable { + detail: e.to_string(), + }, + )); + } + }; + + // Register the persona profile with the TDK ATM Service but do + // NOT open a WebSocket — the DIDComm service owns connections. + let persona_profile = match atm.profile_add(&persona_profile, false).await { + Ok(profile) => profile, + Err(e) => { + return Ok(PersonaHydration::Degraded( + DegradedReason::MessagingUnavailable { + detail: e.to_string(), + }, + )); + } + }; + + Ok(PersonaHydration::Loaded(Box::new( + crate::identity::IdentityContext { + persona_id: *persona_id, + did: persona_did.to_string(), + document, + profile: persona_profile, + mediator_did: Some(persona_mediator), + }, + ))) +} + /// Cross-check the account against what actually loaded. /// /// Two mismatches matter, and both are the same interrupted write seen from @@ -425,15 +626,8 @@ impl Config { // Resolve a runtime identity for EVERY persona in the account, not just // the first: each persona gets its own DIDComm listener at runtime, so // all of them must be hydrated here (keys regenerated + an ATM messaging - // profile registered). A State-A account has zero personas, so the loop - // below never runs and `identities` stays empty. - type PersonaSeed = ( - crate::config::account::PersonaId, - std::sync::Arc, - String, - Option, - Option, - ); + // profile registered). A State-A account has zero personas, so nothing + // is hydrated below and `identities` stays empty. let personas: Vec = account .personas .values() @@ -509,168 +703,47 @@ impl Config { let mut integrity = LoadIntegrity::default(); let hydrate: Result<(), OpenVTCError> = async { - for (persona_id, persona_did, persona_mediator, cached_document, did_locator) in - &personas - { - // A did:scid persona's bare DID resolves only through a source - // the resolver holds, and the resolver forgets its sources on - // restart: seed it with the one recorded at mint (VTI-KEY-162). - // It is also the DID the persona sends as, so a peer meeting it - // for the first time can resolve it (VTI-KEY-163). - if let Some(locator) = did_locator { - tdk.did_resolver().scid_sources().add_source(locator); - } - let presented_did: &str = did_locator.as_deref().unwrap_or(persona_did.as_str()); - // Name the persona's messaging profile after its community AND - // its own DID, so two personas in one community are still - // distinguishable. Shares one helper - // with the runtime label (`Config::persona_profile_label`) — - // this used to be a second copy of the expression, which is how - // the two could drift. + // Personas are independent until they are recorded: each resolves its + // own document, fetches its own keys and registers its own profile, + // through shared handles (`&TDK`, the one VTA session — whose replies + // vta-sdk demuxes by `thid`). So they are hydrated a few at a time, + // then recorded in account order, so the degraded report reads the + // same whichever finished first. + let shared_tdk: &TDK = tdk; + let ctx = HydrateContext { + tdk: shared_tdk, + sc: &sc, + key_backend: &key_backend, + vta_client: vta_client.as_ref(), + on_progress, + }; + let hydrated = map_bounded_in_order(&personas, PERSONA_HYDRATE_CONCURRENCY, |seed| { + let (persona_id, persona_did, _, _, _) = seed; + // Name the persona's messaging profile after its community + // AND its own DID, so two personas in one community are still + // distinguishable. Shares one helper with the runtime label + // (`Config::persona_profile_label`) — this used to be a second + // copy of the expression, which is how the two could drift. // - // No `Config` exists yet here, so the verified-name lookup goes - // through `private_cfg` directly. + // No `Config` exists yet here, so the verified-name lookup + // goes through `private_cfg` directly. let profile_label = crate::config::membership_profile_label( account.memberships().find(|c| c.persona_ref == *persona_id), persona_did, |did| private_cfg.cached_agent_name(did).map(ToString::to_string), ); - - // PERF #3: prefer the persisted persona DID document over a fresh - // network resolve (~1s). did:webvh docs change rarely between - // launches; a stale doc only matters if the persona rotated keys - // out-of-band — rare, and recoverable on the next mint/resolve. - let document = if let Some(doc) = cached_document.clone() { - report_progress(&on_progress, "Identity", "Loading DID document (cached)"); - doc - } else { - report_progress(&on_progress, "Identity", "Resolving DID document"); - match tdk.did_resolver().resolve(presented_did).await { - Ok(resolved) => resolved.doc, - Err(e) => { - // Isolated, not fatal: this persona has no usable - // document, but the others may. - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::DocumentUnresolvable { - detail: e.to_string(), - }, - ); - continue; - } + hydrate_persona(&ctx, seed, profile_label) + }) + .await; + for ((persona_id, persona_did, _, _, _), outcome) in personas.iter().zip(hydrated) { + match outcome? { + PersonaHydration::Loaded(identity) => { + identities.insert(*persona_id, *identity); } - }; - - // Final mediator resolution. If neither the persona record nor - // the account carried a mediator, recover it from the DID - // document itself — the persona DID was minted with a - // DIDCommMessaging service whose endpoint IS the mediator, so it - // is always authoritative. Without this the persona listener - // fails with "No Mediator is configured" and reconnect-loops. - let persona_mediator = if persona_mediator.is_empty() { - match super::did::mediator_from_document(&document) { - Some(m) => { - warn!( - persona = %persona_did, - mediator = %m, - "recovered persona mediator from its DID document" - ); - m - } - None => { - warn!( - persona = %persona_did, - "persona has no mediator anywhere (record, account, \ - or DID document) — its listener will not connect" - ); - persona_mediator.clone() - } + PersonaHydration::Degraded(reason) => { + record_degraded(&mut integrity, &account, *persona_id, persona_did, reason); } - } else { - persona_mediator.clone() - }; - - report_progress(&on_progress, "Identity", "Fetching persona keys"); - // THE isolation point. A persona recorded in the account with no - // `key_info` — the signature of a save that wrote the config file - // and then died before the credential store — used to return here - // and fail the ENTIRE profile: every other persona, every - // community, every relationship. Now it costs exactly the persona - // that has the fault. - if let Err(reason) = Config::regenerate_persona_keys( - tdk, - &sc, - &key_backend, - &document, - vta_client.as_ref(), - ) - .await - { - record_degraded(&mut integrity, &account, *persona_id, persona_did, reason); - continue; } - - report_progress(&on_progress, "Identity", "Building messaging profiles"); - // The ATM service itself is account-wide, not per persona: if it - // is missing, no persona can work and degrading each in turn - // would report the same fault N times as though N things broke. - let atm = tdk.atm.clone().ok_or_else(|| { - OpenVTCError::Config("TDK ATM service not initialized".to_string()) - })?; - let persona_profile = match ATMProfile::new( - &atm, - Some(profile_label.clone()), - presented_did.to_string(), - Some(persona_mediator.clone()), - ) - .await - { - Ok(profile) => profile, - Err(e) => { - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::MessagingUnavailable { - detail: e.to_string(), - }, - ); - continue; - } - }; - - // Register the persona profile with the TDK ATM Service but do - // NOT open a WebSocket — the DIDComm service owns connections. - let persona_profile = match atm.profile_add(&persona_profile, false).await { - Ok(profile) => profile, - Err(e) => { - record_degraded( - &mut integrity, - &account, - *persona_id, - persona_did, - DegradedReason::MessagingUnavailable { - detail: e.to_string(), - }, - ); - continue; - } - }; - - identities.insert( - *persona_id, - crate::identity::IdentityContext { - persona_id: *persona_id, - did: persona_did.to_string(), - document, - profile: persona_profile, - mediator_did: Some(persona_mediator.clone()), - }, - ); } // Register relationship (R-DID) profiles ONCE, excluding ALL persona @@ -1269,3 +1342,41 @@ mod tests { ); } } + +#[cfg(test)] +mod hydrate_concurrency_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + /// Personas are hydrated a few at a time, never more than the bound, and + /// the results come back in account order whichever finished first — so + /// the degraded report and the identities are recorded deterministically. + #[tokio::test(start_paused = true)] + async fn map_bounded_in_order_bounds_concurrency_and_keeps_input_order() { + let running = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + let items: Vec = (0..10).collect(); + let out = map_bounded_in_order(&items, 3, |n| { + let (running, peak) = (&running, &peak); + async move { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + // Later items finish first. + tokio::time::sleep(Duration::from_millis(100 - n * 10)).await; + running.fetch_sub(1, Ordering::SeqCst); + *n * 2 + } + }) + .await; + assert_eq!(out, (0..10).map(|n| n * 2).collect::>()); + assert_eq!(peak.load(Ordering::SeqCst), 3, "at most the bound at once"); + } + + /// A zero bound is treated as one rather than hanging. + #[tokio::test] + async fn map_bounded_in_order_with_zero_limit_still_runs() { + let out = map_bounded_in_order([1, 2, 3], 0, |n| async move { n + 1 }).await; + assert_eq!(out, vec![2, 3, 4]); + } +} diff --git a/openvtc-core/src/elections.rs b/openvtc-core/src/elections.rs index 9a10c70c..a2bca91c 100644 --- a/openvtc-core/src/elections.rs +++ b/openvtc-core/src/elections.rs @@ -968,53 +968,13 @@ impl BallotDraft { // Answers from a community this persona is not a member of // **************************************************************************** -/// How long an answer from a community this persona holds no membership with is waited for. -const AWAITING_TTL: Duration = Duration::from_secs(120); - -/// The most such requests outstanding at once. -const MAX_AWAITING: usize = 64; - -/// Requests sent to a community this persona holds no membership with, by thread: an election -/// put to the members of a party the persona belongs to, held by a community it does not -/// (VTI-NTF-061, VTI-NTF-063). The inbound gate admits that community's answer only on a -/// thread recorded here; any other document from it has no standing. -static AWAITING: std::sync::LazyLock< - std::sync::Mutex>, -> = std::sync::LazyLock::new(Default::default); - -fn thread_key(thid: &str) -> &str { - thid.strip_prefix("urn:uuid:").unwrap_or(thid) -} - -/// Record that `thid` was sent to `community`, so its answer is admitted. -pub fn expect_answer(community: &str, thid: &str) { - if let Ok(mut a) = AWAITING.lock() { - a.retain(|_, (_, at)| at.elapsed() < AWAITING_TTL); - if a.len() < MAX_AWAITING { - a.insert( - thread_key(thid).to_string(), - (community.to_string(), std::time::Instant::now()), - ); - } - } -} - -/// Whether `thid` answers a request sent to `community` and still waited for. -#[must_use] -pub fn is_awaited(community: &str, thid: &str) -> bool { - AWAITING.lock().is_ok_and(|a| { - a.get(thread_key(thid)).is_some_and(|(c, at)| { - at.elapsed() < AWAITING_TTL && vta_sdk::did_scid::same_reference(c, community) - }) - }) -} - -/// The answer on `thid` was taken, or is no longer waited for. -pub fn answered(thid: &str) { - if let Ok(mut a) = AWAITING.lock() { - a.remove(thread_key(thid)); - } -} +// Requests sent to a community this persona holds no membership with — an +// election put to the members of a party the persona belongs to, held by a +// community it does not (VTI-NTF-061, VTI-NTF-063) — are recorded by thread in +// the caller's [`crate::pending_replies::PendingReplies`] (kind +// `ElectionAnswer`, 120 s, at most 64). The inbound gate admits that +// community's answer only on a thread recorded there; any other document from +// it has no standing. // **************************************************************************** // The member's VTA diff --git a/openvtc-core/src/elections/tests.rs b/openvtc-core/src/elections/tests.rs index 9dadb5c0..ff98e6c8 100644 --- a/openvtc-core/src/elections/tests.rs +++ b/openvtc-core/src/elections/tests.rs @@ -876,17 +876,3 @@ fn vti_vot_018_an_indeterminate_answer_says_to_try_again() { ); } } - -/// An answer from a community this persona is not a member of is admitted only on a thread -/// sent to it, and only from it. -#[test] -fn an_answer_from_a_community_we_do_not_belong_to_is_admitted_only_on_its_thread() { - let thid = "urn:uuid:00000000-0000-4000-8000-0000000000bb"; - assert!(!is_awaited(VTC, thid)); - expect_answer(VTC, thid); - assert!(is_awaited(VTC, thid)); - assert!(is_awaited(VTC, "00000000-0000-4000-8000-0000000000bb")); - assert!(!is_awaited("did:key:z6MkSomeoneElse", thid)); - answered(thid); - assert!(!is_awaited(VTC, thid)); -} diff --git a/openvtc-core/src/inbound/mod.rs b/openvtc-core/src/inbound/mod.rs index 1b6d9a2a..2b3a195c 100644 --- a/openvtc-core/src/inbound/mod.rs +++ b/openvtc-core/src/inbound/mod.rs @@ -22,6 +22,7 @@ use crate::messaging::{ is_trust_task_error_type, read_problem_report, require_thid, validate_did, verify_inbound_vrc, vet_vrc_issued, }; +use crate::pending_replies::{PendingReplies, ReplyKind}; use crate::personhood::{PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE}; use crate::vetting::protocol::JoinProtocol; use crate::{ @@ -461,6 +462,7 @@ impl VerifyJob { /// closed without a check result). fn verification_job( config: &Config, + pending: &PendingReplies, message: &Message, from_did: &str, recipient_did: &str, @@ -521,7 +523,7 @@ fn verification_job( let asked = message .thid .as_deref() - .is_some_and(|thid| crate::renewal::is_pending(from_did, thid)); + .is_some_and(|thid| pending.is_awaited(ReplyKind::Renewal, from_did, thid)); (member && asked).then(|| VerifyJob::Renewal { document: message.body.clone(), sender: from_did.to_string(), @@ -538,7 +540,7 @@ fn verification_job( // (VTI-NTF-063), which may be one we hold no membership with: only on // a thread we sent it. Family::ElectionsReply | Family::TrustTaskError if !member => { - awaited_election_answer(message, from_did).then(operational) + awaited_election_answer(pending, message, from_did).then(operational) } Family::CapabilityReply | Family::GitNsReply @@ -682,14 +684,14 @@ fn relationship_request_admissible( /// Whether `message` answers an elections request we sent `from_did` and still wait for — the /// only standing a community we hold no membership with has here. -fn awaited_election_answer(message: &Message, from_did: &str) -> bool { +fn awaited_election_answer(pending: &PendingReplies, message: &Message, from_did: &str) -> bool { let thread = message.thid.as_deref().or_else(|| { message .body .get("threadId") .and_then(serde_json::Value::as_str) }); - thread.is_some_and(|t| crate::elections::is_awaited(from_did, t)) + thread.is_some_and(|t| pending.is_awaited(ReplyKind::ElectionAnswer, from_did, t)) } /// An operational document that was not checked. @@ -726,6 +728,7 @@ fn community_reply_proven( from_did: &str, recipient_did: &str, cached: &mut Option, + pending: &PendingReplies, ) -> bool { if let Some(answer) = *cached { return answer; @@ -745,7 +748,7 @@ fn community_reply_proven( Ok(_) if config.account.memberships_for(from_did).is_empty() && !(crate::elections::is_reply_type(&message.typ) - && awaited_election_answer(message, from_did)) => + && awaited_election_answer(pending, message, from_did)) => { warn!(typ = %message.typ, "reply from a community we hold no membership with — ignored"); false @@ -825,6 +828,7 @@ pub async fn process_inbound_message( tdk: &TDK, service: &Messaging, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, effects: &mut InboundEffects, @@ -839,6 +843,7 @@ pub async fn process_inbound_message( tdk, service, seen, + pending, message, authenticated, effects, @@ -854,6 +859,7 @@ async fn process_inbound( tdk: &TDK, service: &Messaging, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, effects: &mut InboundEffects, @@ -971,7 +977,7 @@ async fn process_inbound( let recheck = matches!(arrival, Arrival::Returning(PreVerified::Barrier)); if !returning || recheck { let job = match ( - verification_job(config, message, &from_did, &recipient_did), + verification_job(config, pending, message, &from_did, &recipient_did), &arrival, ) { (Some(job), _) => Some(job), @@ -1005,6 +1011,7 @@ async fn process_inbound( let mut cx = Cx { tdk, service, + pending, message, from_did, recipient_did, @@ -1058,6 +1065,9 @@ async fn process_inbound( struct Cx<'a> { tdk: &'a TDK, service: &'a Messaging, + /// The requests we wait on an answer for: the gate for answers admitted + /// only because we asked (a renewal, a profile, an election). + pending: &'a mut PendingReplies, /// The message as routed (opened out of its binding envelope). message: &'a Message, /// The authenticated sender. @@ -1147,6 +1157,7 @@ fn capability_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { capability_replies.push((from_did.to_string(), thid, reply)); @@ -1185,6 +1196,7 @@ fn git_ns_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { // The sender and the document's issuer travel with the answer: the @@ -1228,6 +1240,7 @@ fn rooms_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { rooms_replies.push(ThreadedReply { @@ -1271,6 +1284,7 @@ fn elections_reply( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { elections_replies.push(ThreadedReply { @@ -1811,7 +1825,7 @@ async fn renewal_response( // else answers nothing the member is waiting on. The request is // not consumed — a forged reply must not be able to cancel the // genuine one still on its way. - if crate::renewal::is_pending(&from_did, thid) { + if cx.pending.is_awaited(ReplyKind::Renewal, &from_did, thid) { config .account .note_credential_refused(&from_did, Some(&e.to_string())); @@ -1834,8 +1848,9 @@ async fn renewal_response( warn!(vtc = %from_did, reason = %e, "renewal reply refused"); return Ok(false); } - match crate::renewal::take_pending(&from_did, thid) { - Some(member) if member == verified.member_did() => {} + match cx.pending.take(ReplyKind::Renewal, &from_did, thid) { + Some(crate::pending_replies::Purpose::Renewal { member_did }) + if member_did == verified.member_did() => {} _ => { warn!(vtc = %from_did, "renewal reply answers no renewal of ours — ignored"); return Ok(false); @@ -1957,6 +1972,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { capability_replies.push((from_did.to_string(), thid, reply)); @@ -1980,6 +1996,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { // The sender and the document's issuer travel with the answer: the @@ -2008,6 +2025,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { rooms_replies.push(ThreadedReply { @@ -2034,6 +2052,7 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { elections_replies.push(ThreadedReply { @@ -2133,7 +2152,7 @@ async fn trust_task_error( // refusal of a join and drop it for matching no pending request. if is_trust_task_error_type(&message.typ) && let Some(thid) = message.thid.as_deref() - && crate::renewal::is_pending(&from_did, thid) + && cx.pending.is_awaited(ReplyKind::Renewal, &from_did, thid) { if !community_reply_proven( config, @@ -2142,10 +2161,11 @@ async fn trust_task_error( &from_did, &recipient_did, &mut reply_proven, + cx.pending, ) { return Ok(false); } - let _ = crate::renewal::take_pending(&from_did, thid); + let _ = cx.pending.take(ReplyKind::Renewal, &from_did, thid); let outcome = crate::renewal::RenewalOutcome::Refused( crate::renewal::RenewalRefusal::read(&message.body), ); @@ -2268,10 +2288,11 @@ fn community_profile_response(config: &mut Config, cx: &mut Cx<'_>) -> Result, ) -> bool { @@ -2936,6 +2960,7 @@ pub async fn apply_inline( tdk, messaging, seen, + pending, &message, authenticated, &mut effects, diff --git a/openvtc-core/src/inbound/tests.rs b/openvtc-core/src/inbound/tests.rs index 6356be9f..205cf7e4 100644 --- a/openvtc-core/src/inbound/tests.rs +++ b/openvtc-core/src/inbound/tests.rs @@ -110,6 +110,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); // A forged (unsigned) credential: deferred, then refused. let mut config = pending_config(&vtc); @@ -122,6 +123,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -143,6 +145,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &deferred.message, deferred.message.from.as_deref(), &mut effects, @@ -174,6 +177,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -194,6 +198,7 @@ async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { &tdk, &service, &mut seen, + &mut pending, &deferred.message, deferred.message.from.as_deref(), &mut effects, @@ -219,6 +224,7 @@ async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let persona = *config.account.personas.keys().next().unwrap(); @@ -264,6 +270,7 @@ async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { &tdk, &service, &mut seen, + &mut pending, &answer, answer.from.as_deref(), &mut effects, @@ -292,6 +299,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let other = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; @@ -301,6 +309,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { &tdk, &service, &mut seen, + &mut pending, &other, other.from.as_deref(), &mut effects, @@ -320,6 +329,7 @@ async fn a_check_result_for_another_credential_is_not_taken() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -343,6 +353,7 @@ async fn a_removal_notice_waits_for_its_check() { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = pending_config(&vtc); let m = Message::build( uuid::Uuid::new_v4().to_string(), @@ -358,6 +369,7 @@ async fn a_removal_notice_waits_for_its_check() { &tdk, &service, &mut seen, + &mut pending, &m, m.from.as_deref(), &mut effects, @@ -487,7 +499,13 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { let tdk = test_tdk().await; let mut config = retired_config(&vtc); let request = uuid::Uuid::new_v4(); - crate::renewal::record_request(request, &vtc, PERSONA); + let mut pending = PendingReplies::default(); + assert!(crate::renewal::record_request( + &mut pending, + request, + &vtc, + PERSONA + )); let vmc = membership_vmc(&vtc, &key).await; let vac = role_vac(&vtc, &key).await; @@ -506,7 +524,7 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { ) .await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let effects = dispatch_waiting(&mut config, &mut pending, &tdk, &m, Arrival::FRESH).await; let deferred = effects.deferred.expect("set aside for its check"); assert!(matches!(deferred.job, VerifyJob::Renewal { .. })); assert!( @@ -518,8 +536,9 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { let pre = deferred.job.run(tdk.clone()).await; assert!(matches!(&pre, PreVerified::Renewal(Ok(_)))); - let effects = dispatch( + let effects = dispatch_waiting( &mut config, + &mut pending, &tdk, &deferred.message, Arrival::Returning(pre), @@ -554,7 +573,8 @@ async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { assert!(outcome.describe().contains("press m")); // Answered once: the same reply again is not taken. - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let effects = dispatch_waiting(&mut config, &mut pending, &tdk, &m, Arrival::FRESH).await; + assert!(pending.is_empty(), "the reply took its request"); assert!(effects.deferred.is_none()); assert!(effects.renewals.is_empty()); } @@ -597,7 +617,13 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { let tdk = test_tdk().await; let mut config = retired_config(&vtc); let request = uuid::Uuid::new_v4(); - crate::renewal::record_request(request, &vtc, PERSONA); + let mut pending = PendingReplies::default(); + assert!(crate::renewal::record_request( + &mut pending, + request, + &vtc, + PERSONA + )); let m = community_reply( &vtc, &key, @@ -611,16 +637,7 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { ) .await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - let deferred = effects.deferred.expect("set aside for its check"); - let pre = deferred.job.run(tdk.clone()).await; - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(pre), - ) - .await; + let effects = dispatch_checked_waiting(&mut config, &mut pending, &tdk, &m).await; let [(_, outcome)] = effects.renewals.as_slice() else { panic!("one renewal outcome"); @@ -632,7 +649,10 @@ async fn a_renewal_refused_as_not_a_member_is_reported() { let record = &config.account.memberships_for(&vtc)[0]; assert!(record.status.is_active(), "a refused renewal ends nothing"); assert_eq!(record.retired_credentials.len(), 2); - assert!(!crate::renewal::is_pending(&vtc, &request.to_string())); + assert!( + !pending.is_awaited(ReplyKind::Renewal, &vtc, &request.to_string()), + "the refusal took the request" + ); } /// Collects what is logged at `WARN` and above while it is the default @@ -669,10 +689,27 @@ impl Warnings { /// Dispatch `m` once it has been through its off-loop check, as the loop /// does: on arrival it is set aside, then handled with the check's result. async fn dispatch_checked(config: &mut Config, tdk: &TDK, m: &Message) -> InboundEffects { - let effects = dispatch(config, tdk, m, Arrival::FRESH).await; + dispatch_checked_waiting(config, &mut PendingReplies::default(), tdk, m).await +} + +/// [`dispatch_checked`], with the requests in `pending` waited on. +async fn dispatch_checked_waiting( + config: &mut Config, + pending: &mut PendingReplies, + tdk: &TDK, + m: &Message, +) -> InboundEffects { + let effects = dispatch_waiting(config, pending, tdk, m, Arrival::FRESH).await; let deferred = effects.deferred.expect("set aside for its check"); let pre = deferred.job.run(tdk.clone()).await; - dispatch(config, tdk, &deferred.message, Arrival::Returning(pre)).await + dispatch_waiting( + config, + pending, + tdk, + &deferred.message, + Arrival::Returning(pre), + ) + .await } /// A witness's answer reaches the witnessing waiting on it whoever sent it — a witness is @@ -908,20 +945,35 @@ async fn an_election_answer_from_a_non_member_community_needs_a_thread_we_sent() // On a thread we sent it: checked, then handed to the Elections panel. let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - crate::elections::expect_answer(&holder, &thid); + let mut pending = PendingReplies::default(); + pending.record( + &thid, + &holder, + crate::pending_replies::Purpose::ElectionAnswer, + ); let m = answer(thid.clone()).await; - let effects = dispatch_checked(&mut config, &tdk, &m).await; + let effects = dispatch_checked_waiting(&mut config, &mut pending, &tdk, &m).await; assert_eq!(effects.elections_replies.len(), 1); assert_eq!(effects.elections_replies[0].thid, thid); - crate::elections::answered(&thid); } -/// Dispatch `m` as `arrival`, returning the effects. +/// Dispatch `m` as `arrival`, returning the effects. Nothing is waited on. async fn dispatch( config: &mut Config, tdk: &TDK, m: &Message, arrival: Arrival<'_>, +) -> InboundEffects { + dispatch_waiting(config, &mut PendingReplies::default(), tdk, m, arrival).await +} + +/// [`dispatch`], with the requests in `pending` waited on. +async fn dispatch_waiting( + config: &mut Config, + pending: &mut PendingReplies, + tdk: &TDK, + m: &Message, + arrival: Arrival<'_>, ) -> InboundEffects { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let service = @@ -933,6 +985,7 @@ async fn dispatch( tdk, &service, &mut seen, + pending, m, m.from.as_deref(), &mut effects, @@ -1268,12 +1321,14 @@ mod sender_binding { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut effects = InboundEffects::default(); process_inbound_message( config, &tdk, &service, &mut seen, + &mut pending, m, authenticated, &mut effects, @@ -1467,6 +1522,7 @@ mod sender_binding { let service = crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let mut config = config_with(RelationshipState::Established, "rel"); let m = message( protocol_urls::VRC_REQUEST, @@ -1481,6 +1537,7 @@ mod sender_binding { &tdk, &service, &mut seen, + &mut pending, &m, authenticated, &mut effects, diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index 71cc0300..f2b4eb83 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -31,6 +31,7 @@ use vta_sdk::protocols::join_requests::{ use crate::errors::OpenVTCError; +pub mod saga; pub mod sequence; pub mod status_poll; @@ -248,44 +249,23 @@ async fn send_to_community( .await } -/// Profile questions we have outstanding: document id → (community, when). -/// In memory: a question is this process's, and its answer after a restart is -/// simply not taken (the next launch asks again). -static PROFILE_QUERIES: std::sync::LazyLock< - std::sync::Mutex>, -> = std::sync::LazyLock::new(Default::default); - -/// How long a profile question waits for its answer. -const PROFILE_QUERY_TTL: std::time::Duration = std::time::Duration::from_secs(600); -/// Most profile questions outstanding at once. -const MAX_PROFILE_QUERIES: usize = 256; - -fn remember_profile_query(document_id: &str, vtc_did: &str) { - if let Ok(mut q) = PROFILE_QUERIES.lock() { - q.retain(|_, (_, at)| at.elapsed() < PROFILE_QUERY_TTL); - if q.len() < MAX_PROFILE_QUERIES { - q.insert( - document_id.to_string(), - (vtc_did.to_string(), std::time::Instant::now()), - ); - } - } -} - -/// Whether `thid` answers a profile question we put to `vtc_did` (and is not -/// yet answered). Consumes it: a question is answered once. -#[must_use] -pub fn take_profile_query(vtc_did: &str, thid: &str) -> bool { - let Ok(mut q) = PROFILE_QUERIES.lock() else { - return false; - }; - match q.get(thid) { - Some((vtc, at)) if vtc == vtc_did && at.elapsed() < PROFILE_QUERY_TTL => { - q.remove(thid); - true - } - _ => false, - } +/// Open a profile question to `vtc_did`: a fresh document id, recorded in +/// `pending` (kind `CommunityProfile`) so the answer threading on it is taken. +/// Done on the thread that owns `pending`, before the send +/// ([`send_community_profile_show`]) is spawned, so an answer racing the send +/// is still recognised. In memory: a question is this process's, and its answer +/// after a restart is simply not taken (the next launch asks again). +pub fn open_profile_query( + pending: &mut crate::pending_replies::PendingReplies, + vtc_did: &str, +) -> String { + let document_id = format!("urn:uuid:{}", Uuid::new_v4()); + pending.record( + &document_id, + vtc_did, + crate::pending_replies::Purpose::CommunityProfile, + ); + document_id } /// Ask a community for its profile (`vtc/community/profile/show/0.1`) so we can @@ -299,6 +279,9 @@ pub fn take_profile_query(vtc_did: &str, thid: &str) -> bool { /// awaited here. Background, best-effort — a send failure is the caller's to log, /// not surface (the value only seeds a form default, and its absence is a valid, /// pairwise-defaulting state). +/// +/// `document_id` is the question's id, from [`open_profile_query`]. +#[allow(clippy::too_many_arguments)] pub async fn send_community_profile_show( atm: &ATM, profile: &Arc, @@ -307,10 +290,8 @@ pub async fn send_community_profile_show( vtc_did: &str, mediator_did: &str, tsp_mediator_did: Option<&str>, + document_id: String, ) -> Result<(), OpenVTCError> { - let document_id = format!("urn:uuid:{}", Uuid::new_v4()); - // Remembered before the send, so an answer racing it is still recognised. - remember_profile_query(&document_id, vtc_did); // The show payload carries only an optional extension bag; an empty object is // the request. The VTC reads the body as a Trust Task document, so it must be // wrapped (a bare payload is rejected `malformedRequest`). @@ -1366,18 +1347,6 @@ mod tests { assert!(err.contains("revocation status"), "{err}"); } - #[test] - fn a_profile_answer_is_taken_once_and_only_from_the_community_asked() { - remember_profile_query("urn:uuid:q1", "did:webvh:vtc"); - assert!(!take_profile_query("did:webvh:mallory", "urn:uuid:q1")); - assert!(!take_profile_query("did:webvh:vtc", "urn:uuid:other")); - assert!(take_profile_query("did:webvh:vtc", "urn:uuid:q1")); - assert!( - !take_profile_query("did:webvh:vtc", "urn:uuid:q1"), - "answered once" - ); - } - #[test] fn validate_accepts_a_complete_vic() { assert!(validate_invitation_credential(&complete_vic()).is_ok()); diff --git a/openvtc-core/src/join/saga.rs b/openvtc-core/src/join/saga.rs new file mode 100644 index 00000000..05d6a8e4 --- /dev/null +++ b/openvtc-core/src/join/saga.rs @@ -0,0 +1,201 @@ +//! What a join launch has done that a failure or a cancel must undo (#529). +//! +//! A join launch creates things before the community has the request: the +//! community's context at the VTA, possibly a freshly minted persona (persisted +//! to the account at once), and the applicant's mediator listener. Until the +//! request is out, each of those is speculative. The TUI used to track two of +//! them in out-parameters and undo them by hand at five call sites, relying on +//! where each `.await` happened to sit when a cancel dropped the future. +//! +//! [`JoinSaga`] records the steps as they complete, in order: +//! +//! `ContextRegistered → Minted → ListenerUp → Submitted → Persisted` +//! +//! and [`JoinSaga::compensations`] says, from that record alone, what undoes +//! the launch at the point it stopped. One compensation per step: +//! +//! | Step | Compensation | +//! |------|--------------| +//! | context registered | none — an empty context at the VTA is harmless and reused by a retry (the cancel message says it may remain) | +//! | persona minted | forget the persona, unless something already references it | +//! | listener up | remove the listener | +//! | submitted / persisted | none — the request is out, and the community may answer it | +//! +//! A *reused* persona is never recorded as minted, so it is never forgotten. + +use crate::config::account::PersonaId; + +/// One thing to undo, newest first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Compensation { + /// Tear down the applicant's mediator listener this launch installed. + RemoveListener(String), + /// Remove the persona this launch minted from the account (the caller checks + /// it is not referenced, then saves). + ForgetPersona(PersonaId), +} + +/// The steps a join launch has completed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JoinSaga { + context: Option, + minted: Option, + listener: Option, + submitted: bool, + persisted: bool, +} + +impl JoinSaga { + /// The community's context exists at the VTA. + pub fn context_registered(&mut self, context_id: impl Into) { + self.context = Some(context_id.into()); + } + + /// A persona was minted for this join and persisted to the account. + pub fn minted(&mut self, persona: PersonaId) { + self.minted = Some(persona); + } + + /// This launch installed the applicant's listener (a listener that already + /// existed — a reused persona's — is not ours and is never recorded). + pub fn listener_up(&mut self, listener_id: impl Into) { + self.listener = Some(listener_id.into()); + } + + /// The listener could not be installed after all: nothing to remove. + pub fn listener_failed(&mut self) { + self.listener = None; + } + + /// The join request left: from here nothing is undone. + pub fn submitted(&mut self) { + self.submitted = true; + } + + /// The pending membership was recorded. + pub fn persisted(&mut self) { + self.persisted = true; + } + + /// The context this launch registered, if it got that far. + #[must_use] + pub fn context(&self) -> Option<&str> { + self.context.as_deref() + } + + /// The persona this launch minted, if any. + #[must_use] + pub fn minted_persona(&self) -> Option { + self.minted + } + + /// The listener this launch installed, if any. + #[must_use] + pub fn listener(&self) -> Option<&str> { + self.listener.as_deref() + } + + /// Whether the request went out. + #[must_use] + pub fn is_submitted(&self) -> bool { + self.submitted + } + + /// Whether the pending membership was recorded. + #[must_use] + pub fn is_persisted(&self) -> bool { + self.persisted + } + + /// What undoes this launch where it stopped, newest step first. Empty once + /// the request is out. + #[must_use] + pub fn compensations(&self) -> Vec { + if self.submitted || self.persisted { + return Vec::new(); + } + let mut undo = Vec::new(); + if let Some(listener) = &self.listener { + undo.push(Compensation::RemoveListener(listener.clone())); + } + if let Some(persona) = self.minted { + undo.push(Compensation::ForgetPersona(persona)); + } + undo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nothing_done_is_nothing_to_undo() { + assert!(JoinSaga::default().compensations().is_empty()); + } + + #[test] + fn a_registered_context_alone_is_left_in_place() { + let mut saga = JoinSaga::default(); + saga.context_registered("ctx/community"); + assert!(saga.compensations().is_empty()); + assert_eq!(saga.context(), Some("ctx/community")); + } + + #[test] + fn a_failure_after_the_mint_forgets_the_persona() { + let mut saga = JoinSaga::default(); + let persona = PersonaId::new(); + saga.context_registered("ctx"); + saga.minted(persona); + assert_eq!( + saga.compensations(), + vec![Compensation::ForgetPersona(persona)] + ); + } + + #[test] + fn a_failure_after_the_listener_removes_it_before_forgetting_the_persona() { + let mut saga = JoinSaga::default(); + let persona = PersonaId::new(); + saga.minted(persona); + saga.listener_up("persona-listener"); + assert_eq!( + saga.compensations(), + vec![ + Compensation::RemoveListener("persona-listener".into()), + Compensation::ForgetPersona(persona), + ] + ); + } + + #[test] + fn a_reused_persona_is_never_forgotten() { + let mut saga = JoinSaga::default(); + saga.listener_up("persona-listener"); + assert_eq!( + saga.compensations(), + vec![Compensation::RemoveListener("persona-listener".into())] + ); + } + + #[test] + fn a_listener_that_failed_to_install_is_not_removed() { + let mut saga = JoinSaga::default(); + saga.listener_up("persona-listener"); + saga.listener_failed(); + assert!(saga.compensations().is_empty()); + } + + #[test] + fn once_the_request_is_out_nothing_is_undone() { + let mut saga = JoinSaga::default(); + saga.minted(PersonaId::new()); + saga.listener_up("persona-listener"); + saga.submitted(); + assert!(saga.compensations().is_empty()); + saga.persisted(); + assert!(saga.compensations().is_empty()); + assert!(saga.is_submitted() && saga.is_persisted()); + } +} diff --git a/openvtc-core/src/join/sequence.rs b/openvtc-core/src/join/sequence.rs index bda2b9d7..18d4a598 100644 --- a/openvtc-core/src/join/sequence.rs +++ b/openvtc-core/src/join/sequence.rs @@ -4,9 +4,15 @@ //! Each step here touches only [`Config`] and the network; what the person is //! shown about it (the TUI's join page, the headless command's progress lines) //! stays with the caller, which applies the step's result. Moved from the CLI -//! (issue #530). The rest of the sequence — minting a persona, choosing and -//! presenting an invitation, answering a community's questions — still lives -//! in the CLI's `join_flow` and moves with the join-flow rework (#529). +//! (issue #530). +//! +//! Each network step comes in two halves where the TUI needs them apart +//! (#529): an owned I/O half that runs off the runtime loop +//! ([`fetch_over_http`], [`resolve_document`], [`submit_prepared`]) and an +//! apply half that takes `&mut Config` on the loop ([`learn_fetched`], +//! [`note_signing`], [`prepare_submit`]). The one-call forms +//! ([`learn_over_http`], [`learn_signing`], [`submit`]) are those halves in +//! sequence, for callers that own their `Config` outright (`community join`). use std::time::Duration; @@ -77,7 +83,24 @@ pub async fn learn_over_http( tdk: &TDK, vtc_did: &str, ) -> Result<(), NotLearned> { - let resolver = tdk.did_resolver(); + let fetched = fetch_over_http(tdk.did_resolver(), vtc_did).await?; + learn_fetched(config, vtc_did, fetched); + Ok(()) +} + +/// A community's manifest, read from its REST endpoint, with the DID document +/// it was found through — what [`learn_fetched`] records. +pub struct OverHttp { + fetched: discover::FetchedManifest, + document: affinidi_tdk::did_common::Document, +} + +/// The I/O half of [`learn_over_http`]: resolve `vtc_did` and read its +/// manifest. Needs no `Config`, so it can run off the runtime loop. +pub async fn fetch_over_http( + resolver: &affinidi_tdk::did_resolver::DIDCacheClient, + vtc_did: &str, +) -> Result { let resolved = tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)) .await .map_err(|_| { @@ -92,15 +115,28 @@ pub async fn learn_over_http( .map_err(|e| NotLearned::Unresolvable(format!("it could not be resolved ({e})")))?; let doc = serde_json::to_value(&resolved.doc) .map_err(|e| NotLearned::Unanswered(format!("its DID document could not be read ({e})")))?; - let discover::FetchedManifest { - manifest, - raw, - protocol, - meta, - } = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) + let fetched = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) .await .map_err(|e| NotLearned::Unanswered(e.to_string()))?; + Ok(OverHttp { + fetched, + document: resolved.doc, + }) +} +/// The apply half of [`learn_over_http`]: the book learns the manifest, and any +/// application to this community adopts it. +pub fn learn_fetched(config: &mut Config, vtc_did: &str, fetched: OverHttp) { + let OverHttp { + fetched: + discover::FetchedManifest { + manifest, + raw, + protocol, + meta, + }, + document, + } = fetched; let now = Utc::now(); let before = config.private.vetting.vetter_mode(vtc_did).mode(); config @@ -118,7 +154,7 @@ pub async fn learn_over_http( .learn_mode(vtc_did, &raw, before, now); // The document is in hand already, so how the community signs is free to // learn — and before joining it is the only evidence there is. - note_signing(config, vtc_did, &resolved.doc); + note_signing(config, vtc_did, &document); let book = &mut config.private.vetting; for application in book .applications @@ -131,7 +167,6 @@ pub async fn learn_over_http( warn!(community = %vtc_did, error = %e, "community manifest not adopted"); } } - Ok(()) } /// Record how `vtc_did` signs, from its resolved DID document: whether it lists @@ -157,16 +192,31 @@ pub async fn learn_signing(config: &mut Config, tdk: &TDK, vtc_did: &str) { if config.private.vetting.post_quantum_key(vtc_did).is_some() { return; } - match tokio::time::timeout(RESOLVE_TIMEOUT, tdk.did_resolver().resolve(vtc_did)).await { - Ok(Ok(resolved)) => note_signing(config, vtc_did, &resolved.doc), + if let Some(doc) = resolve_document(tdk.did_resolver(), vtc_did).await { + note_signing(config, vtc_did, &doc); + } +} + +/// The I/O half of [`learn_signing`]: resolve `vtc_did`'s DID document, +/// bounded by [`RESOLVE_TIMEOUT`]. `None` when it did not resolve in time. +pub async fn resolve_document( + resolver: &affinidi_tdk::did_resolver::DIDCacheClient, + vtc_did: &str, +) -> Option { + match tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)).await { + Ok(Ok(resolved)) => Some(resolved.doc), Ok(Err(e)) => { debug!(community = %vtc_did, error = %e, "community DID not resolved; signing not checked"); + None + } + Err(_) => { + debug!( + community = %vtc_did, + timeout_secs = RESOLVE_TIMEOUT.as_secs(), + "community DID resolve timed out; signing not checked" + ); + None } - Err(_) => debug!( - community = %vtc_did, - timeout_secs = RESOLVE_TIMEOUT.as_secs(), - "community DID resolve timed out; signing not checked" - ), } } @@ -362,7 +412,29 @@ pub async fn submit( route: &SubmitRoute, presentation: crate::join::JoinPresentation, ) -> Result { - let atm = tdk.atm.as_ref().ok_or(SubmitError::NoMessaging)?; + let prepared = prepare_submit(config, tdk, persona_id).await?; + submit_prepared(&prepared, vtc_did, route, presentation).await +} + +/// What sending a join request needs from the config, read out of it so the +/// send can run without it: the messaging runtime, the persona's messaging +/// identity, and the key that signs the request. +pub struct PreparedSubmit { + atm: affinidi_tdk::messaging::ATM, + profile: std::sync::Arc, + persona_did: String, + mediator: String, + signer: affinidi_tdk::secrets_resolver::secrets::Secret, +} + +/// The config half of [`submit`]: no network (the key is read from the local +/// secrets the persona was loaded with). +pub async fn prepare_submit( + config: &Config, + tdk: &TDK, + persona_id: PersonaId, +) -> Result { + let atm = tdk.atm.clone().ok_or(SubmitError::NoMessaging)?; let identity = config .identities .get(&persona_id) @@ -377,13 +449,29 @@ pub async fn submit( .authentication .secret .clone(); - let applicant = crate::join::Applicant { + Ok(PreparedSubmit { atm, - profile: &profile, - persona_did: &persona_did, - signer: &signer, + profile, + persona_did, + mediator, + signer, + }) +} + +/// The send half of [`submit`]: sign and send the request along `route`. +pub async fn submit_prepared( + prepared: &PreparedSubmit, + vtc_did: &str, + route: &SubmitRoute, + presentation: crate::join::JoinPresentation, +) -> Result { + let applicant = crate::join::Applicant { + atm: &prepared.atm, + profile: &prepared.profile, + persona_did: &prepared.persona_did, + signer: &prepared.signer, vtc_did, - mediator_did: &mediator, + mediator_did: &prepared.mediator, tsp_mediator_did: route.tsp_mediator.as_deref(), }; crate::join::submit_join_request(&applicant, presentation) diff --git a/openvtc-core/src/lib.rs b/openvtc-core/src/lib.rs index 1dae3849..ff79d34f 100644 --- a/openvtc-core/src/lib.rs +++ b/openvtc-core/src/lib.rs @@ -56,6 +56,7 @@ mod net_guard; #[cfg(feature = "openpgp-card")] pub mod openpgp_card; pub mod operational; +pub mod pending_replies; pub mod persona; pub mod personhood; pub mod presentation; diff --git a/openvtc-core/src/pending_replies.rs b/openvtc-core/src/pending_replies.rs new file mode 100644 index 00000000..db4c1383 --- /dev/null +++ b/openvtc-core/src/pending_replies.rs @@ -0,0 +1,438 @@ +//! Replies we are waiting for from communities, by thread. +//! +//! Some answers are admitted only because we asked for them: a community's +//! answer to a renewal, its profile, or — from a community this persona holds +//! no membership with — an election answer (VTI-NTF-061, VTI-NTF-063). Each +//! request is recorded here, against the community it went to, before it is +//! sent; the inbound gate ([`crate::inbound`]) admits and takes an answer only +//! on a thread recorded here, from that community, while it is still waited +//! for. +//! +//! The table is **in memory and owned by whoever drives inbound handling**: +//! the TUI's runtime loop holds one for its lifetime, and each headless command +//! holds its own. It is passed explicitly to the send paths that record and to +//! the dispatcher that consumes — never a process-global — so the single +//! mutator owns it, and tests each use their own. A request is this process's: +//! an answer arriving after a restart is not taken (the member asks again). +//! +//! Each [`ReplyKind`] keeps the window, capacity, thread normalisation and +//! community comparison it has always had: +//! +//! | kind | waits | at most | thread key | community | +//! |-------------------------------|--------|---------|---------------------------------|---------------| +//! | [`ReplyKind::Renewal`] | 600 s | 64 | the UUID, `urn:uuid:` optional | exact | +//! | [`ReplyKind::CommunityProfile`] | 600 s | 256 | the thread id exactly | exact | +//! | [`ReplyKind::ElectionAnswer`] | 120 s | 64 | `urn:uuid:` prefix optional | same DID ([`vta_sdk::did_scid::same_reference`]) | + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use uuid::Uuid; + +/// What a recorded request was, and so how its answer is matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReplyKind { + /// `vtc/members/renew/0.1` ([`crate::renewal`]). + Renewal, + /// `vtc/community/profile/show/0.1` ([`crate::join::send_community_profile_show`]). + CommunityProfile, + /// A `vtc/elections/*` request to a community this persona may hold no + /// membership with ([`crate::elections`]). + ElectionAnswer, +} + +impl ReplyKind { + /// How long a request of this kind waits for its answer. + #[must_use] + pub const fn ttl(self) -> Duration { + match self { + ReplyKind::Renewal | ReplyKind::CommunityProfile => Duration::from_secs(600), + ReplyKind::ElectionAnswer => Duration::from_secs(120), + } + } + + /// The most requests of this kind outstanding at once. A request past the + /// cap is not recorded, and its answer is not taken. + #[must_use] + pub const fn capacity(self) -> usize { + match self { + ReplyKind::Renewal | ReplyKind::ElectionAnswer => 64, + ReplyKind::CommunityProfile => 256, + } + } + + /// The key a thread id is recorded and looked up under, or `None` for a + /// thread this kind can never answer. + /// + /// A renewal is sent with its document id as the DIDComm message id + /// (`urn:uuid:`), and a community may thread on either form, so it is + /// keyed by the UUID. An election answer may thread on either form too. A + /// profile answer threads on the document id exactly. + fn key(self, thid: &str) -> Option { + let bare = || thid.strip_prefix("urn:uuid:").unwrap_or(thid); + match self { + ReplyKind::Renewal => Uuid::parse_str(bare()).ok().map(|id| id.to_string()), + ReplyKind::CommunityProfile => Some(thid.to_string()), + ReplyKind::ElectionAnswer => Some(bare().to_string()), + } + } + + /// Whether `from` is the community a request of this kind was sent to. + /// An election's community may name itself with the `?src=` it is resolved + /// through (VTI-KEY-163), so it is compared by DID. + fn same_community(self, recorded: &str, from: &str) -> bool { + match self { + ReplyKind::Renewal | ReplyKind::CommunityProfile => recorded == from, + ReplyKind::ElectionAnswer => vta_sdk::did_scid::same_reference(recorded, from), + } + } +} + +/// What a request was for, recorded with it and handed back when its answer +/// is taken. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Purpose { + /// A renewal `member_did` asked for. + Renewal { member_did: String }, + /// A question for the community's profile. + CommunityProfile, + /// An election request. + ElectionAnswer, +} + +impl Purpose { + /// Its kind. + #[must_use] + pub const fn kind(&self) -> ReplyKind { + match self { + Purpose::Renewal { .. } => ReplyKind::Renewal, + Purpose::CommunityProfile => ReplyKind::CommunityProfile, + Purpose::ElectionAnswer => ReplyKind::ElectionAnswer, + } + } +} + +#[derive(Debug)] +struct Entry { + community: String, + purpose: Purpose, + at: Instant, +} + +impl Entry { + fn live(&self, kind: ReplyKind, now: Instant) -> bool { + now.saturating_duration_since(self.at) < kind.ttl() + } +} + +/// The requests we wait on an answer for: thread → community, purpose, and +/// when it was sent. See the [module docs](self). +#[derive(Debug, Default)] +pub struct PendingReplies { + entries: HashMap<(ReplyKind, String), Entry>, +} + +impl PendingReplies { + /// An empty table. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record that the request threaded on `thid` was sent to `community`, so + /// its answer is admitted. Done before the send, so an answer racing it is + /// still recognised. Returns whether it was recorded: not for a thread the + /// kind cannot be answered on, nor past the kind's capacity. + pub fn record(&mut self, thid: &str, community: &str, purpose: Purpose) -> bool { + self.record_at(thid, community, purpose, Instant::now()) + } + + fn record_at(&mut self, thid: &str, community: &str, purpose: Purpose, now: Instant) -> bool { + let kind = purpose.kind(); + let Some(key) = kind.key(thid) else { + return false; + }; + self.entries.retain(|(k, _), e| e.live(*k, now)); + if self.entries.keys().filter(|(k, _)| *k == kind).count() >= kind.capacity() { + return false; + } + self.entries.insert( + (kind, key), + Entry { + community: community.to_string(), + purpose, + at: now, + }, + ); + true + } + + /// Whether `thid` answers a request of `kind` sent to `community`, still + /// waited for. Does not consume it: an answer is [taken](Self::take) only + /// once it has been verified, so a forged one cannot cancel the genuine + /// answer still on its way. + #[must_use] + pub fn is_awaited(&self, kind: ReplyKind, community: &str, thid: &str) -> bool { + self.is_awaited_at(kind, community, thid, Instant::now()) + } + + fn is_awaited_at(&self, kind: ReplyKind, community: &str, thid: &str, now: Instant) -> bool { + self.live_entry(kind, community, thid, now).is_some() + } + + /// Take the request of `kind` that `thid` answers, if it was sent to + /// `community` and is still waited for. Consumes it: a request is answered + /// once. An answer from any other community leaves it waiting. + pub fn take(&mut self, kind: ReplyKind, community: &str, thid: &str) -> Option { + self.take_at(kind, community, thid, Instant::now()) + } + + fn take_at( + &mut self, + kind: ReplyKind, + community: &str, + thid: &str, + now: Instant, + ) -> Option { + let key = self.live_entry(kind, community, thid, now)?; + self.entries.remove(&(kind, key)).map(|e| e.purpose) + } + + /// Stop waiting on `thid`: its send failed, its view gave up on it, or its + /// answer was taken elsewhere. + pub fn forget(&mut self, kind: ReplyKind, thid: &str) { + if let Some(key) = kind.key(thid) { + self.entries.remove(&(kind, key)); + } + } + + /// The key of the live entry of `kind` on `thid` sent to `community`. + fn live_entry( + &self, + kind: ReplyKind, + community: &str, + thid: &str, + now: Instant, + ) -> Option { + let key = kind.key(thid)?; + let entry = self.entries.get(&(kind, key.clone()))?; + (entry.live(kind, now) && kind.same_community(&entry.community, community)).then_some(key) + } + + /// How many requests are waited on (expired ones included until the next + /// [`record`](Self::record) prunes them). + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether nothing is waited on. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VTC: &str = "did:webvh:QmVtc:vtc.example"; + const MEMBER: &str = "did:example:member"; + + fn renewal() -> Purpose { + Purpose::Renewal { + member_did: MEMBER.into(), + } + } + + /// A renewal answer is taken only from the community asked, only once, and + /// on either form of the thread id. + #[test] + fn a_renewal_is_bound_to_the_request_it_answers() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4(); + assert!(t.record(&id.to_string(), VTC, renewal())); + assert!(!t.is_awaited(ReplyKind::Renewal, "did:example:other", &id.to_string())); + assert!(t.is_awaited(ReplyKind::Renewal, VTC, &format!("urn:uuid:{id}"))); + assert_eq!( + t.take(ReplyKind::Renewal, VTC, &id.to_string()), + Some(renewal()) + ); + assert!( + t.take(ReplyKind::Renewal, VTC, &id.to_string()).is_none(), + "answered once" + ); + } + + /// A renewal is keyed by its UUID, so a thread that is not one never + /// matches — and an upper-case rendering of the same UUID does. + #[test] + fn a_renewal_thread_is_normalised_to_its_uuid() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4(); + assert!(!t.record("not-a-uuid", VTC, renewal())); + assert!(t.record(&format!("urn:uuid:{id}"), VTC, renewal())); + let upper = id.to_string().to_uppercase(); + assert!(t.is_awaited(ReplyKind::Renewal, VTC, &upper)); + assert!(!t.is_awaited(ReplyKind::Renewal, VTC, "garbage")); + } + + /// A profile answer threads on the document id exactly: no prefix + /// stripping. + #[test] + fn a_profile_answer_is_taken_once_and_only_from_the_community_asked() { + let mut t = PendingReplies::new(); + t.record("urn:uuid:q1", "did:webvh:vtc", Purpose::CommunityProfile); + assert!( + t.take( + ReplyKind::CommunityProfile, + "did:webvh:mallory", + "urn:uuid:q1" + ) + .is_none() + ); + assert!( + t.take( + ReplyKind::CommunityProfile, + "did:webvh:vtc", + "urn:uuid:other" + ) + .is_none() + ); + assert!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "q1") + .is_none(), + "exact thread only" + ); + assert_eq!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "urn:uuid:q1"), + Some(Purpose::CommunityProfile) + ); + assert!( + t.take(ReplyKind::CommunityProfile, "did:webvh:vtc", "urn:uuid:q1") + .is_none(), + "answered once" + ); + } + + /// An election answer is admitted only on a thread sent to that community, + /// on either form of the thread, and no longer once forgotten. + #[test] + fn an_election_answer_is_admitted_only_on_its_thread() { + let mut t = PendingReplies::new(); + let thid = "urn:uuid:00000000-0000-4000-8000-0000000000bb"; + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + t.record(thid, VTC, Purpose::ElectionAnswer); + assert!(t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + assert!(t.is_awaited( + ReplyKind::ElectionAnswer, + VTC, + "00000000-0000-4000-8000-0000000000bb" + )); + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, "did:key:z6MkSomeoneElse", thid)); + t.forget(ReplyKind::ElectionAnswer, thid); + assert!(!t.is_awaited(ReplyKind::ElectionAnswer, VTC, thid)); + } + + /// An election's community is compared by DID, so a `?src=` on the name it + /// answers under does not make it someone else. + #[test] + fn an_election_community_is_compared_by_did() { + let mut t = PendingReplies::new(); + let scid = "did:scid:vh:1:QmScid"; + t.record("t1", scid, Purpose::ElectionAnswer); + assert!(t.is_awaited( + ReplyKind::ElectionAnswer, + &format!("{scid}?src=vtc.example"), + "t1" + )); + // Renewals compare exactly. + let id = Uuid::new_v4().to_string(); + t.record(&id, scid, renewal()); + assert!(!t.is_awaited(ReplyKind::Renewal, &format!("{scid}?src=vtc.example"), &id)); + } + + /// Each kind waits its own window. + #[test] + fn each_kind_expires_on_its_own_window() { + let mut t = PendingReplies::new(); + let now = Instant::now(); + let id = Uuid::new_v4().to_string(); + t.record_at(&id, VTC, renewal(), now); + t.record_at("p", VTC, Purpose::CommunityProfile, now); + t.record_at("e", VTC, Purpose::ElectionAnswer, now); + + let before_election = now + Duration::from_secs(119); + assert!(t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e", before_election)); + let after_election = now + Duration::from_secs(120); + assert!(!t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e", after_election)); + assert!(t.is_awaited_at(ReplyKind::Renewal, VTC, &id, after_election)); + assert!(t.is_awaited_at(ReplyKind::CommunityProfile, VTC, "p", after_election)); + + let after_all = now + Duration::from_secs(600); + assert!(!t.is_awaited_at(ReplyKind::Renewal, VTC, &id, after_all)); + assert!( + t.take_at(ReplyKind::CommunityProfile, VTC, "p", after_all) + .is_none(), + "an expired request is not taken" + ); + } + + /// Each kind has its own cap, and a request past it is not recorded. + /// Expired requests make room. + #[test] + fn each_kind_is_capped_on_its_own() { + let mut t = PendingReplies::new(); + let now = Instant::now(); + for i in 0..64 { + assert!(t.record_at(&format!("e{i}"), VTC, Purpose::ElectionAnswer, now)); + } + assert!(!t.record_at("e-over", VTC, Purpose::ElectionAnswer, now)); + assert!(!t.is_awaited_at(ReplyKind::ElectionAnswer, VTC, "e-over", now)); + // Another kind is not crowded out. + for i in 0..256 { + assert!(t.record_at(&format!("p{i}"), VTC, Purpose::CommunityProfile, now)); + } + assert!(!t.record_at("p-over", VTC, Purpose::CommunityProfile, now)); + for _ in 0..64 { + assert!(t.record_at(&Uuid::new_v4().to_string(), VTC, renewal(), now)); + } + assert!(!t.record_at(&Uuid::new_v4().to_string(), VTC, renewal(), now)); + + // Once the election window passes, its requests are pruned on the next + // record and there is room again. + let later = now + ReplyKind::ElectionAnswer.ttl(); + assert!(t.record_at("e-later", VTC, Purpose::ElectionAnswer, later)); + } + + /// A take or check from the wrong community leaves the request waiting + /// for the right one; kinds do not answer for each other. + #[test] + fn the_wrong_community_or_kind_takes_nothing() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4().to_string(); + t.record(&id, VTC, renewal()); + assert!( + t.take(ReplyKind::Renewal, "did:example:mallory", &id) + .is_none() + ); + assert!(t.take(ReplyKind::ElectionAnswer, VTC, &id).is_none()); + assert!(t.take(ReplyKind::CommunityProfile, VTC, &id).is_none()); + assert!(t.take(ReplyKind::Renewal, VTC, &id).is_some()); + assert!(t.is_empty()); + } + + /// Forgetting removes only that kind's request on that thread. + #[test] + fn forgetting_is_per_kind() { + let mut t = PendingReplies::new(); + let id = Uuid::new_v4().to_string(); + t.record(&id, VTC, renewal()); + t.record(&id, VTC, Purpose::ElectionAnswer); + assert_eq!(t.len(), 2); + t.forget(ReplyKind::Renewal, &format!("urn:uuid:{id}")); + assert!(!t.is_awaited(ReplyKind::Renewal, VTC, &id)); + assert!(t.is_awaited(ReplyKind::ElectionAnswer, VTC, &id)); + } +} diff --git a/openvtc-core/src/relationships.rs b/openvtc-core/src/relationships.rs index 38e6cae6..32445a5d 100644 --- a/openvtc-core/src/relationships.rs +++ b/openvtc-core/src/relationships.rs @@ -27,8 +27,9 @@ use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fmt::Display, + ops::Bound, sync::Arc, time::SystemTime, }; @@ -44,6 +45,34 @@ use uuid::Uuid; /// How many relationship keys are fetched from the VTA at once at startup. const VTA_KEY_FETCH_CONCURRENCY: usize = 8; +/// The entries of an id-sorted map whose id starts with `prefix`. +/// +/// Every string that starts with `prefix` sorts at or after it, and they are +/// contiguous, so this is exactly `map.iter().filter(|(k, _)| k.starts_with(prefix))` +/// at O(log n + matches) rather than a scan of the whole map. Sorting `key_info` +/// once and querying it per relationship keeps the startup passes +/// O((keys + relationships) log keys) instead of O(keys × relationships). +fn entries_with_prefix<'a, K, V>( + sorted: &'a BTreeMap, + prefix: &'a str, +) -> impl Iterator + 'a +where + K: std::borrow::Borrow + Ord, +{ + sorted + .range::((Bound::Included(prefix), Bound::Unbounded)) + .take_while(move |(k, _)| (*k).borrow().starts_with(prefix)) +} + +/// Whether any id in the sorted set starts with `prefix` — exactly +/// `ids.iter().any(|k| k.starts_with(prefix))`, by the argument on +/// [`entries_with_prefix`]: the first id at or after `prefix` decides it. +fn any_with_prefix(ids: &BTreeSet, prefix: &str) -> bool { + ids.range::((Bound::Included(prefix), Bound::Unbounded)) + .next() + .is_some_and(|k| k.starts_with(prefix)) +} + /// State machine for the lifecycle of a relationship between two parties. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)] pub enum RelationshipState { @@ -412,6 +441,10 @@ impl Relationships { let mut all_secrets: Vec = Vec::new(); let mut vta_fetches: Vec = Vec::new(); + // `key_info` by id, sorted once, so each R-DID's keys are one range. + let key_info_sorted: BTreeMap<&str, &KeyInfoConfig> = + key_info.iter().map(|(k, v)| (k.as_str(), v)).collect(); + for our_did in &r_did_entries { // Skip any R-DID that has no canonical key-agreement (encryption) // entry in `key_info`. Registering a messaging profile for such a @@ -422,10 +455,8 @@ impl Relationships { // older build's id bug); flag it for the user to re-establish // rather than spin. Recoverable R-DIDs were re-keyed to // `{r_did}#key-2` by the repair pass and pass this check. - let has_key_agreement = key_info.iter().any(|(k, v)| { - k.starts_with(our_did.as_str()) - && matches!(v.purpose, KeyTypes::RelationshipEncryption) - }); + let has_key_agreement = entries_with_prefix(&key_info_sorted, our_did.as_str()) + .any(|(_, v)| matches!(v.purpose, KeyTypes::RelationshipEncryption)); if !has_key_agreement { warn!( r_did = %our_did, @@ -442,10 +473,8 @@ impl Relationships { profiles.insert(our_did.clone(), atm.profile_add(&profile, false).await?); // Collect secrets for this DID - for (k, v) in key_info.iter() { - if !k.starts_with(our_did.as_str()) { - continue; - } + for (k, v) in entries_with_prefix(&key_info_sorted, our_did.as_str()) { + let k = (*k).to_string(); let kp = match v.purpose { KeyTypes::RelationshipVerification => KeyPurpose::Signing, KeyTypes::RelationshipEncryption => KeyPurpose::Encryption, @@ -590,11 +619,14 @@ impl Relationships { let path_pointer = self.path_pointer; // remote_p_dids to flag once the immutable snapshot above is consumed. let mut to_flag: Vec> = Vec::new(); + // The `key_info` ids, sorted, kept in step with every insert/remove below + // so the "already canonical" check is a range lookup, not a full scan. + let mut key_ids: BTreeSet = key_info.keys().cloned().collect(); for (remote_p_did, r_did) in targets { // Already canonical (created post-fix or previously repaired): a - // cheap string scan keeps the whole pass idempotent. - if key_info.keys().any(|k| k.starts_with(r_did.as_str())) { + // cheap prefix lookup keeps the whole pass idempotent. + if any_with_prefix(&key_ids, r_did.as_str()) { continue; } @@ -653,7 +685,9 @@ impl Relationships { && old != new_id { key_info.remove(&old); + key_ids.remove(&old); } + key_ids.insert(new_id.clone()); key_info.insert(new_id, new_ki); report.repaired += 1; } @@ -1106,6 +1140,55 @@ mod tests { use ed25519_dalek_bip32::ExtendedSigningKey; use secrecy::SecretString; + #[test] + fn prefix_lookups_match_a_starts_with_scan() { + // Ids shaped like `key_info`: `{did}#key-N`, plus the awkward cases a + // `#`-split grouping would get wrong — a DID that is a prefix of another + // DID, an id equal to the DID, and ids that are not DID-shaped at all. + let ids = [ + "did:peer:2.Vabc#key-1", + "did:peer:2.Vabc#key-2", + "did:peer:2.Vabcd#key-1", + "did:peer:2.Vab", + "did:peer:2.Vb#key-1", + "did:webvh:Q:example.com#key-0", + "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "x25519", + "", + ]; + let sorted: BTreeMap<&str, usize> = ids.iter().enumerate().map(|(i, k)| (*k, i)).collect(); + let set: BTreeSet = ids.iter().map(|k| (*k).to_string()).collect(); + let prefixes = [ + "did:peer:2.Vabc", + "did:peer:2.Vab", + "did:peer:2.Vabcd#key-1", + "did:peer:2.Vz", + "did:webvh:Q:example.com", + "did:", + "z6Mk", + "x25519", + "zzz", + "", + ]; + for prefix in prefixes { + let mut expected: Vec<&str> = ids + .iter() + .copied() + .filter(|k| k.starts_with(prefix)) + .collect(); + expected.sort_unstable(); + let got: Vec<&str> = entries_with_prefix(&sorted, prefix) + .map(|(k, _)| *k) + .collect(); + assert_eq!(got, expected, "entries_with_prefix({prefix:?})"); + assert_eq!( + any_with_prefix(&set, prefix), + !expected.is_empty(), + "any_with_prefix({prefix:?})" + ); + } + } + fn make_relationship( task_id: &str, our_did: &str, diff --git a/openvtc-core/src/renewal.rs b/openvtc-core/src/renewal.rs index ffadbbfc..07de2b6c 100644 --- a/openvtc-core/src/renewal.rs +++ b/openvtc-core/src/renewal.rs @@ -43,10 +43,6 @@ //! [`crate::members`]); the community drops it on renewal. The caller sends a //! fresh one for the new grant, as the join flow does on admission. -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; -use std::time::{Duration, Instant}; - use crate::specs::vtc::members::renew::v0_1 as renew; use affinidi_did_resolver_cache_sdk::DIDCacheClient; use affinidi_tdk::secrets_resolver::secrets::Secret; @@ -75,87 +71,30 @@ pub const MEMBERS_RENEW_RESPONSE_TYPE: &str = pub const RENEW_NOT_MEMBER: &str = renew::error_codes::NOT_MEMBER.code; // ─── Outstanding requests ──────────────────────────────────────────────── - -/// Renewals we have asked for: request id → who asked whom, and when. -/// -/// In memory, like the community-profile questions in [`crate::join`]: a -/// renewal is this process's, and an answer arriving after a restart is not -/// taken — the member asks again, which is harmless (renewal is idempotent on -/// the community's side, re-minting into the same status-list slot). -static PENDING: LazyLock>> = LazyLock::new(Default::default); - -struct Pending { - vtc_did: String, - member_did: String, - at: Instant, -} - -/// How long a renewal waits for its answer. -const PENDING_TTL: Duration = Duration::from_secs(600); - -/// Most renewals outstanding at once. -const MAX_PENDING: usize = 64; - -/// The request id a reply's `thid` names. The request is sent with its -/// document id as the DIDComm message id (`urn:uuid:`), and a community -/// may thread on either form. -fn request_id_of(thid: &str) -> Option { - Uuid::parse_str(thid.strip_prefix("urn:uuid:").unwrap_or(thid)).ok() -} - -/// Record that `member_did` asked `vtc_did` to renew, under `request_id` — the -/// id the reply threads on. [`request_renewal`] does this itself; it is exposed -/// for a caller that delivers the document by another route (and for tests of -/// the reply's handling). -pub fn record_request(request_id: Uuid, vtc_did: &str, member_did: &str) { - if let Ok(mut pending) = PENDING.lock() { - pending.retain(|_, p| p.at.elapsed() < PENDING_TTL); - if pending.len() < MAX_PENDING { - pending.insert( - request_id, - Pending { - vtc_did: vtc_did.to_string(), - member_did: member_did.to_string(), - at: Instant::now(), - }, - ); - } - } -} - -fn forget(request_id: Uuid) { - if let Ok(mut pending) = PENDING.lock() { - pending.remove(&request_id); - } -} - -/// Whether `thid` answers a renewal we asked `vtc_did` for, still unanswered. -/// Does not consume it: a reply is taken ([`take_pending`]) only once it has -/// been verified. -#[must_use] -pub fn is_pending(vtc_did: &str, thid: &str) -> bool { - let Some(id) = request_id_of(thid) else { - return false; - }; - PENDING.lock().is_ok_and(|pending| { - pending - .get(&id) - .is_some_and(|p| p.vtc_did == vtc_did && p.at.elapsed() < PENDING_TTL) - }) -} - -/// Take the renewal `thid` answers, if it is one we asked `vtc_did` for. -/// Returns the member DID that asked. Consumes it: a renewal is answered once. -#[must_use] -pub fn take_pending(vtc_did: &str, thid: &str) -> Option { - let id = request_id_of(thid)?; - let mut pending = PENDING.lock().ok()?; - match pending.get(&id) { - Some(p) if p.vtc_did == vtc_did && p.at.elapsed() < PENDING_TTL => { - pending.remove(&id).map(|p| p.member_did) - } - _ => None, - } +// +// Renewals we have asked for are recorded in the caller's +// [`crate::pending_replies::PendingReplies`] (kind `Renewal`): request id → +// who asked whom, and when. In memory — a renewal is this process's, and an +// answer arriving after a restart is not taken; the member asks again, which +// is harmless (renewal is idempotent on the community's side, re-minting into +// the same status-list slot). + +/// Record in `pending` that `member_did` asked `vtc_did` to renew, under +/// `request_id` — the id the reply threads on. Returns whether it was recorded +/// (not past the table's cap for renewals). +pub fn record_request( + pending: &mut crate::pending_replies::PendingReplies, + request_id: Uuid, + vtc_did: &str, + member_did: &str, +) -> bool { + pending.record( + &request_id.to_string(), + vtc_did, + crate::pending_replies::Purpose::Renewal { + member_did: member_did.to_string(), + }, + ) } // ─── The request ───────────────────────────────────────────────────────── @@ -169,25 +108,20 @@ pub fn take_pending(vtc_did: &str, thid: &str) -> Option { /// asked for them. It rides the DIDComm binding envelope like every other /// member verb ([`crate::members::submit_member_vmc`]). /// -/// Returns the request id the community's reply threads on. Nothing is awaited: +/// `request_id` is the id the community's reply threads on. The caller records +/// it ([`record_request`]) in the table its inbound handling consumes from +/// *before* calling this — so a reply quicker than the return from the send +/// still finds its request — and forgets it if this fails. Nothing is awaited: /// the reply arrives asynchronously and is verified by /// [`verify_renewal_response`]. pub async fn request_renewal( route: &crate::members::Delivery<'_>, signer: &Secret, -) -> Result { - let request_id = Uuid::new_v4(); + request_id: Uuid, +) -> Result<(), OpenVTCError> { let document_id = format!("urn:uuid:{request_id}"); let body = build_renew_request(route.member_did, route.vtc_did, &document_id, signer).await?; - - // Remembered before the send, so a reply quicker than the return from - // the send still finds its request. - record_request(request_id, route.vtc_did, route.member_did); - if let Err(e) = crate::community_send::send_document(route, document_id, body).await { - forget(request_id); - return Err(e); - } - Ok(request_id) + crate::community_send::send_document(route, document_id, body).await } /// Build the signed `vtc/members/renew/0.1` document — split out from @@ -716,21 +650,6 @@ mod tests { assert_eq!(doc["proof"]["proofPurpose"], "authentication"); } - /// A reply is taken only for a renewal we asked that community for, and - /// only once — on either form of the thread id. - #[test] - fn a_reply_is_bound_to_the_request_it_answers() { - let id = Uuid::new_v4(); - record_request(id, "did:example:vtc", MEMBER); - assert!(!is_pending("did:example:other", &id.to_string())); - assert!(is_pending("did:example:vtc", &format!("urn:uuid:{id}"))); - assert_eq!( - take_pending("did:example:vtc", &id.to_string()).as_deref(), - Some(MEMBER) - ); - assert!(take_pending("did:example:vtc", &id.to_string()).is_none()); - } - /// **Success.** A verified renewal stores both credentials, clears the /// pre-v1 notices they answer, and reports the roles. #[tokio::test] diff --git a/openvtc-core/src/vta_task.rs b/openvtc-core/src/vta_task.rs index 10e712f7..ff0fac67 100644 --- a/openvtc-core/src/vta_task.rs +++ b/openvtc-core/src/vta_task.rs @@ -38,11 +38,25 @@ pub fn acl_subject_absent(err: &VtaError) -> bool { /// Max attempts for a single VTA round-trip before giving up (1 try + 2 retries). pub const MAX_ATTEMPTS: usize = 3; -/// Base back-off between retries; doubles each attempt (0.5s, then 1s). Short, -/// because the dominant retryable fault is a stale DIDComm socket that ATM -/// re-establishes almost immediately — we just need a beat before re-sending. +/// Base back-off between retries; doubles each attempt (0.5s, then 1s, each +/// jittered down by up to half — see [`backoff_delay`]). Short, because the +/// dominant retryable fault is a stale DIDComm socket that ATM re-establishes +/// almost immediately — we just need a beat before re-sending. const RETRY_BASE: std::time::Duration = std::time::Duration::from_millis(500); +/// The back-off before retry number `attempt` (1-based): "equal jitter" over the +/// exponential step `RETRY_BASE * 2^(attempt-1)` — half of the step is fixed, the +/// other half is scaled by `unit`, a sample in `[0, 1]`. The fixed half keeps the +/// beat a stale socket needs; the random half spreads clients that failed together +/// (a VTA or mediator restart) so their retries do not land in lockstep (R1.4). +/// `unit` is clamped, so the result is always within `[step/2, step]`. +#[must_use] +fn backoff_delay(attempt: usize, unit: f64) -> std::time::Duration { + let step = RETRY_BASE * (1u32 << (attempt.saturating_sub(1)).min(16)); + let half = step / 2; + half + half.mul_f64(unit.clamp(0.0, 1.0)) +} + /// True for errors a retry might clear: transport/timeout faults where the /// request most likely never reached the VTA (or its reply never came back). /// @@ -77,7 +91,7 @@ where match op().await { Ok(v) => return Ok(v), Err(e) if attempt < MAX_ATTEMPTS && retryable(&e) => { - let backoff = RETRY_BASE * (1 << (attempt - 1)); + let backoff = backoff_delay(attempt, rand::random::()); tracing::warn!( "VTA '{label}' failed (attempt {attempt}/{MAX_ATTEMPTS}): {e} — \ retrying in {backoff:?}" @@ -110,6 +124,24 @@ mod tests { assert!(!retryable(&VtaError::Auth("expired".into()))); } + #[test] + fn backoff_delay_stays_within_jitter_bounds() { + for attempt in 1..MAX_ATTEMPTS { + let step = RETRY_BASE * (1 << (attempt - 1)); + // The extremes of the sample, and out-of-range samples, are clamped. + assert_eq!(backoff_delay(attempt, 0.0), step / 2); + assert_eq!(backoff_delay(attempt, 1.0), step); + assert_eq!(backoff_delay(attempt, -3.0), step / 2); + assert_eq!(backoff_delay(attempt, 7.0), step); + for _ in 0..200 { + let d = backoff_delay(attempt, rand::random::()); + assert!(d >= step / 2 && d <= step, "attempt {attempt}: {d:?}"); + } + } + // A runaway attempt count cannot overflow the shift. + assert!(backoff_delay(usize::MAX, 1.0) <= RETRY_BASE * (1 << 16)); + } + #[tokio::test(start_paused = true)] async fn returns_immediately_on_success() { let calls = Cell::new(0u32); diff --git a/openvtc-core/tests/inbound_dispatch_e2e.rs b/openvtc-core/tests/inbound_dispatch_e2e.rs index c37e4e8a..4eb15c1e 100644 --- a/openvtc-core/tests/inbound_dispatch_e2e.rs +++ b/openvtc-core/tests/inbound_dispatch_e2e.rs @@ -36,6 +36,7 @@ use openvtc_core::config::{ use openvtc_core::didcomm::{Messaging, routes_inbound_type}; use openvtc_core::inbound::{Arrival, InboundEffects, process_inbound_message}; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use openvtc_core::protocol_urls; use openvtc_core::relationships::{ BindingRole, Relationship, RelationshipRequestBody, RelationshipState, did_binding_proofs, @@ -234,6 +235,7 @@ async fn dispatch(config: &mut Config, tdk: &TDK, received: &Received) -> (bool, let service: Messaging = openvtc_core::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); let mut seen = SeenMessages::new(); + let mut pending = PendingReplies::default(); let nobody = |_: &str| false; let mut arrival = Arrival::New { waiting: &nobody }; let mut message = received.message.clone(); @@ -245,6 +247,7 @@ async fn dispatch(config: &mut Config, tdk: &TDK, received: &Received) -> (bool, tdk, &service, &mut seen, + &mut pending, &message, received.authenticated.as_deref(), &mut effects, @@ -463,6 +466,7 @@ async fn a_type_no_handler_claims_is_refused_by_the_gate() { &tdk, &service, &mut SeenMessages::new(), + &mut PendingReplies::default(), &received.message, received.authenticated.as_deref(), &mut effects, diff --git a/openvtc/README.md b/openvtc/README.md index e1927524..ea9df654 100644 --- a/openvtc/README.md +++ b/openvtc/README.md @@ -188,6 +188,9 @@ OPENVTC_DEBUG_LOG=/tmp/openvtc.log openvtc ``` This writes timestamped tracing output at `debug` level to the specified file. +The file is appended to across runs. When it is already larger than 50 MiB at +startup it is moved to `.1` (replacing any earlier `.1`) and a fresh file +is started, so at most two files are kept. For finer control, combine with `RUST_LOG`: ```bash diff --git a/openvtc/src/main.rs b/openvtc/src/main.rs index 551ed42b..053e08cf 100644 --- a/openvtc/src/main.rs +++ b/openvtc/src/main.rs @@ -326,6 +326,16 @@ fn redact_paths(msg: &str) -> String { } } +/// The size at which `OPENVTC_DEBUG_LOG` is rotated when it is opened: 50 MiB. +const DEBUG_LOG_MAX_BYTES: u64 = 50 * 1024 * 1024; + +/// The one rotated generation of the debug log: `.1`. +fn rotated_debug_log_path(path: &std::path::Path) -> std::path::PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".1"); + std::path::PathBuf::from(name) +} + /// Open the `OPENVTC_DEBUG_LOG` file for appending, owner-only on unix. /// /// Append, never truncate. `File::create` truncated on every launch, so the @@ -343,7 +353,12 @@ fn redact_paths(msg: &str) -> String { /// * **A symlink is refused.** Creating through a link planted in a /// world-writable directory would append this process's log to a file of the /// planter's choosing, so a symlink at `path` is an error, not a target. -fn open_debug_log(path: &std::path::Path) -> std::io::Result { +/// +/// And one bound: a file already larger than `max_bytes` is moved to `.1` +/// (replacing any earlier rotated file) and a fresh one started, so a long run of +/// debug sessions keeps at most two files rather than growing without limit. The +/// check is made at open only; one run can still write past the cap. +fn open_debug_log(path: &std::path::Path, max_bytes: u64) -> std::io::Result { // `symlink_metadata` does not follow the final component, so this sees the // link itself rather than what it points at. A missing path is the normal // case: it is about to be created. @@ -354,6 +369,12 @@ fn open_debug_log(path: &std::path::Path) -> std::io::Result { "refusing to write the debug log through a symlink", )); } + // Over the cap: keep it as the one rotated generation. `rename` replaces + // the destination entry itself (a link planted at `.1` is replaced, + // not followed), and the previous runs' evidence survives in `.1`. + Ok(meta) if meta.is_file() && meta.len() > max_bytes => { + std::fs::rename(path, rotated_debug_log_path(path))?; + } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e), @@ -448,7 +469,7 @@ async fn main() -> Result<()> { // Log level defaults to this workspace's own crates but can be overridden // with RUST_LOG. if let Ok(log_path) = env::var("OPENVTC_DEBUG_LOG") { - match open_debug_log(std::path::Path::new(&log_path)) { + match open_debug_log(std::path::Path::new(&log_path), DEBUG_LOG_MAX_BYTES) { Ok(log_file) => { // Default to this workspace's own spans. The previous default // was a bare "debug", which also turned on every dependency — @@ -1263,7 +1284,7 @@ mod tests { let dir = temp_dir("mode"); let path = dir.join("openvtc.log"); - let file = open_debug_log(&path).expect("a fresh path opens"); + let file = open_debug_log(&path, DEBUG_LOG_MAX_BYTES).expect("a fresh path opens"); drop(file); let mode = std::fs::metadata(&path) @@ -1290,7 +1311,7 @@ mod tests { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) .expect("loosen the log"); - let file = open_debug_log(&path).expect("an existing path opens"); + let file = open_debug_log(&path, DEBUG_LOG_MAX_BYTES).expect("an existing path opens"); drop(file); let mode = std::fs::metadata(&path) @@ -1312,6 +1333,41 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn debug_log_rotates_once_over_the_cap() { + let dir = temp_dir("rotate"); + let path = dir.join("openvtc.log"); + let rotated = rotated_debug_log_path(&path); + let _ = std::fs::remove_file(&rotated); + + // Under the cap: appended to, nothing rotated. + std::fs::write(&path, b"small\n").expect("seed the log"); + drop(open_debug_log(&path, 16).expect("opens under the cap")); + assert_eq!(std::fs::read(&path).expect("read"), b"small\n"); + assert!(!rotated.exists(), "no rotation under the cap"); + + // Over the cap: moved to `.1`, a fresh empty file started. + std::fs::write(&path, b"run one, which is over the cap\n").expect("grow the log"); + drop(open_debug_log(&path, 16).expect("opens over the cap")); + assert_eq!(std::fs::read(&path).expect("read").len(), 0); + assert_eq!( + std::fs::read(&rotated).expect("read rotated"), + b"run one, which is over the cap\n" + ); + + // Over again: the previous rotated generation is replaced, not kept. + std::fs::write(&path, b"run two, also over the cap\n").expect("grow again"); + drop(open_debug_log(&path, 16).expect("opens over the cap again")); + assert_eq!( + std::fs::read(&rotated).expect("read rotated"), + b"run two, also over the cap\n", + "only one rotated file is kept" + ); + assert!(!dir.join("openvtc.log.2").exists()); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] #[cfg(unix)] fn debug_log_refuses_a_symlink() { @@ -1322,7 +1378,8 @@ mod tests { let _ = std::fs::remove_file(&link); std::os::unix::fs::symlink(&target, &link).expect("create the symlink"); - let err = open_debug_log(&link).expect_err("a symlink must be refused"); + let err = + open_debug_log(&link, DEBUG_LOG_MAX_BYTES).expect_err("a symlink must be refused"); assert_eq!( err.kind(), std::io::ErrorKind::InvalidInput, diff --git a/openvtc/src/repos_cmd.rs b/openvtc/src/repos_cmd.rs index c441e022..8daa2d3f 100644 --- a/openvtc/src/repos_cmd.rs +++ b/openvtc/src/repos_cmd.rs @@ -22,11 +22,12 @@ use openvtc_core::didcomm::{self, DIDCommEvent, Messaging}; use openvtc_core::git_ns::{self, GitRight, Reply, Request}; use openvtc_core::git_workspace::{self, CLONE_TIMEOUT, RepoCoords, WorkspaceSettings}; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use tokio::sync::mpsc; use crate::colors::{CLI_ERROR, CLI_INFO, Themed}; use crate::headless::{self, Admin, Membership, progress, save, warn}; -use crate::state_handler::{DeferredLoad, repos_actions}; +use crate::state_handler::{DeferredLoad, community_request}; use openvtc_core::inbound::apply_inline as apply_inbound; /// How long a request waits for the community's answer (as the panel does). @@ -333,7 +334,7 @@ async fn ask( progress("Connecting to the mediator…"); headless::connect_persona(&service, config, &tdk, target.persona, CONNECT_WINDOW).await?; - let sender = repos_actions::sender(config, &tdk, target.persona, &target.vtc_did) + let sender = community_request::sender(config, &tdk, target.persona, &target.vtc_did) .await .map_err(|e| anyhow!(e))?; let mut doc = git_ns::build(&request, &sender.persona_did, &target.vtc_did)?; @@ -343,7 +344,7 @@ async fn ask( request.type_uri().trim_start_matches(git_ns::TYPE_PREFIX), if sender.over_tsp { "TSP" } else { "DIDComm" } )); - repos_actions::sign_and_send(&sender, &target.vtc_did, &mut doc) + community_request::sign_and_send(&sender, &target.vtc_did, &mut doc) .await .map_err(|e| anyhow!("couldn't send the request: {e}"))?; @@ -387,6 +388,10 @@ async fn ask( #[derive(Default)] struct Others { seen: SeenMessages, + /// Replies this command waits on by thread. It asks nothing the inbound + /// gate admits only on a recorded thread, so this stays empty; it is the + /// command's own, as the runtime loop's is its own. + pending: PendingReplies, handled: usize, } @@ -452,6 +457,7 @@ async fn await_reply( tdk, messaging, &mut others.seen, + &mut others.pending, &message, authenticated.as_deref(), ) @@ -489,7 +495,7 @@ async fn take_reply( Err(e) => return Some(Err(format!("not accepted from this sender: {e}"))), }; let from = from.as_str(); - if !repos_actions::answered_by(vtc_did, from, doc.issuer.as_deref()) { + if !community_request::answered_by(vtc_did, from, doc.issuer.as_deref()) { return Some(Err(format!( "sent by {from}, not the community it was asked of" ))); diff --git a/openvtc/src/setup_cmd.rs b/openvtc/src/setup_cmd.rs index 08bea903..cf82031c 100644 --- a/openvtc/src/setup_cmd.rs +++ b/openvtc/src/setup_cmd.rs @@ -108,7 +108,7 @@ pub async fn run(args: &ArgMatches, profile: &str) -> Result<()> { let mut state = State::default(); state.setup.protection = ConfigProtection::Passcode(Arc::new(key)); // Nothing renders state here; sends to a closed channel are ignored. - let (state_tx, _) = watch::channel(State::default()); + let (state_tx, _) = watch::channel(std::sync::Arc::new(State::default())); // 2. Resolve the VTA and mint the setup DID. step(format!("Resolving VTA {vta_did}")); diff --git a/openvtc/src/state_handler/agent_name_actions.rs b/openvtc/src/state_handler/agent_name_actions.rs index 57842ba0..647d8b58 100644 --- a/openvtc/src/state_handler/agent_name_actions.rs +++ b/openvtc/src/state_handler/agent_name_actions.rs @@ -59,6 +59,18 @@ pub(crate) struct AgentNameJob { pub(crate) verb: Verb, } +impl crate::state_handler::background_dispatch::Job for AgentNameJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::AgentNameManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::AgentNameManage( + self.run().await, + ) + } +} + impl AgentNameJob { /// Run the verb, then re-read the registry. I/O only — no `State`, no /// `Config`, nothing that has to be mutated on the loop. diff --git a/openvtc/src/state_handler/agent_name_refresh.rs b/openvtc/src/state_handler/agent_name_refresh.rs index beb3265e..9dac7e08 100644 --- a/openvtc/src/state_handler/agent_name_refresh.rs +++ b/openvtc/src/state_handler/agent_name_refresh.rs @@ -22,6 +22,24 @@ use tracing::debug; /// Maximum concurrent DID resolutions inside one batch job. const CONCURRENCY: usize = 4; +/// One sweep: every uncached or stale DID, resolved by [`resolve_batch`]. +pub(crate) struct AgentNameRefreshJob { + pub(crate) resolver: DIDCacheClient, + pub(crate) dids: Vec, +} + +impl crate::state_handler::background_dispatch::Job for AgentNameRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::AgentName + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::AgentName( + resolve_batch(self.resolver, self.dids).await, + ) + } +} + /// Resolve each DID to a verified agent name (or `None`), bounded to /// [`CONCURRENCY`] in flight. The result pairs each input DID with its verified /// name; order is not preserved (the caller keys by DID). diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index 47e4d8fc..0f5b2f94 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -111,6 +111,12 @@ pub(crate) enum DispatchDomain { /// only. Its own domain so a periodic read never makes a member's key /// press wait. GitWorkspaceProbe, + /// Detecting the did-git-sign install for the persona, for Help / Status + /// and Settings: two small file reads on a blocking thread. Never claimed + /// by a key press, so a refresh asked for while one runs is queued + /// ([`SettingsState::did_git_sign_wanted`](crate::state_handler::main_page::content::SettingsState::did_git_sign_wanted)) + /// rather than reported as busy. + DidGitSign, /// A `vtc/rooms/*` request sent to a community from the Rooms panel. The answer /// arrives on the inbound channel; this serialises the sends. Rooms, @@ -169,6 +175,13 @@ pub(crate) enum DispatchDomain { /// to the witness, each waited on for up to a minute. Its own domain so the wait never /// holds up a ping or a VRC request on the same relationship. Witness, + /// The join flow's network work: resolving an agent name, reading a + /// community, and each stage of a launch (#529). One job at a time — the + /// join driver serialises them and queues the page's keys behind one — and + /// never claimed by a key press directly. Its outcomes are the driver's, + /// matched by generation, and an aborted job's domain is freed by the + /// driver that aborted it. + Join, } impl DispatchDomain { @@ -189,6 +202,7 @@ impl DispatchDomain { DispatchDomain::GitNs => "Repository request", DispatchDomain::GitWorkspace => "Checkout or signing change", DispatchDomain::GitWorkspaceProbe => "Checkout check", + DispatchDomain::DidGitSign => "Signing install check", DispatchDomain::Rooms => "Data room request", DispatchDomain::RoomsVta => "Data room keys at your VTA", DispatchDomain::Elections => "Election request", @@ -204,6 +218,7 @@ impl DispatchDomain { DispatchDomain::VettingStatus => "Vetter grant check", DispatchDomain::HiddenVetting => "Hidden-vetting work at your VTA", DispatchDomain::Witness => "Witnessing", + DispatchDomain::Join => "Joining a community", } } } @@ -307,12 +322,19 @@ pub(crate) enum DispatchOutcome { Workspace(crate::state_handler::repos_workspace::WorkspaceOutcome), /// A Repos-panel checkout and signing read finished. WorkspaceProbe(crate::state_handler::repos_workspace::ProbeOutcome), + /// The did-git-sign install was read for `persona_did`. Display-only. + DidGitSign { + /// The persona DID the detection was for. + persona_did: String, + /// What it found; `None` when did-git-sign is not set up for it. + info: Option, + }, /// A `vtc/rooms/*` request was sent to a community (or failed to send). - Rooms(crate::state_handler::rooms_actions::RoomsOutcome), + Rooms(crate::state_handler::community_request::SendOutcome), /// The member's VTA answered the Rooms panel. RoomsVta(crate::state_handler::rooms_actions::RoomsVtaOutcome), /// A `vtc/elections/*` request was sent to a community (or failed to send). - Elections(crate::state_handler::elections_actions::ElectionsOutcome), + Elections(crate::state_handler::community_request::SendOutcome), /// The member's VTA answered the Elections panel. ElectionsVta(crate::state_handler::elections_actions::ElectionsVtaOutcome), /// A cast reached its ballot box (or did not). @@ -364,6 +386,11 @@ pub(crate) enum DispatchOutcome { ), /// A witnessing finished (or failed). Witness(crate::state_handler::witness_actions::WitnessOutcome), + /// A join-flow job finished. The owning loop hands it to its + /// [`JoinDriver`](crate::state_handler::join_flow::JoinDriver) before + /// [`apply_outcome`] sees it; reaching `apply_outcome` it only frees the + /// domain. + Join(Box), } impl DispatchOutcome { @@ -387,6 +414,7 @@ impl DispatchOutcome { DispatchOutcome::Repos(_) => DispatchDomain::GitNs, DispatchOutcome::Workspace(_) => DispatchDomain::GitWorkspace, DispatchOutcome::WorkspaceProbe(_) => DispatchDomain::GitWorkspaceProbe, + DispatchOutcome::DidGitSign { .. } => DispatchDomain::DidGitSign, DispatchOutcome::Rooms(_) => DispatchDomain::Rooms, DispatchOutcome::RoomsVta(_) => DispatchDomain::RoomsVta, DispatchOutcome::Elections(_) => DispatchDomain::Elections, @@ -403,6 +431,7 @@ impl DispatchOutcome { DispatchOutcome::CommunityContext(_) => DispatchDomain::CommunityAccess, DispatchOutcome::DeviceGrantSweep(_) => DispatchDomain::DeviceGrantSweep, DispatchOutcome::Witness(_) => DispatchDomain::Witness, + DispatchOutcome::Join(_) => DispatchDomain::Join, DispatchOutcome::Panicked(domain) => *domain, } } @@ -416,6 +445,9 @@ pub(crate) enum ProgressUpdate { PersonaMint(String), /// A step of a witnessing, for the relationships panel's status line. Witness(String), + /// A line from a running join job, for the join page — said only while + /// that job (`generation`) is still the one the join is waiting on. + Join { generation: u64, line: String }, } impl ProgressUpdate { @@ -425,6 +457,7 @@ impl ProgressUpdate { match self { ProgressUpdate::PersonaMint(_) => DispatchDomain::Persona, ProgressUpdate::Witness(_) => DispatchDomain::Witness, + ProgressUpdate::Join { .. } => DispatchDomain::Join, } } } @@ -454,6 +487,51 @@ pub(crate) fn spawn_dispatch( }); } +/// One unit of off-loop work: the domain it serialises on, and the I/O that +/// produces its [`DispatchOutcome`]. +/// +/// Every job is built on the loop thread from owned values (no borrow of +/// `State` or `Config` survives into it), then handed to [`dispatch`], which +/// is the one place a job is claimed and spawned. `execute` does I/O only; the +/// outcome it returns is applied on the loop by [`apply_outcome`]. +pub(crate) trait Job: Send + 'static { + /// The domain this job holds while it runs. + fn domain(&self) -> DispatchDomain; + + /// Run the job's I/O and wrap its result as the outcome the loop applies. + fn execute(self) -> impl std::future::Future + Send; +} + +/// Claim `job`'s domain and spawn it, or refuse it. +/// +/// The single spawn shape for off-loop work: +/// +/// * If the domain is free it is claimed ([`InFlight::try_begin`]) and the job +/// is spawned through [`spawn_dispatch`] (so a panic still frees the +/// domain); returns `true`. +/// * If the domain is busy the job is dropped unrun, `on_busy` is called with +/// the domain's [`InFlight::busy_message`] — each caller decides where that +/// is shown, or whether a deferred retry is remembered instead — and it +/// returns `false`. +/// +/// Work the loop must do only once the claim has succeeded (locking an +/// overlay, setting a loading flag) is done by the caller on `true`: the +/// outcome is applied by the same loop, so it can never overtake that work. +pub(crate) fn dispatch( + job: J, + in_flight: &mut InFlight, + dispatch_tx: &tokio::sync::mpsc::UnboundedSender, + on_busy: impl FnOnce(String), +) -> bool { + let domain = job.domain(); + if !in_flight.try_begin(domain) { + on_busy(InFlight::busy_message(domain)); + return false; + } + spawn_dispatch(dispatch_tx.clone(), domain, job.execute()); + true +} + /// Apply a completed [`DispatchOutcome`] to `state` and clear the domain's /// busy-flag. **Pure** over `(&mut State, &mut InFlight, outcome)` — no I/O — so /// it is unit-testable and is the single place the loop's outcome arm mutates @@ -509,6 +587,7 @@ pub(crate) fn apply_outcome( config: &mut Config, save: &mut SaveScheduler, in_flight: &mut InFlight, + pending_replies: &mut openvtc_core::pending_replies::PendingReplies, outcome: DispatchOutcome, ) -> AfterApply { let domain = outcome.domain(); @@ -527,6 +606,9 @@ pub(crate) fn apply_outcome( state.main_page.content_panel.relationships.status_message = Some(Status::progress(step)); } + // The join driver's, intercepted by the owning loop; a line that + // reaches here belongs to no open join. + ProgressUpdate::Join { .. } => {} } return AfterApply::Nothing; } @@ -593,7 +675,7 @@ pub(crate) fn apply_outcome( } DispatchOutcome::AgentNameManage(outcome) => outcome.apply(state, config, save), DispatchOutcome::Community(outcome) => { - let pending = outcome.apply(state, config, save); + let pending = outcome.apply(state, config, save, pending_replies); in_flight.finish(domain); return match pending { Some((vtc, persona)) => AfterApply::Deregister(vtc, persona), @@ -615,11 +697,25 @@ pub(crate) fn apply_outcome( DispatchOutcome::Credential(outcome) => outcome.apply(state, config, save), DispatchOutcome::Capabilities(outcome) => outcome.apply(state), DispatchOutcome::Repos(outcome) => outcome.apply(state), - DispatchOutcome::Workspace(outcome) => outcome.apply(state), + DispatchOutcome::Workspace(outcome) => { + outcome.apply(state); + // A clone, a signing set-up or a removal may have changed the + // did-git-sign install Help / Status shows: read it again. + state.main_page.content_panel.settings.did_git_sign_wanted = true; + } DispatchOutcome::WorkspaceProbe(outcome) => outcome.apply(state), - DispatchOutcome::Rooms(outcome) => outcome.apply(state), + DispatchOutcome::DidGitSign { persona_did, info } => { + crate::state_handler::settings_actions::apply_did_git_sign(state, persona_did, info); + } + DispatchOutcome::Rooms(outcome) => crate::state_handler::community_request::send_failed::< + crate::state_handler::rooms_actions::Rooms, + >(state, pending_replies, outcome), DispatchOutcome::RoomsVta(outcome) => outcome.apply(state), - DispatchOutcome::Elections(outcome) => outcome.apply(state), + DispatchOutcome::Elections(outcome) => { + crate::state_handler::community_request::send_failed::< + crate::state_handler::elections_actions::Elections, + >(state, pending_replies, outcome) + } DispatchOutcome::ElectionsVta(outcome) => outcome.apply(state), DispatchOutcome::ElectionsCast(outcome) => outcome.apply(state), // Merged, not replaced. A sweep only carries the targets it was given, @@ -668,6 +764,9 @@ pub(crate) fn apply_outcome( DispatchOutcome::Vic(outcome) => outcome.apply(state, config), DispatchOutcome::VicMutation(outcome) => outcome.apply(state), DispatchOutcome::Witness(outcome) => outcome.apply(state, config, save), + // The owning loop's join driver applies these; one that reaches here + // has no open join to apply to, so its domain is only freed. + DispatchOutcome::Join(_) => {} // Handled above, before the domain is finished. Listed because the // match must stay exhaustive. DispatchOutcome::Progress(_) => unreachable!("progress returns before this match"), @@ -774,6 +873,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::MediatorReconnect(ReconnectOutcome::Connected), ); @@ -803,6 +903,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::MediatorReconnect(ReconnectOutcome::Failed { reason: "dead mediator".into(), class: openvtc_core::errors::ErrorClass::Unreachable, @@ -837,6 +938,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Panicked(DispatchDomain::Relationship), ); @@ -865,6 +967,102 @@ mod tests { )); } + /// A test job: holds `domain`, and answers with a `Panicked` outcome for it + /// (the simplest outcome that names a domain) once `release` fires. + struct TestJob { + domain: DispatchDomain, + release: tokio::sync::oneshot::Receiver<()>, + } + + impl Job for TestJob { + fn domain(&self) -> DispatchDomain { + self.domain + } + + async fn execute(self) -> DispatchOutcome { + let _ = self.release.await; + DispatchOutcome::Panicked(self.domain) + } + } + + /// `dispatch` claims the job's own domain and spawns it: the domain is busy + /// until the outcome is applied, the outcome arrives on the channel, and + /// applying it frees the domain again. + #[tokio::test] + async fn dispatch_claims_the_jobs_domain_and_spawns_it() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut in_flight = InFlight::default(); + let (release, gate) = tokio::sync::oneshot::channel(); + + let mut busy = None; + let started = dispatch( + TestJob { + domain: DispatchDomain::Vic, + release: gate, + }, + &mut in_flight, + &tx, + |msg| busy = Some(msg), + ); + assert!(started); + assert!(busy.is_none(), "a free domain must not report busy"); + assert!(in_flight.is_busy(DispatchDomain::Vic)); + assert!( + !in_flight.is_busy(DispatchDomain::Community), + "only the job's own domain is claimed" + ); + + release.send(()).unwrap(); + let outcome = rx.recv().await.expect("the spawned job reports back"); + assert_eq!(outcome.domain(), DispatchDomain::Vic); + + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), + outcome, + ); + assert!(!in_flight.is_busy(DispatchDomain::Vic)); + } + + /// A job for a busy domain is refused: not spawned, `on_busy` told why in + /// the domain's own words, and the running job's claim left untouched. + #[tokio::test] + async fn dispatch_rejects_a_job_for_a_busy_domain() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut in_flight = InFlight::default(); + assert!(in_flight.try_begin(DispatchDomain::Community)); + + let (release, gate) = tokio::sync::oneshot::channel(); + let mut busy = None; + let started = dispatch( + TestJob { + domain: DispatchDomain::Community, + release: gate, + }, + &mut in_flight, + &tx, + |msg| busy = Some(msg), + ); + assert!(!started); + assert_eq!( + busy.as_deref(), + Some(InFlight::busy_message(DispatchDomain::Community).as_str()) + ); + assert!(in_flight.is_busy(DispatchDomain::Community)); + + // The refused job never ran: its gate was dropped with it, and nothing + // reaches the channel. + assert!(release.send(()).is_err(), "a refused job is dropped unrun"); + drop(tx); + assert!(rx.recv().await.is_none()); + } + /// The busy message names the domain so the UI tells the user *what* is /// already running. #[test] @@ -938,7 +1136,14 @@ mod tests { } } Some(outcome) = dispatch_rx.recv() => { - apply_outcome(&mut state, &mut config, &mut save, &mut in_flight, outcome); + apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), + outcome, + ); } } }; @@ -976,6 +1181,7 @@ mod tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::AgentName(vec![ ( "did:webvh:example.com:alice".to_string(), @@ -1011,6 +1217,7 @@ mod tests { &mut config, &mut save2, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::AgentName(vec![ ( "did:webvh:example.com:alice".to_string(), @@ -1052,6 +1259,7 @@ mod progress_tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Progress(ProgressUpdate::PersonaMint("Minting the DID…".into())), ); @@ -1085,6 +1293,7 @@ mod progress_tests { &mut config, &mut save, &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), DispatchOutcome::Progress(ProgressUpdate::PersonaMint("Minting…".into())), ); diff --git a/openvtc/src/state_handler/capability_actions.rs b/openvtc/src/state_handler/capability_actions.rs index dec74bee..84e82d5c 100644 --- a/openvtc/src/state_handler/capability_actions.rs +++ b/openvtc/src/state_handler/capability_actions.rs @@ -20,7 +20,7 @@ //! //! Building the document here is what fixes its id — the thread the reply is //! matched on — before the send is spawned, so the view is armed with it -//! straight away ([`CapabilitySend::arm`]). Armed only when the send reported +//! straight away ([`CapabilitySend::arming`]). Armed only when the send reported //! back, a community answering within a millisecond (a refusal from a host //! that offers no capability management does) beat it: the reply found no //! pending thread, was dropped, and the view sat out the 30 s reply window to @@ -126,25 +126,45 @@ pub(crate) struct CapabilitySend { signing_secret: Box, } +impl crate::state_handler::background_dispatch::Job for CapabilitySend { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Capabilities + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Capabilities(self.run().await) + } +} + impl CapabilitySend { /// The thread the community's reply will carry. pub(crate) fn thread(&self) -> &str { openvtc_core::capabilities::correlation_thread(&self.doc) } - /// Arm the view to await this request's reply. Called on the loop, before - /// the send is spawned, so a reply that overtakes the send's own report is + /// An owned closure that arms the view to await this request's reply. + /// + /// Detached from the send so the caller can hand the send to + /// [`dispatch`](crate::state_handler::background_dispatch::dispatch) and + /// arm only once the claim has succeeded. Called on the loop in the same + /// turn as the spawn, so a reply that overtakes the send's own report is /// still matched. - pub(crate) fn arm(&self, state: &mut State) { - arm( - state, - &self.vtc_did, - self.persona, - self.thread(), - self.toggle - .as_ref() - .map(|(slug, enable)| (slug.as_str(), *enable)), - ); + pub(crate) fn arming(&self) -> impl FnOnce(&mut State) + use<> { + let vtc_did = self.vtc_did.clone(); + let persona = self.persona; + let thid = self.thread().to_string(); + let toggle = self.toggle.clone(); + move |state: &mut State| { + arm( + state, + &vtc_did, + persona, + &thid, + toggle + .as_ref() + .map(|(slug, enable)| (slug.as_str(), *enable)), + ); + } } /// Sign and send. I/O only. diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index 87a8467d..7ca0ce21 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -26,6 +26,7 @@ use crate::state_handler::save_coalesce::SaveScheduler; use crate::state_handler::state::State; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; +use openvtc_core::pending_replies::{PendingReplies, ReplyKind}; /// Which document a job sends to the community. pub(crate) enum Verb { @@ -77,8 +78,13 @@ pub(crate) enum Verb { /// [`openvtc_core::inbound`]. /// /// `document_signer` is the persona's authentication key: the task - /// declares `proof` REQUIRED. - Renew { document_signer: Box }, + /// declares `proof` REQUIRED. `request_id` is the id the reply threads on, + /// recorded in the runtime's [`PendingReplies`] on the loop thread before + /// this job is spawned ([`openvtc_core::renewal::record_request`]). + Renew { + document_signer: Box, + request_id: uuid::Uuid, + }, } /// What a job did, for the apply path to report. @@ -110,6 +116,16 @@ pub(crate) struct CommunityJob { pub(crate) over_tsp: bool, } +impl crate::state_handler::background_dispatch::Job for CommunityJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Community + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Community(self.run().await) + } +} + impl CommunityJob { /// Do the send. I/O only. pub(crate) async fn run(self) -> CommunityOutcome { @@ -205,19 +221,28 @@ impl CommunityJob { ) .await .map(|_| ()), - Verb::Renew { document_signer } => openvtc_core::renewal::request_renewal( - &openvtc_core::members::Delivery { - atm: &self.atm, - profile: &self.profile, - member_did: &self.member_did, - vtc_did: &self.vtc_did, - mediator_did: &self.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, + Verb::Renew { document_signer, - ) - .await - .map(|_| ()), + request_id, + } => { + openvtc_core::renewal::request_renewal( + &openvtc_core::members::Delivery { + atm: &self.atm, + profile: &self.profile, + member_did: &self.member_did, + vtc_did: &self.vtc_did, + mediator_did: &self.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + document_signer, + *request_id, + ) + .await + } + }; + let renewal_request = match &self.verb { + Verb::Renew { request_id, .. } => Some(*request_id), + _ => None, }; CommunityOutcome { vtc_did: self.vtc_did, @@ -225,6 +250,7 @@ impl CommunityJob { performed, error: result.err().map(|e| format!("{e}")), issued, + renewal_request, } } } @@ -240,6 +266,9 @@ pub(crate) struct CommunityOutcome { /// own acknowledgement. Carried back rather than written by the job, because /// the job holds no `Config`: every record change happens on the apply path. issued: Option, + /// The renewal's request id, waited on in the runtime's [`PendingReplies`] + /// from before the send: a send that failed is no longer waited on. + renewal_request: Option, } impl CommunityOutcome { @@ -254,7 +283,14 @@ impl CommunityOutcome { state: &mut State, config: &mut Config, save: &mut SaveScheduler, + pending_replies: &mut PendingReplies, ) -> Option<(String, PersonaId)> { + // A renewal that never left answers nothing. + if self.error.is_some() + && let Some(id) = self.renewal_request + { + pending_replies.forget(ReplyKind::Renewal, &id.to_string()); + } // Set through a local rather than a long-lived `&mut` into `state`: // two of the arms also want to log, which needs `main_page` again. fn status(state: &mut State, msg: Status) { @@ -393,6 +429,7 @@ mod tests { performed, error: error.map(ToString::to_string), issued: None, + renewal_request: None, } } @@ -415,7 +452,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(Performed::Leave, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Leave, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert_eq!(deregister.map(|(v, _)| v).as_deref(), Some(VTC)); assert!(save.is_pending(), "the record change must be persisted"); @@ -433,6 +475,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); assert!(deregister.is_none()); @@ -456,8 +499,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = - outcome(Performed::IssueVmc, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::IssueVmc, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert!(deregister.is_none()); assert!(!save.is_pending(), "nothing to persist"); @@ -487,6 +534,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); assert!(deregister.is_none()); @@ -513,6 +561,7 @@ mod tests { &mut state, &mut config, &mut save, + &mut PendingReplies::default(), ); let msg = status_of(&state).expect("a status line"); @@ -534,7 +583,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - outcome(Performed::AssertPersonhood, None).apply(&mut state, &mut config, &mut save); + outcome(Performed::AssertPersonhood, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); let msg = status_of(&state).expect("a status line"); assert!( @@ -551,7 +605,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(Performed::Renew, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Renew, None).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); assert!(deregister.is_none()); assert!(!save.is_pending()); @@ -562,6 +621,36 @@ mod tests { ); } + /// A renewal whose send failed is no longer waited on: an answer threading + /// on it later is not taken. One that was sent still is. + #[test] + fn a_renewal_that_never_left_is_no_longer_awaited() { + let vtc = VTC.to_string(); + for (error, awaited) in [(Some("peer unreachable"), false), (None, true)] { + let mut pending = PendingReplies::default(); + let id = uuid::Uuid::new_v4(); + assert!(openvtc_core::renewal::record_request( + &mut pending, + id, + &vtc, + "did:example:member" + )); + let mut sent = outcome(Performed::Renew, error); + sent.renewal_request = Some(id); + sent.apply( + &mut State::default(), + &mut test_config(), + &mut SaveScheduler::new("test"), + &mut pending, + ); + assert_eq!( + pending.is_awaited(ReplyKind::Renewal, &vtc, &id.to_string()), + awaited, + "error: {error:?}" + ); + } + } + /// Each verb reports its own failure. With the previous `leaving: bool` /// this could not be expressed — two of the four would have had to borrow /// another's wording and tell the member the wrong thing went wrong. @@ -581,7 +670,12 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - outcome(performed, Some("peer unreachable")).apply(&mut state, &mut config, &mut save); + outcome(performed, Some("peer unreachable")).apply( + &mut state, + &mut config, + &mut save, + &mut PendingReplies::default(), + ); let msg = status_of(&state).expect("a status line"); assert!( diff --git a/openvtc/src/state_handler/community_context_actions.rs b/openvtc/src/state_handler/community_context_actions.rs index 32343aee..8b9a8b5d 100644 --- a/openvtc/src/state_handler/community_context_actions.rs +++ b/openvtc/src/state_handler/community_context_actions.rs @@ -24,7 +24,6 @@ //! [`reduce`], which both loops share. use crate::state_handler::main_page::status::Status; -use std::future::Future; use chrono::Utc; use openvtc_core::community_access::{self, DeviceGrant, EXPIRY_CHOICES, Revoked}; @@ -33,11 +32,12 @@ use openvtc_core::config::account::{CommunityRecord, PersonaId}; use openvtc_core::config::community_context::{ self, ContextDeletion, ContextDeletionPreview, DELETE_CONFIRMATION, }; +use openvtc_core::didcomm::Messaging; use openvtc_core::errors::OpenVTCError; use vta_sdk::client::VtaClient; use crate::state_handler::actions::CommunityContextAction as Act; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; use crate::state_handler::main_page::content::{ CommunitiesState, ContextDeletePhase, ContextDeleteView, DeviceAccessView, GrantForm, GrantListing, @@ -239,23 +239,175 @@ fn community_name(config: &Config, membership: &CommunityRecord) -> String { ) } -/// Claim the domain and the VTA session for a job, or say why not. -fn claim(ctx: &mut ActionCtx<'_>, what: &str) -> Result { - let Some(client) = ctx.admin_vta else { - return Err(format!( - "VTA session unavailable — cannot {what} right now." - )); - }; - if !ctx.in_flight.try_begin(DOMAIN) { - return Err(InFlight::busy_message(DOMAIN)); +/// The VTA session a job needs, or why there is none. +fn session(ctx: &ActionCtx<'_>, what: &str) -> Result { + ctx.admin_vta + .cloned() + .ok_or_else(|| format!("VTA session unavailable — cannot {what} right now.")) +} + +/// Claim the domain for `job` and spawn it. On a busy domain the job is +/// dropped and `on_busy` given the reason; returns whether it started. +fn start( + ctx: &mut ActionCtx<'_>, + job: ContextJob, + on_busy: impl FnOnce(&mut State, String), +) -> bool { + let state = &mut *ctx.state; + background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |busy| { + on_busy(state, busy); + }) +} + +/// One context job, resolved on the loop. I/O only. +enum ContextJob { + /// What deleting `context_id` would remove. + Preview { + client: VtaClient, + top: String, + context_id: String, + }, + /// Delete the context (and the persona that goes with it), then tear the + /// persona's listener down if its DID went. + Delete { + client: VtaClient, + service: Messaging, + top: String, + deletion: ContextDeletion, + vtc_did: String, + persona: PersonaId, + community: String, + }, + /// Read `context_id`'s device grants. + List { + client: VtaClient, + context_id: String, + }, + /// Grant `did` access, then re-read the grants. + Grant { + client: VtaClient, + top: String, + context_id: String, + did: String, + label: String, + expires: chrono::DateTime, + }, + /// Revoke `did`'s access, then re-read the grants. + Revoke { + client: VtaClient, + context_id: String, + did: String, + }, +} + +impl background_dispatch::Job for ContextJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::CommunityContext(self.run().await) } - Ok(client.clone()) } -fn spawn(ctx: &ActionCtx<'_>, job: impl Future + Send + 'static) { - background_dispatch::spawn_dispatch(ctx.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::CommunityContext(job.await) - }); +impl ContextJob { + async fn run(self) -> ContextOutcome { + match self { + ContextJob::Preview { + client, + top, + context_id, + } => { + let result = + community_context::preview_context_deletion(&client, &top, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Previewed { context_id, result } + } + ContextJob::Delete { + client, + service, + top, + deletion, + vtc_did, + persona, + community, + } => { + let report = + community_context::delete_community_context(&client, &top, &deletion).await; + if report.persona_removed + && let Some(taken) = &deletion.persona + { + // The DID is gone, so the persona's listener has nothing left to + // receive — the same teardown deleting a persona does. + service + .remove_listener(&openvtc_core::didcomm::persona_listener_id(&taken.did)) + .await; + } + ContextOutcome::Deleted { + vtc_did, + persona, + community, + persona_removed: report.persona_removed, + result: report.result.map_err(|e| reason(&e)), + deletion, + } + } + ContextJob::List { client, context_id } => { + let result = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Listed { context_id, result } + } + ContextJob::Grant { + client, + top, + context_id, + did, + label, + expires, + } => { + let result = community_access::grant_device( + &client, + &top, + &context_id, + &did, + &label, + expires, + ) + .await + .map_err(|e| reason(&e)); + // Re-read whatever the grant did, so the list is the VTA's answer. + let listing = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Granted { + context_id, + did, + result, + listing, + } + } + ContextJob::Revoke { + client, + context_id, + did, + } => { + let result = community_access::revoke_device_grant(&client, &context_id, &did) + .await + .map_err(|e| reason(&e)); + let listing = community_access::list_device_grants(&client, &context_id) + .await + .map_err(|e| reason(&e)); + ContextOutcome::Revoked { + context_id, + did, + result, + listing, + } + } + } + } } /// Report a grant or revoke that could not start — always a refusal. @@ -291,25 +443,24 @@ fn start_deletion(ctx: &mut ActionCtx<'_>, index: usize) { phase: ContextDeletePhase::Previewing, typed: String::new(), }; - let context_id = deletion.context_id; - let top = config.account.top_context_id.clone(); - let client = match claim(ctx, "delete a context") { - Ok(client) => client, - Err(message) => { - panel(ctx.state).status_message = Some(Status::error(message)); - return; - } + let refuse = |state: &mut State, message: String| { + panel(state).status_message = Some(Status::error(message)); + }; + let job = match session(ctx, "delete a context") { + Ok(client) => ContextJob::Preview { + client, + top: config.account.top_context_id.clone(), + context_id: deletion.context_id, + }, + Err(message) => return refuse(ctx.state, message), }; + if !start(ctx, job, refuse) { + return; + } let communities = panel(ctx.state); communities.device_access = None; communities.status_message = None; communities.context_delete = Some(view); - spawn(ctx, async move { - let result = community_context::preview_context_deletion(&client, &top, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Previewed { context_id, result } - }); } fn confirm_deletion(ctx: &mut ActionCtx<'_>) { @@ -355,17 +506,13 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { communities.status_message = Some(Status::warning(problem)); return; } - let top = config.account.top_context_id.clone(); - let client = match claim(ctx, "delete a context") { + let refuse = |state: &mut State, message: String| { + panel(state).status_message = Some(Status::error(message)); + }; + let client = match session(ctx, "delete a context") { Ok(client) => client, - Err(message) => { - panel(ctx.state).status_message = Some(Status::error(message)); - return; - } + Err(message) => return refuse(ctx.state, message), }; - if let Some(open) = panel(ctx.state).context_delete.as_mut() { - open.phase = ContextDeletePhase::Deleting; - } let deletion = view.deletion(); let ContextDeleteView { vtc_did, @@ -373,27 +520,21 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { community, .. } = view; - let service = ctx.didcomm_service.clone(); - spawn(ctx, async move { - let report = community_context::delete_community_context(&client, &top, &deletion).await; - if report.persona_removed - && let Some(taken) = &deletion.persona - { - // The DID is gone, so the persona's listener has nothing left to - // receive — the same teardown deleting a persona does. - service - .remove_listener(&openvtc_core::didcomm::persona_listener_id(&taken.did)) - .await; - } - ContextOutcome::Deleted { - vtc_did, - persona, - community, - persona_removed: report.persona_removed, - result: report.result.map_err(|e| reason(&e)), - deletion, - } - }); + let job = ContextJob::Delete { + client, + service: ctx.didcomm_service.clone(), + top: config.account.top_context_id.clone(), + deletion, + vtc_did, + persona, + community, + }; + if !start(ctx, job, refuse) { + return; + } + if let Some(open) = panel(ctx.state).context_delete.as_mut() { + open.phase = ContextDeletePhase::Deleting; + } } /// The persona OpenVTC is acting as — the one selected, or, with no selection, @@ -441,24 +582,25 @@ fn open_devices(ctx: &mut ActionCtx<'_>, index: usize) { } fn read_grants(ctx: &mut ActionCtx<'_>, context_id: String) { - let client = match claim(ctx, "read device access") { + let client = match session(ctx, "read device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let job = ContextJob::List { + client, + context_id: context_id.clone(), + }; + if !start(ctx, job, set_view_message) { + return; + } let communities = panel(ctx.state); communities .device_grants - .entry(context_id.clone()) + .entry(context_id) .or_insert(GrantListing::Loading); if let Some(view) = communities.device_access.as_mut() { view.busy = true; } - spawn(ctx, async move { - let result = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Listed { context_id, result } - }); } fn submit_grant(ctx: &mut ActionCtx<'_>) { @@ -478,35 +620,26 @@ fn submit_grant(ctx: &mut ActionCtx<'_>) { }; let expires = community_access::expires_at(Utc::now(), form.expiry); let label = community_access::grant_label(&form.name, &view.community); - let top = ctx.config.account.top_context_id.clone(); - let client = match claim(ctx, "grant device access") { + let client = match session(ctx, "grant device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let progress = format!("Granting {}…", shorten_did(&did, 40)); + let job = ContextJob::Grant { + client, + top: ctx.config.account.top_context_id.clone(), + context_id: view.context_id, + did, + label, + expires, + }; + if !start(ctx, job, set_view_message) { + return; + } if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(Status::progress(format!( - "Granting {}…", - shorten_did(&did, 40) - ))); + open.message = Some(Status::progress(progress)); } - let context_id = view.context_id; - spawn(ctx, async move { - let result = - community_access::grant_device(&client, &top, &context_id, &did, &label, expires) - .await - .map_err(|e| reason(&e)); - // Re-read whatever the grant did, so the list is the VTA's answer. - let listing = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Granted { - context_id, - did, - result, - listing, - } - }); } fn confirm_revoke(ctx: &mut ActionCtx<'_>) { @@ -527,31 +660,23 @@ fn confirm_revoke(ctx: &mut ActionCtx<'_>) { else { return; }; - let client = match claim(ctx, "revoke device access") { + let client = match session(ctx, "revoke device access") { Ok(client) => client, Err(message) => return set_view_message(ctx.state, message), }; + let progress = format!("Revoking {}…", shorten_did(&grant.did, 40)); + let job = ContextJob::Revoke { + client, + context_id, + did: grant.did, + }; + if !start(ctx, job, set_view_message) { + return; + } if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(Status::progress(format!( - "Revoking {}…", - shorten_did(&grant.did, 40) - ))); + open.message = Some(Status::progress(progress)); } - spawn(ctx, async move { - let result = community_access::revoke_device_grant(&client, &context_id, &grant.did) - .await - .map_err(|e| reason(&e)); - let listing = community_access::list_device_grants(&client, &context_id) - .await - .map_err(|e| reason(&e)); - ContextOutcome::Revoked { - context_id, - did: grant.did, - result, - listing, - } - }); } // **************************************************************************** @@ -862,6 +987,54 @@ pub(crate) fn sweep_targets(config: &Config) -> Vec { .collect() } +/// One device-grant sweep, by [`sweep`]. +pub(crate) struct DeviceGrantSweepJob { + pub(crate) client: VtaClient, + pub(crate) contexts: Vec, +} + +impl crate::state_handler::background_dispatch::Job for DeviceGrantSweepJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + DispatchDomain::DeviceGrantSweep + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + sweep(self.client, self.contexts).await + } +} + +/// Registering the contexts memberships name at the VTA: each is read, and +/// created under `top_context_id` when missing. +pub(crate) struct ContextRegistrationJob { + pub(crate) client: VtaClient, + pub(crate) top_context_id: String, + pub(crate) contexts: Vec, +} + +impl crate::state_handler::background_dispatch::Job for ContextRegistrationJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + DispatchDomain::CommunityContexts + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + let mut results = Vec::with_capacity(self.contexts.len()); + for context_id in self.contexts { + let name = openvtc_core::config::context_path::parse_sub_context_id(&context_id) + .map_or(context_id.clone(), |(_, slug)| slug.to_string()); + let result = community_context::ensure_context( + &self.client, + &self.top_context_id, + &context_id, + &name, + ) + .await + .map_err(|e| e.to_string()); + results.push((context_id, result)); + } + DispatchOutcome::CommunityContexts(results) + } +} + /// Read the device grants of every context in `contexts`. pub(crate) async fn sweep(client: VtaClient, contexts: Vec) -> DispatchOutcome { let mut results = Vec::with_capacity(contexts.len()); diff --git a/openvtc/src/state_handler/community_profile_poll.rs b/openvtc/src/state_handler/community_profile_poll.rs index 204a9d02..23350f98 100644 --- a/openvtc/src/state_handler/community_profile_poll.rs +++ b/openvtc/src/state_handler/community_profile_poll.rs @@ -32,6 +32,7 @@ use affinidi_tdk::messaging::ATM; use openvtc_core::config::Config; use openvtc_core::config::account::{PersonaId, VtcDid}; use openvtc_core::didcomm::MessagingTransport; +use openvtc_core::pending_replies::PendingReplies; use tracing::debug; /// Cap on profile asks emitted per tick (R1.4 — bound the fan-out). @@ -145,44 +146,68 @@ impl ProfilePacer { } } -/// Send each ask, one at a time. The reply is asynchronous — it arrives on the -/// persona's listener and is applied by inbound dispatch — so nothing is awaited -/// beyond the send, and a failure is logged rather than surfaced: this is -/// background metadata the operator did not initiate, and the value only seeds a -/// form default whose absence is harmless. -pub(crate) async fn send_all(atm: ATM, asks: Vec) { - for ask in asks { - // Never sent unsigned: an ask with no key is dropped. - let Some(signer) = ask.signer.as_ref() else { - debug!(vtc = %ask.vtc_did, "no key to sign the profile ask; skipped"); - continue; - }; - let tsp_mediator = if ask.over_tsp { - openvtc_core::config::peer_tsp_mediator(&ask.vtc_did).await - } else { - None - }; - match openvtc_core::join::send_community_profile_show( - &atm, - &ask.profile, - &ask.persona_did, - signer, - &ask.vtc_did, - &ask.mediator_did, - tsp_mediator.as_deref(), - ) - .await - { - Ok(()) => debug!(vtc = %ask.vtc_did, "asked the community for its profile"), - Err(e) => debug!( - vtc = %ask.vtc_did, - error = %e, - "could not ask the community for its profile; will retry next launch" - ), +/// Open each ask's question in `pending` — here, on the loop thread that owns +/// the table, so an answer racing the send is still recognised — and return the +/// sends, one at a time, for the caller to spawn. The reply is asynchronous — it +/// arrives on the persona's listener and is applied by inbound dispatch — so +/// nothing is awaited beyond the send, and a failure is logged rather than +/// surfaced: this is background metadata the operator did not initiate, and the +/// value only seeds a form default whose absence is harmless. +pub(crate) fn send_all( + atm: ATM, + asks: Vec, + pending: &mut PendingReplies, +) -> impl std::future::Future + Send + 'static { + let opened: Vec<(Ask, String)> = asks + .into_iter() + .filter_map(|ask| { + // Never sent unsigned: an ask with no key is dropped. + if ask.signer.is_none() { + debug!(vtc = %ask.vtc_did, "no key to sign the profile ask; skipped"); + return None; + } + let document_id = openvtc_core::join::open_profile_query(pending, &ask.vtc_did); + Some((ask, document_id)) + }) + .collect(); + async move { + for (ask, document_id) in opened { + send_one(&atm, ask, document_id).await; } } } +/// Send one ask whose question is already open. +async fn send_one(atm: &ATM, ask: Ask, document_id: String) { + let Some(signer) = ask.signer.as_ref() else { + return; + }; + let tsp_mediator = if ask.over_tsp { + openvtc_core::config::peer_tsp_mediator(&ask.vtc_did).await + } else { + None + }; + match openvtc_core::join::send_community_profile_show( + atm, + &ask.profile, + &ask.persona_did, + signer, + &ask.vtc_did, + &ask.mediator_did, + tsp_mediator.as_deref(), + document_id, + ) + .await + { + Ok(()) => debug!(vtc = %ask.vtc_did, "asked the community for its profile"), + Err(e) => debug!( + vtc = %ask.vtc_did, + error = %e, + "could not ask the community for its profile; will retry next launch" + ), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/state_handler/community_request.rs b/openvtc/src/state_handler/community_request.rs new file mode 100644 index 00000000..622a862b --- /dev/null +++ b/openvtc/src/state_handler/community_request.rs @@ -0,0 +1,828 @@ +//! One request to a community at a time, and the answer it waits for — the +//! plumbing the panels that speak to a community share. +//! +//! A panel (Rooms, Elections) builds a request, and from here on it is the same +//! for each: the view is armed on the request's thread *before* the send is +//! spawned ([`CommunityLoop::spawn_send`]) — the answer travels the inbound +//! channel, not the dispatch one, and can overtake the send's own outcome; a +//! send that failed ends the wait while the view still waits on it +//! ([`send_failed`]); an answer is taken only from the community the request +//! went to, sent and issued by it, on the thread the view waits on +//! ([`apply_replies`]); a request unanswered within [`REPLY_WINDOW`] ends +//! ([`tick`]); a job that panicked ends what it was part of ([`job_lost`]); and +//! the step a view's last answer called for is taken when its lane is free +//! ([`advance`]). What differs — what a request is, what an answer means, what +//! a timeout or a failure says — is the panel's, behind [`CommunityFeature`]. +//! +//! A panel whose requests may go to a community this persona holds no +//! membership with ([`CommunityFeature::RECORDS_THREAD`]) also records the +//! thread in the runtime's [`PendingReplies`], so the inbound gate admits that +//! community's answer, and forgets it when the view stops waiting. +//! +//! The Repos panel keeps two waits (a request and a link poll) with their own +//! windows, so it uses only the shared pieces below the engine: the loop's +//! borrows ([`CommunityLoop`]), the sender ([`sender`], [`sign_and_send`]) and +//! the sender check ([`answered_by`]). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use affinidi_tdk::TDK; +use affinidi_tdk::messaging::ATM; +use affinidi_tdk::messaging::profiles::ATMProfile; +use affinidi_tdk::secrets_resolver::secrets::Secret; +use openvtc_core::capabilities::{RequestDocument, correlation_thread}; +use openvtc_core::config::Config; +use openvtc_core::config::account::PersonaId; +use openvtc_core::inbound::ThreadedReply; +use openvtc_core::pending_replies::{self, PendingReplies, ReplyKind}; +use tokio::sync::mpsc::UnboundedSender; +use vta_sdk::client::VtaClient; + +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::main_page::repos::Severity; +use crate::state_handler::runtime_actions::ActionCtx; +use crate::state_handler::state::State; + +/// How long a request waits for the community's answer (R1.2). +pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); + +/// The first letter upper-cased. +pub(crate) fn capitalise(s: &str) -> String { + let mut c = s.chars(); + c.next() + .map(|f| f.to_uppercase().chain(c).collect()) + .unwrap_or_default() +} + +/// The DID part of a DID or DID URL. +pub(crate) fn did_part(did: &str) -> &str { + did.split('#').next().unwrap_or(did) +} + +/// Whether an answer is `community`'s own: sent by it and issued by it. A +/// thread id is a correlation key, not a credential — anyone who learned one +/// could otherwise answer for the community. Compared by DID: a did:scid +/// community may name itself with the `?src=` it is resolved through +/// (VTI-KEY-163). +pub(crate) fn answered_by(community: &str, from: &str, issuer: Option<&str>) -> bool { + let is_community = |did: &str| vta_sdk::did_scid::same_reference(did_part(did), community); + is_community(from) && issuer.is_some_and(is_community) +} + +// **************************************************************************** +// The loop's borrows +// **************************************************************************** + +/// What a send, a VTA call or a reply needs from the loop, borrowed. The action +/// path ([`ActionCtx`]) and the loop's own tick, reply and outcome arms each +/// build one. +pub(crate) struct CommunityLoop<'a> { + pub(crate) state: &'a mut State, + pub(crate) config: &'a Config, + pub(crate) tdk: &'a TDK, + pub(crate) vta: Option<&'a VtaClient>, + pub(crate) dispatch_tx: &'a UnboundedSender, + pub(crate) in_flight: &'a mut InFlight, + /// The runtime's replies awaited by thread ([`CommunityFeature::RECORDS_THREAD`]). + pub(crate) pending_replies: &'a mut PendingReplies, +} + +impl<'a> CommunityLoop<'a> { + /// Borrow what the loop lent an action. + pub(crate) fn from_ctx(ctx: &'a mut ActionCtx<'_>) -> Self { + Self { + state: &mut *ctx.state, + config: ctx.config, + tdk: ctx.tdk, + vta: ctx.admin_vta, + dispatch_tx: ctx.dispatch_tx, + in_flight: &mut *ctx.in_flight, + pending_replies: &mut *ctx.pending_replies, + } + } +} + +// **************************************************************************** +// Sending +// **************************************************************************** + +/// Everything a send needs, resolved on the loop thread. +pub(crate) struct Sender { + pub(crate) atm: ATM, + pub(crate) profile: Arc, + pub(crate) persona_did: String, + pub(crate) mediator: String, + /// The membership was joined over TSP, so its requests go over TSP: the + /// community answers on the transport a request arrived on, and a persona + /// that joined over TSP may not collect a DIDComm answer at all. + pub(crate) over_tsp: bool, + pub(crate) signer: Box, +} + +/// Resolve the persona's messaging identity and signing key. The key comes +/// from the TDK secrets resolver, an in-memory store populated at startup — +/// not I/O — which is what lets the job own a plain `Secret`. +pub(crate) async fn sender( + config: &Config, + tdk: &TDK, + persona: PersonaId, + vtc_did: &str, +) -> Result { + let id = config + .identities + .get(&persona) + .ok_or_else(|| "this persona has no messaging identity".to_string())?; + let atm = tdk + .atm + .as_ref() + .ok_or_else(|| "messaging is unavailable".to_string())? + .clone(); + let keys = config + .get_persona_keys_for(persona, tdk) + .await + .map_err(|e| format!("couldn't read this persona's signing key: {e}"))?; + Ok(Sender { + atm, + profile: id.profile().clone(), + persona_did: id.persona_did().to_string(), + mediator: id.mediator_did.clone().unwrap_or_default(), + over_tsp: config + .account + .membership(vtc_did, persona) + .is_some_and(|c| c.joined_over_tsp()), + signer: Box::new(keys.authentication.secret.clone()), + }) +} + +/// Sign `doc` as the persona and send it to `vtc_did`, over TSP when the +/// membership was joined over it. +pub(crate) async fn sign_and_send( + s: &Sender, + vtc_did: &str, + doc: &mut RequestDocument, +) -> Result<(), String> { + async { + openvtc_core::capabilities::sign_document(doc, &s.signer).await?; + let tsp_mediator = + openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; + openvtc_core::capabilities::send_capability_document( + &openvtc_core::community_send::Delivery { + atm: &s.atm, + profile: &s.profile, + member_did: &s.persona_did, + vtc_did, + mediator_did: &s.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + doc, + ) + .await + } + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// Send a document the VTA signed, unchanged, on the community's transport. +async fn send_signed_document( + s: &Sender, + vtc_did: &str, + doc: &serde_json::Value, +) -> Result<(), String> { + let id = doc + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let tsp_mediator = openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; + openvtc_core::community_send::send_document( + &openvtc_core::community_send::Delivery { + atm: &s.atm, + profile: &s.profile, + member_did: &s.persona_did, + vtc_did, + mediator_did: &s.mediator, + tsp_mediator_did: tsp_mediator.as_deref(), + }, + id, + doc.clone(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// A document on its way to the community. +pub(crate) enum Doc { + /// Built here: signed as the persona before it goes. + Unsigned(Box), + /// Signed by the VTA as the member, carrying a presentation: sent exactly as it answered. + Signed(serde_json::Value), +} + +impl Doc { + /// The thread its answer carries: the document's id. + pub(crate) fn thread(&self) -> Option { + match self { + Doc::Unsigned(doc) => Some(correlation_thread(doc).to_string()), + Doc::Signed(doc) => doc + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string), + } + } +} + +/// One send, off the loop. +struct SendJob { + sender: Sender, + vtc_did: String, + persona: PersonaId, + doc: Doc, +} + +impl SendJob { + async fn run(mut self) -> SendOutcome { + let thid = self.doc.thread().unwrap_or_default(); + let result = match &mut self.doc { + Doc::Unsigned(doc) => sign_and_send(&self.sender, &self.vtc_did, doc).await, + Doc::Signed(doc) => send_signed_document(&self.sender, &self.vtc_did, doc).await, + }; + SendOutcome { + vtc_did: self.vtc_did, + persona: self.persona, + thid, + result, + } + } +} + +/// What a send did. Applied on the loop by [`send_failed`]. +pub(crate) struct SendOutcome { + /// The community it went to. + pub(crate) vtc_did: String, + pub(crate) persona: PersonaId, + /// The thread the view was armed with for it. + pub(crate) thid: String, + pub(crate) result: Result<(), String>, +} + +// **************************************************************************** +// The engine +// **************************************************************************** + +/// A request awaiting the community's answer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pending

{ + pub thid: String, + pub sent_at: Instant, + pub purpose: P, + /// The community it went to: the panel's own, or (Elections) the one + /// holding a notified election. + pub to: String, +} + +/// A panel that sends one request to a community at a time and waits for its +/// answer. Everything here is the panel's; the waiting is [this module](self)'s. +pub(crate) trait CommunityFeature: 'static { + type View; + /// What a request was for, and so what its answer means. + type Purpose: std::fmt::Debug; + /// The community's classified answer. + type Reply; + /// The step an answer calls for, taken by [`advance`]. + type Next: Clone; + + /// For log lines (`"rooms"`, `"elections"`). + const NAME: &'static str; + /// The lane its sends take. + const SEND: DispatchDomain; + /// Whether a request's thread is recorded in the runtime's + /// [`PendingReplies`] ([`ReplyKind::ElectionAnswer`]), so the inbound gate + /// admits the answer of a community this persona holds no membership with. + const RECORDS_THREAD: bool; + + fn view_mut(state: &mut State) -> Option<&mut Self::View>; + fn pending_mut(view: &mut Self::View) -> &mut Option>; + fn next_mut(view: &mut Self::View) -> &mut Option; + /// The panel's community, and the persona it speaks as. + fn community(view: &Self::View) -> (&str, PersonaId); + /// Say something on the view's status line. + fn note(view: &mut Self::View, severity: Severity, text: String); + /// Wrap a send's outcome for the dispatch channel. + fn outcome(outcome: SendOutcome) -> DispatchOutcome; + + /// A refusal, which may answer another panel's request too, so one that + /// matches nothing here is dropped without a warning. + fn is_refusal(reply: &Self::Reply) -> bool; + /// Take the answer to the request made for `purpose` (no longer pending). + fn take_reply(view: &mut Self::View, reply: Self::Reply, purpose: Self::Purpose); + /// The request made for `purpose` went unanswered for `secs` seconds. + fn timed_out(view: &mut Self::View, purpose: Self::Purpose, secs: u64); + /// The request made for `purpose` never left, for `why`. + fn failed(view: &mut Self::View, purpose: &Self::Purpose, why: String); + /// End whatever multi-step flow is in progress (a job was lost). + fn end_flow(view: &mut Self::View); + + /// Take `next`. Returns whether it was taken; one that was not (a busy + /// lane) is kept for the next tick. + async fn step(lp: &mut CommunityLoop<'_>, next: Self::Next) -> bool; + /// Anything to do before a step is taken. + fn before_step(_lp: &mut CommunityLoop<'_>) {} +} + +/// Say a request never left, where the view shows it. +pub(crate) fn fail(state: &mut State, purpose: &F::Purpose, why: String) { + if let Some(view) = F::view_mut(state) { + F::failed(view, purpose, why); + } +} + +impl CommunityLoop<'_> { + /// Arm the open view on `doc`'s thread and spawn its send to `to`. Returns + /// whether it was dispatched — or failed in a way the view now shows; `false` + /// only for a busy lane, with nothing armed. + /// + /// Armed in the same loop turn as the spawn, never from its outcome: a + /// community that answers at once could otherwise have its answer applied + /// first — to find nothing pending, be dropped, and leave the view to time + /// out on a request that was answered. + pub(crate) fn spawn_send( + &mut self, + sender: Sender, + to: String, + persona: PersonaId, + doc: Doc, + purpose: F::Purpose, + ) -> bool { + let Some(thid) = doc.thread() else { + fail::( + self.state, + &purpose, + "couldn't send: a request without an id".into(), + ); + return true; + }; + let job = FeatureSend:: { + job: SendJob { + sender, + vtc_did: to.clone(), + persona, + doc, + }, + feature: std::marker::PhantomData, + }; + if !background_dispatch::dispatch(job, self.in_flight, self.dispatch_tx, |_| {}) { + return false; + } + if F::RECORDS_THREAD { + // Its answer is admitted from this community on this thread, member or not. + self.pending_replies + .record(&thid, &to, pending_replies::Purpose::ElectionAnswer); + } + if let Some(view) = F::view_mut(self.state) { + *F::pending_mut(view) = Some(Pending { + thid, + sent_at: Instant::now(), + purpose, + to, + }); + } + true + } +} + +/// A [`SendJob`] on feature `F`'s lane, its outcome wrapped as `F`'s. +struct FeatureSend { + job: SendJob, + feature: std::marker::PhantomData F>, +} + +impl background_dispatch::Job for FeatureSend { + fn domain(&self) -> DispatchDomain { + F::SEND + } + + async fn execute(self) -> DispatchOutcome { + F::outcome(self.job.run().await) + } +} + +/// Stop waiting on `thid` at the inbound gate, for a panel that records threads. +fn forget(pending_replies: &mut PendingReplies, thid: &str) { + if F::RECORDS_THREAD { + pending_replies.forget(ReplyKind::ElectionAnswer, thid); + } +} + +/// Say the send never left — while the view still waits on this request. A +/// send that did needs nothing: the view was armed before it was spawned, and +/// its answer may already be applied. +pub(crate) fn send_failed( + state: &mut State, + pending_replies: &mut PendingReplies, + outcome: SendOutcome, +) { + let Err(e) = outcome.result else { + return; + }; + forget::(pending_replies, &outcome.thid); + let Some(view) = F::view_mut(state) else { + return; + }; + let (_, persona) = F::community(view); + let awaited = persona == outcome.persona + && F::pending_mut(view) + .as_ref() + .is_some_and(|p| p.thid == outcome.thid && p.to == outcome.vtc_did); + if !awaited { + tracing::debug!( + thid = %outcome.thid, + "{} send failed for a request no longer awaited: {e}", + F::NAME + ); + return; + } + if let Some(p) = F::pending_mut(view).take() { + F::failed( + view, + &p.purpose, + format!("couldn't reach the community: {e}"), + ); + } +} + +/// Apply the community's answers. Returns whether any was taken, so the loop +/// [`advance`]s. +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec>, +) -> bool { + let mut taken = false; + for inbound in replies { + // The community the outstanding request went to. + let Some(community) = F::view_mut(state).map(|v| { + let own = F::community(v).0.to_string(); + F::pending_mut(v).as_ref().map_or(own, |p| p.to.clone()) + }) else { + continue; + }; + if !answered_by(&community, &inbound.from, inbound.issuer.as_deref()) { + tracing::warn!( + from = %inbound.from, + thid = %inbound.thid, + "{} reply not from the community the view is asking — dropped", + F::NAME + ); + continue; + } + taken |= apply_reply::(state, pending_replies, &inbound.thid, inbound.reply); + } + taken +} + +fn apply_reply( + state: &mut State, + pending_replies: &mut PendingReplies, + thid: &str, + reply: F::Reply, +) -> bool { + let Some(view) = F::view_mut(state) else { + return false; + }; + let slot = F::pending_mut(view); + if slot.as_ref().is_none_or(|p| p.thid != thid) { + if !F::is_refusal(&reply) { + tracing::warn!(%thid, "{} reply matches no pending request — dropped", F::NAME); + } + return false; + } + let Some(pending) = slot.take() else { + return false; + }; + forget::(pending_replies, &pending.thid); + F::take_reply(view, reply, pending.purpose); + true +} + +/// The loop's periodic pass: the reply window, and any step left waiting on a +/// busy lane. +pub(crate) async fn tick(lp: &mut CommunityLoop<'_>) { + let now = Instant::now(); + if let Some(view) = F::view_mut(lp.state) { + let slot = F::pending_mut(view); + if slot + .as_ref() + .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) + && let Some(p) = slot.take() + { + forget::(lp.pending_replies, &p.thid); + F::timed_out(view, p.purpose, REPLY_WINDOW.as_secs()); + } + } + advance::(lp).await; +} + +/// Take the step the view's last answer called for, if its lane is free. The +/// loop calls this after applying replies and dispatch outcomes, and on its +/// tick. +pub(crate) async fn advance(lp: &mut CommunityLoop<'_>) { + F::before_step(lp); + let Some(next) = F::view_mut(lp.state).and_then(|v| F::next_mut(v).take()) else { + return; + }; + let taken = F::step(lp, next.clone()).await; + // A lane that was busy: keep the step for the next tick. + if !taken && let Some(view) = F::view_mut(lp.state) { + F::next_mut(view).get_or_insert(next); + } +} + +/// A job ended without an outcome (it panicked): end what it was part of. +pub(crate) fn job_lost(state: &mut State, msg: &str) { + if let Some(view) = F::view_mut(state) { + *F::pending_mut(view) = None; + *F::next_mut(view) = None; + F::end_flow(view); + F::note(view, Severity::Error, msg.to_string()); + } +} + +#[cfg(test)] +mod tests { + //! The engine, driven through the real panels: Rooms (whose answers come + //! only from a community the persona belongs to) and Elections (whose + //! threads are recorded at the inbound gate). + + use super::*; + use crate::state_handler::elections_actions::Elections; + use crate::state_handler::main_page::elections::{self as el, ElectionsView}; + use crate::state_handler::main_page::rooms::{self as rm, RoomsView}; + use crate::state_handler::rooms_actions::Rooms; + use openvtc_core::community_rooms::{Refusal, Reply}; + + const VTC: &str = "did:key:zVtc"; + + fn rooms_state() -> State { + let mut state = State::default(); + state.main_page.content_panel.rooms.view = Some(RoomsView::new( + VTC.into(), + PersonaId::default(), + "did:key:zAlice".into(), + "Community".into(), + )); + state + } + + fn rooms(state: &mut State) -> &mut RoomsView { + Rooms::view_mut(state).unwrap() + } + + fn arm_rooms(state: &mut State, thid: &str, sent_at: Instant) { + rooms(state).pending = Some(Pending { + thid: thid.into(), + sent_at, + purpose: rm::Purpose::Renewals, + to: VTC.into(), + }); + } + + fn refused(thid: &str, from: &str) -> ThreadedReply { + ThreadedReply { + from: from.into(), + issuer: Some(from.into()), + thid: thid.into(), + reply: Reply::Refused(Refusal { + code: "vtc/rooms/offers/list:unavailable".into(), + message: None, + }), + } + } + + fn outcome(thid: &str, persona: PersonaId, result: Result<(), String>) -> SendOutcome { + SendOutcome { + vtc_did: VTC.into(), + persona, + thid: thid.into(), + result, + } + } + + fn status(state: &mut State) -> String { + rooms(state) + .status + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default() + } + + #[test] + fn capitalise_upper_cases_only_the_first_letter() { + assert_eq!(capitalise("no reply"), "No reply"); + assert_eq!(capitalise("éclair"), "Éclair"); + assert_eq!(capitalise(""), ""); + } + + /// An answer is the community's only when it both sent and issued it, + /// compared by DID — a fragment or a did:scid `?src=` makes no difference. + #[test] + fn an_answer_is_the_communitys_only_when_sent_and_issued_by_it() { + assert!(answered_by(VTC, VTC, Some(&format!("{VTC}#key-1")))); + assert!(!answered_by(VTC, VTC, None)); + assert!(!answered_by(VTC, "did:key:zMallory", Some(VTC))); + assert!(!answered_by(VTC, VTC, Some("did:key:zMallory"))); + let scid = "did:scid:vh:1:QmScid"; + assert!(answered_by( + scid, + &format!("{scid}?src=vtc.example"), + Some(scid) + )); + } + + /// A VTA-signed document threads on its `id`; one without an id has no + /// thread to wait on. + #[test] + fn a_signed_document_threads_on_its_id() { + let doc = Doc::Signed(serde_json::json!({ "id": "urn:uuid:x" })); + assert_eq!(doc.thread().as_deref(), Some("urn:uuid:x")); + assert_eq!(Doc::Signed(serde_json::json!({ "id": "" })).thread(), None); + assert_eq!(Doc::Signed(serde_json::json!({})).thread(), None); + } + + /// The answer on the thread the view waits on, from the community asked, + /// is taken once; one from anyone else, or on another thread, is dropped + /// and the wait goes on. + #[test] + fn an_answer_is_taken_once_and_only_on_its_thread_from_its_community() { + let mut state = rooms_state(); + let mut table = PendingReplies::default(); + arm_rooms(&mut state, "t1", Instant::now()); + + assert!(!apply_replies::( + &mut state, + &mut table, + vec![refused("t1", "did:key:zMallory")] + )); + assert!(!apply_replies::( + &mut state, + &mut table, + vec![refused("t2", VTC)] + )); + assert!(rooms(&mut state).pending.is_some(), "still waiting"); + + assert!(apply_replies::( + &mut state, + &mut table, + vec![refused("t1", VTC)] + )); + assert!(rooms(&mut state).pending.is_none()); + assert!( + !apply_replies::(&mut state, &mut table, vec![refused("t1", VTC)]), + "a second answer on the thread matches nothing" + ); + } + + /// A send's failure ends only the wait it belongs to: not a newer request's, + /// not another persona's — and it says why where the view shows it. + #[test] + fn a_failed_send_ends_only_its_own_wait() { + let mut state = rooms_state(); + let mut table = PendingReplies::default(); + let persona = rooms(&mut state).persona; + arm_rooms(&mut state, "t2", Instant::now()); + + send_failed::( + &mut state, + &mut table, + outcome("t1", persona, Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_some(), "a stale failure"); + send_failed::( + &mut state, + &mut table, + outcome("t2", PersonaId::new(), Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_some(), "another persona's"); + send_failed::(&mut state, &mut table, outcome("t2", persona, Ok(()))); + assert!(rooms(&mut state).pending.is_some(), "a send that left"); + + send_failed::( + &mut state, + &mut table, + outcome("t2", persona, Err("down".into())), + ); + assert!(rooms(&mut state).pending.is_none()); + } + + /// A job that panicked ends the wait, the flow and the next step, and says so. + #[test] + fn a_lost_job_ends_what_it_was_part_of() { + let mut state = rooms_state(); + arm_rooms(&mut state, "t1", Instant::now()); + rooms(&mut state).next = Some(rm::Next::Refresh); + rooms(&mut state).accept = Some(rm::AcceptFlow { + room: "did:key:zR1".into(), + step: rm::AcceptStep::Collecting, + offer: None, + }); + job_lost::(&mut state, "Data room request failed (internal error)"); + let view = rooms(&mut state); + assert!(view.pending.is_none() && view.next.is_none() && view.accept.is_none()); + assert_eq!( + view.status.as_ref().unwrap().severity, + crate::state_handler::main_page::status::Severity::Error + ); + } + + /// Ticks inside the window leave the wait; the first past it ends it, says + /// so in the panel's words, and — for a panel whose threads the gate admits + /// — stops the gate admitting an answer on it. A step whose lane is busy is + /// kept for the next tick. + #[tokio::test] + async fn the_window_closes_a_wait_and_a_busy_step_is_kept() { + let config = crate::state_handler::dispatch_util::test_config(); + let tdk = crate::state_handler::dispatch_util::test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut in_flight = InFlight::default(); + let mut table = PendingReplies::default(); + + // Rooms: inside the window, then past it. + let mut state = rooms_state(); + arm_rooms(&mut state, "t1", Instant::now()); + // The lane is busy, so the refresh it calls for waits. + assert!(in_flight.try_begin(DispatchDomain::Rooms)); + rooms(&mut state).next = Some(rm::Next::Refresh); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(rooms(&mut state).pending.is_some()); + assert_eq!(rooms(&mut state).next, Some(rm::Next::Refresh), "kept"); + + arm_rooms( + &mut state, + "t1", + Instant::now() - REPLY_WINDOW - Duration::from_secs(1), + ); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(rooms(&mut state).pending.is_none()); + assert!(status(&mut state).contains("within 30s")); + + // Elections: the gate stops admitting the thread too. + let mut state = State::default(); + state.main_page.content_panel.elections.view = Some(ElectionsView::new( + VTC.into(), + PersonaId::default(), + "did:key:zAlice".into(), + "Community".into(), + )); + Elections::view_mut(&mut state).unwrap().pending = Some(Pending { + thid: "e1".into(), + sent_at: Instant::now() - REPLY_WINDOW - Duration::from_secs(1), + purpose: el::Purpose::Issue { + election: "agm".into(), + }, + to: "did:key:zHolder".into(), + }); + table.record( + "e1", + "did:key:zHolder", + pending_replies::Purpose::ElectionAnswer, + ); + { + let mut lp = CommunityLoop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut table, + }; + tick::(&mut lp).await; + } + assert!(Elections::view_mut(&mut state).unwrap().pending.is_none()); + assert!(!table.is_awaited(ReplyKind::ElectionAnswer, "did:key:zHolder", "e1")); + } +} diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index c33b1695..e4e9c059 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -6,7 +6,7 @@ //! that VIC on the clean join-as-subject path (the VIC subject is one of our own //! personas, so no subject-linkage proof is needed). //! -//! This is the join flow's mint sub-sequence (`join_flow::run_join_sequence`) +//! This is the join launch's mint stage (`join_flow::driver`) //! minus the community/submit parts: pick a WebVH server, mint the DID via the //! VTA, then persist through the shared [`ConfigExtension::mint_persona_into`]. //! The minted persona is an orphan (no community) until a join reuses it, and @@ -261,13 +261,12 @@ impl MintedPersona { /// Write the persona into the config, recording the context it was minted /// in as its origin. Runs on the loop thread, from whichever loop is live — /// a first persona is minted in State A. - pub(crate) async fn persist( - &self, - config: &mut Config, - tdk: &TDK, - profile: &str, - ) -> Result { - let persona_id = Config::mint_persona_into(config, &self.setup, tdk, profile).await?; + /// + /// In memory only: the caller persists it, the TUI loops through + /// [`SaveScheduler::flush`](crate::state_handler::save_coalesce::SaveScheduler::flush) + /// (the one persistence path), the headless join with its own save. + pub(crate) async fn write_into(&self, config: &mut Config, tdk: &TDK) -> Result { + let persona_id = Config::mint_persona_in_memory(config, &self.setup, tdk).await?; if let Some(record) = config.account.personas.get_mut(&persona_id) { record.origin_context_id = self.context_id.clone(); } @@ -296,6 +295,18 @@ pub(crate) struct MintJob { >, } +impl crate::state_handler::background_dispatch::Job for MintJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Persona + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Persona(Box::new( + self.run().await, + )) + } +} + impl MintJob { /// Run the mint, streaming each step. I/O only — the persist is the loop's. pub(crate) async fn run(self) -> MintOutcome { diff --git a/openvtc/src/state_handler/dispatch_util.rs b/openvtc/src/state_handler/dispatch_util.rs index 774d058b..832ed1f9 100644 --- a/openvtc/src/state_handler/dispatch_util.rs +++ b/openvtc/src/state_handler/dispatch_util.rs @@ -5,7 +5,7 @@ //! inline across the inbox, relationship, credential, and settings handlers: //! //! 1. set the panel's `status_message`, -//! 2. persist the config (`save_config`, logging on failure), +//! 2. persist the config (now `SaveScheduler::mark_dirty`), //! 3. rebuild UI state from config (`sync_from_config`), //! 4. push an activity-log entry (`log` or `log_detailed`). //! diff --git a/openvtc/src/state_handler/elections_actions.rs b/openvtc/src/state_handler/elections_actions.rs index da4884bd..6c5284ed 100644 --- a/openvtc/src/state_handler/elections_actions.rs +++ b/openvtc/src/state_handler/elections_actions.rs @@ -26,10 +26,8 @@ //! its first answer, so an interrupted ticket or vote is finished by starting it again; a cast //! whose answer was lost is resent **unchanged** (`r` on the result), never rebuilt. -use std::time::{Duration, Instant}; +use std::time::Instant; -use affinidi_tdk::TDK; -use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; use openvtc_core::elections::{ @@ -37,16 +35,18 @@ use openvtc_core::elections::{ Voter, }; use openvtc_core::notifications::NotifiedElection; -use tokio::sync::mpsc::UnboundedSender; -use vta_sdk::client::VtaClient; +use openvtc_core::pending_replies::PendingReplies; use crate::state_handler::actions::ElectionsAction as Act; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +use crate::state_handler::community_request::{ + self, CommunityFeature, Doc, SendOutcome, capitalise, fail, sender, +}; use crate::state_handler::main_page::elections::{ BallotForm, Disclose, ElectionsPhase, ElectionsView, Flow, Intent, Next, NotifiedPhase, - Pending, Purpose, Row, Screen, Severity, Step, VoteResult, + Purpose, Row, Screen, Severity, Step, VoteResult, }; -use crate::state_handler::repos_actions::{Sender, sender}; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; @@ -54,17 +54,10 @@ const SEND: DispatchDomain = DispatchDomain::Elections; const VTA: DispatchDomain = DispatchDomain::ElectionsVta; const CAST: DispatchDomain = DispatchDomain::ElectionsCast; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); - /// A `vtc/elections/*` answer as it arrived, threaded on the request it answers /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - fn view_mut(state: &mut State) -> Option<&mut ElectionsView> { state.main_page.content_panel.elections.view.as_mut() } @@ -177,264 +170,267 @@ fn review(view: &mut ElectionsView) { } } -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().collect::() + c.as_str()) - .unwrap_or_default() -} - // **************************************************************************** // The loop's side // **************************************************************************** -/// What a send or a VTA call needs from the loop. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) vta: Option<&'a VtaClient>, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, -} +/// The Elections panel, on the shared request/reply plumbing +/// ([`community_request`]). +pub(crate) struct Elections; -impl Loop<'_> { - /// Send `request` to the open view's community, armed on `purpose`. Returns whether it was - /// dispatched; every early return says why in the view. - async fn send(&mut self, request: Request, purpose: Purpose) -> bool { - // The list is always the panel's own community's; one election is read from where it - // is held. - let to_target = !matches!(request, Request::List { .. }); - self.dispatch(Outgoing::Build(request), purpose, to_target) - .await - } +impl CommunityFeature for Elections { + type View = ElectionsView; + type Purpose = Purpose; + type Reply = Reply; + type Next = Next; + const NAME: &'static str = "elections"; + const SEND: DispatchDomain = SEND; + /// A notified election is read from the community holding it, which may be one this + /// persona is not a member of (VTI-NTF-063): its answer is admitted only on a thread + /// recorded for it. + const RECORDS_THREAD: bool = true; - /// Carry a document the VTA signed to the view's target community, unchanged. - async fn send_signed(&mut self, document: serde_json::Value, purpose: Purpose) -> bool { - self.dispatch(Outgoing::Signed(document), purpose, true) - .await + fn view_mut(state: &mut State) -> Option<&mut ElectionsView> { + view_mut(state) + } + fn pending_mut(view: &mut ElectionsView) -> &mut Option> { + &mut view.pending + } + fn next_mut(view: &mut ElectionsView) -> &mut Option { + &mut view.next + } + fn community(view: &ElectionsView) -> (&str, PersonaId) { + (&view.vtc_did, view.persona) + } + fn note(view: &mut ElectionsView, severity: Severity, text: String) { + view.note(severity, text); + } + fn outcome(outcome: SendOutcome) -> DispatchOutcome { + DispatchOutcome::Elections(outcome) + } + fn is_refusal(reply: &Reply) -> bool { + matches!(reply, Reply::Refused(_)) } + fn take_reply(view: &mut ElectionsView, reply: Reply, purpose: Purpose) { + take_reply(view, reply, purpose); + } + fn timed_out(view: &mut ElectionsView, purpose: Purpose, secs: u64) { + timed_out(view, purpose, secs); + } + fn failed(view: &mut ElectionsView, purpose: &Purpose, why: String) { + failed(view, purpose, why); + } + fn end_flow(view: &mut ElectionsView) { + view.flow = None; + } + async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + step(lp, next).await + } + fn before_step(lp: &mut Loop<'_>) { + ask_notified(lp); + } +} - async fn dispatch(&mut self, outgoing: Outgoing, purpose: Purpose, to_target: bool) -> bool { - let Some((vtc_did, target, persona, waiting)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .map(|v| { - ( - v.vtc_did.clone(), - v.target.clone(), - v.persona, - v.pending.is_some(), - ) - }) - else { - return false; - }; - if waiting { - return false; +/// Send `request` to the open view's community, armed on `purpose`. Returns whether it was +/// dispatched; every early return says why in the view. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose) -> bool { + // The list is always the panel's own community's; one election is read from where it + // is held. + let to_target = !matches!(request, Request::List { .. }); + send_to(lp, Outgoing::Build(request), purpose, to_target).await +} + +/// Carry a document the VTA signed to the view's target community, unchanged. +async fn send_signed(lp: &mut Loop<'_>, document: serde_json::Value, purpose: Purpose) -> bool { + send_to(lp, Outgoing::Signed(document), purpose, true).await +} + +async fn send_to(lp: &mut Loop<'_>, outgoing: Outgoing, purpose: Purpose, to_target: bool) -> bool { + let Some((vtc_did, target, persona, waiting)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .map(|v| { + ( + v.vtc_did.clone(), + v.target.clone(), + v.persona, + v.pending.is_some(), + ) + }) + else { + return false; + }; + if waiting { + return false; + } + let to = if to_target { target } else { vtc_did }; + let mut sender = match sender(lp.config, lp.tdk, persona, &to).await { + Ok(s) => s, + Err(e) => { + fail::(lp.state, &purpose, e); + return true; } - let to = if to_target { target } else { vtc_did }; - let mut sender = match sender(self.config, self.tdk, persona, &to).await { - Ok(s) => s, + }; + // A community this persona is not a member of (one holding a notified election) has + // no membership transport to follow: TSP when the persona speaks it anywhere, so the + // answer comes back on the socket TSP answers arrive on; DIDComm otherwise. + if lp.config.account.membership(&to, persona).is_none() { + sender.over_tsp = lp + .config + .account + .memberships() + .any(|c| c.persona_ref == persona && c.joined_over_tsp()); + } + let doc = match outgoing { + Outgoing::Build(request) => match elections::build(&request, &sender.persona_did, &to) { + Ok(doc) => Doc::Unsigned(Box::new(doc)), Err(e) => { - fail(self.state, &purpose, e); + fail::(lp.state, &purpose, format!("couldn't send: {e}")); return true; } - }; - // A community this persona is not a member of (one holding a notified election) has - // no membership transport to follow: TSP when the persona speaks it anywhere, so the - // answer comes back on the socket TSP answers arrive on; DIDComm otherwise. - if self.config.account.membership(&to, persona).is_none() { - sender.over_tsp = self - .config - .account - .memberships() - .any(|c| c.persona_ref == persona && c.joined_over_tsp()); - } - let doc = match outgoing { - Outgoing::Build(request) => { - match elections::build(&request, &sender.persona_did, &to) { - Ok(doc) => Doc::Unsigned(Box::new(doc)), - Err(e) => { - fail(self.state, &purpose, format!("couldn't send: {e}")); - return true; - } - } - } - Outgoing::Signed(document) => { - let addressed = document - .get("recipient") - .and_then(serde_json::Value::as_str); - if !addressed.is_some_and(|r| vta_sdk::did_scid::same_reference(r, &to)) { - fail( - self.state, - &purpose, - "your VTA signed the request for another community than this one".into(), - ); - return true; - } - Doc::Signed(document) + }, + Outgoing::Signed(document) => { + let addressed = document + .get("recipient") + .and_then(serde_json::Value::as_str); + if !addressed.is_some_and(|r| vta_sdk::did_scid::same_reference(r, &to)) { + fail::( + lp.state, + &purpose, + "your VTA signed the request for another community than this one".into(), + ); + return true; } - }; - let Some(thid) = doc.thread() else { - fail( - self.state, - &purpose, - "couldn't send: a request without an id".into(), - ); - return true; - }; - if !self.in_flight.try_begin(SEND) { - return false; + Doc::Signed(document) } - // Its answer is admitted from this community on this thread, member or not. - elections::expect_answer(&to, &thid); - if let Some(view) = view_mut(self.state) { - view.pending = Some(Pending { - thid, - sent_at: Instant::now(), - purpose, - to: to.clone(), - }); - } - let job = SendJob { - sender, - vtc_did: to, - persona, - doc, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), SEND, async move { - DispatchOutcome::Elections(job.run().await) - }); - true - } + }; + lp.spawn_send::(sender, to, persona, doc, purpose) +} - /// Ask the VTA. Returns whether it was dispatched (or failed in a way the view now shows). - fn ask_vta(&mut self, ask: VtaAsk) -> bool { - let Some((vtc_did, target, persona)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.target.clone(), v.persona)) - else { - return true; - }; - let Some(vta) = self.vta.cloned() else { - end_flow( - self.state, - Severity::Error, - "Voting needs your VTA: it holds the voter keys and the ticket. None is \ - connected.", - ); - return true; - }; - let Some(context_id) = persona_context(self.config, persona) else { - end_flow( - self.state, - Severity::Error, - "This persona has no VTA context to hold its voter keys.", - ); - return true; - }; - if !self.in_flight.try_begin(VTA) { - return false; - } - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), VTA, async move { - // The registrar is the community holding the election. - let voter = Voter { - vta: &vta, - context_id: &context_id, - community: &target, - }; - let answer = ask.run(&voter).await; - DispatchOutcome::ElectionsVta(ElectionsVtaOutcome { - vtc_did, - persona, - answer, - }) - }); - true +/// Ask the VTA. Returns whether it was dispatched (or failed in a way the view now shows). +fn ask_vta(lp: &mut Loop<'_>, ask: VtaAsk) -> bool { + let Some((vtc_did, target, persona)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.target.clone(), v.persona)) + else { + return true; + }; + let Some(vta) = lp.vta.cloned() else { + end_flow( + lp.state, + Severity::Error, + "Voting needs your VTA: it holds the voter keys and the ticket. None is \ + connected.", + ); + return true; + }; + let Some(context_id) = persona_context(lp.config, persona) else { + end_flow( + lp.state, + Severity::Error, + "This persona has no VTA context to hold its voter keys.", + ); + return true; + }; + if !lp.in_flight.try_begin(VTA) { + return false; } - - /// Send the cast to the ballot box, after a random pause. - fn deliver(&mut self, cast: Cast) -> bool { - let Some((vtc_did, persona, ballot_box)) = self - .state - .main_page - .content_panel - .elections - .view - .as_ref() - .and_then(|v| { - v.shown - .as_ref() - .map(|s| (v.vtc_did.clone(), v.persona, s.ballot_box().to_string())) - }) - else { - return true; + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), VTA, async move { + // The registrar is the community holding the election. + let voter = Voter { + vta: &vta, + context_id: &context_id, + community: &target, }; - if !self.in_flight.try_begin(CAST) { - return false; - } - let resolver = self.tdk.did_resolver().clone(); - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), CAST, async move { - let result = - elections::deliver_cast(&resolver, &ballot_box, &cast, elections::cast_delay()) - .await; - DispatchOutcome::ElectionsCast(Box::new(ElectionsCastOutcome { - vtc_did, - persona, - cast, - result, - })) - }); - true + let answer = ask.run(&voter).await; + DispatchOutcome::ElectionsVta(ElectionsVtaOutcome { + vtc_did, + persona, + answer, + }) + }); + true +} + +/// Send the cast to the ballot box, after a random pause. +fn deliver(lp: &mut Loop<'_>, cast: Cast) -> bool { + let Some((vtc_did, persona, ballot_box)) = lp + .state + .main_page + .content_panel + .elections + .view + .as_ref() + .and_then(|v| { + v.shown + .as_ref() + .map(|s| (v.vtc_did.clone(), v.persona, s.ballot_box().to_string())) + }) + else { + return true; + }; + if !lp.in_flight.try_begin(CAST) { + return false; } + let resolver = lp.tdk.did_resolver().clone(); + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), CAST, async move { + let result = + elections::deliver_cast(&resolver, &ballot_box, &cast, elections::cast_delay()).await; + DispatchOutcome::ElectionsCast(Box::new(ElectionsCastOutcome { + vtc_did, + persona, + cast, + result, + })) + }); + true +} - /// Read the list again, and the VTA's held election notifications beside it. - pub(crate) async fn refresh(&mut self) { - match view_mut(self.state) { - Some(view) => { - view.restart_pages(); - view.notified_due = true; - } - None => return, +/// Read the list again, and the VTA's held election notifications beside it. +pub(crate) async fn refresh(lp: &mut Loop<'_>) { + match view_mut(lp.state) { + Some(view) => { + view.restart_pages(); + view.notified_due = true; } - self.send(Request::List { cursor: None }, Purpose::List) - .await; + None => return, } + send(lp, Request::List { cursor: None }, Purpose::List).await; +} - /// Read the open election again — presenting the membership the member chose to show for - /// it, and nothing else (VTI-VOT-016). Returns whether it was dispatched. - async fn read_election(&mut self, election: String, intent: Intent) -> bool { - let presenting = view_mut(self.state).and_then(|v| v.presenting.clone()); - match presenting { - Some(member_of) => { - if let Some(view) = view_mut(self.state) { - view.next = Some(Next::ReadPresenting { - election, - member_of, - intent, - }); - } - true - } - None => { - self.send( - Request::Show { - election_id: election.clone(), - }, - Purpose::Show { election, intent }, - ) - .await +/// Read the open election again — presenting the membership the member chose to show for +/// it, and nothing else (VTI-VOT-016). Returns whether it was dispatched. +async fn read_election(lp: &mut Loop<'_>, election: String, intent: Intent) -> bool { + let presenting = view_mut(lp.state).and_then(|v| v.presenting.clone()); + match presenting { + Some(member_of) => { + if let Some(view) = view_mut(lp.state) { + view.next = Some(Next::ReadPresenting { + election, + member_of, + intent, + }); } + true + } + None => { + send( + lp, + Request::Show { + election_id: election.clone(), + }, + Purpose::Show { election, intent }, + ) + .await } } } @@ -465,10 +461,7 @@ fn end_flow(state: &mut State, severity: Severity, why: impl Into) { } /// Say a request never left, where the view shows it. -fn fail(state: &mut State, purpose: &Purpose, why: String) { - let Some(view) = view_mut(state) else { - return; - }; +fn failed(view: &mut ElectionsView, purpose: &Purpose, why: String) { match purpose { Purpose::List if view.elections.is_empty() => view.phase = ElectionsPhase::Failed(why), Purpose::List | Purpose::Show { .. } | Purpose::Issue { .. } => { @@ -483,20 +476,13 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { if let Act::Open(index) = action { open(ctx, index); } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - vta: ctx.admin_vta, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) => { if let Some(view) = view_mut(lp.state) { view.phase = ElectionsPhase::Loading; } - lp.refresh().await; + refresh(&mut lp).await; } Act::Refresh => refresh_screen(&mut lp).await, Act::Show => show_selected(&mut lp).await, @@ -530,12 +516,12 @@ async fn refresh_screen(lp: &mut Loop<'_>) { view.shown.as_ref().map(|s| s.election_id().to_string()), ) { (Screen::Detail, Some(election)) => { - let sent = lp.read_election(election, Intent::View).await; + let sent = read_election(lp, election, Intent::View).await; if !sent && let Some(view) = view_mut(lp.state) { view.note(Severity::Warning, InFlight::busy_message(SEND)); } } - (Screen::List, _) => lp.refresh().await, + (Screen::List, _) => refresh(lp).await, // The first read of this election never arrived: read it again. (Screen::Detail, None) => { view.screen = Screen::List; @@ -583,17 +569,17 @@ async fn show_selected(lp: &mut Loop<'_>) { view.shown = None; view.status = None; view.note(Severity::Progress, "Reading the election…"); - let sent = lp - .send( - Request::Show { - election_id: election.clone(), - }, - Purpose::Show { - election, - intent: Intent::View, - }, - ) - .await; + let sent = send( + lp, + Request::Show { + election_id: election.clone(), + }, + Purpose::Show { + election, + intent: Intent::View, + }, + ) + .await; if !sent && let Some(view) = view_mut(lp.state) { view.screen = Screen::List; view.note(Severity::Warning, InFlight::busy_message(SEND)); @@ -629,7 +615,7 @@ async fn start_flow(lp: &mut Loop<'_>, intent: Intent) { step: Step::Reading, }); view.note(Severity::Progress, "Reading the election again…"); - let sent = lp.read_election(election, intent).await; + let sent = read_election(lp, election, intent).await; if !sent && let Some(view) = view_mut(lp.state) { view.flow = None; view.note(Severity::Warning, InFlight::busy_message(SEND)); @@ -756,20 +742,21 @@ fn open(ctx: &mut ActionCtx<'_>, index: usize) { } /// Take the step the view's last answer called for, if its lane is free. The loop calls this -/// after applying replies and dispatch outcomes, and on its tick. +/// after applying replies and outcomes, and on its tick. pub(crate) async fn advance(lp: &mut Loop<'_>) { - ask_notified(lp); - let Some(next) = view_mut(lp.state).and_then(|v| v.next.take()) else { - return; - }; + community_request::advance::(lp).await; +} + +/// Take one step. Returns whether it was taken (a busy lane keeps it). +async fn step(lp: &mut Loop<'_>, next: Next) -> bool { let shown = view_mut(lp.state).and_then(|v| v.shown.clone()); - let taken = match next.clone() { + match next { Next::Refresh => { if lp.in_flight.is_busy(SEND) || view_mut(lp.state).is_some_and(|v| v.pending.is_some()) { false } else { - lp.refresh().await; + refresh(lp).await; true } } @@ -779,30 +766,31 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { intent, } => { let member = view_mut(lp.state).map(|v| v.me.clone()).unwrap_or_default(); - lp.ask_vta(VtaAsk::ReadPresenting { - member, - election, - member_of, - intent, - }) + ask_vta( + lp, + VtaAsk::ReadPresenting { + member, + election, + member_of, + intent, + }, + ) } Next::SendShowDocument { document, election, intent, - } => { - lp.send_signed(document, Purpose::Show { election, intent }) - .await - } + } => send_signed(lp, document, Purpose::Show { election, intent }).await, Next::SendIssueDocument(document) => { let election = shown .as_ref() .map(|s| s.election_id().to_string()) .unwrap_or_default(); - lp.send_signed(document, Purpose::Issue { election }).await + send_signed(lp, document, Purpose::Issue { election }).await } Next::ListPage { cursor } => { - lp.send( + send( + lp, Request::List { cursor: Some(cursor), }, @@ -813,35 +801,38 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { Next::Register => match shown { Some(shown) => { let member = view_mut(lp.state).map(|v| v.me.clone()).unwrap_or_default(); - lp.ask_vta(VtaAsk::Register { shown, member }) + ask_vta(lp, VtaAsk::Register { shown, member }) } None => true, }, Next::SendIssue(ticket) => { let election = ticket.election_id.clone(); - lp.send(Request::IssueTicket(ticket), Purpose::Issue { election }) - .await + send( + lp, + Request::IssueTicket(ticket), + Purpose::Issue { election }, + ) + .await } Next::Accept { issued } => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Accept { shown, issued }), + Some(shown) => ask_vta(lp, VtaAsk::Accept { shown, issued }), None => true, }, Next::Vote { ballot } => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Vote { shown, ballot }), + Some(shown) => ask_vta(lp, VtaAsk::Vote { shown, ballot }), None => true, }, - Next::Deliver(cast) => lp.deliver(cast), + Next::Deliver(cast) => deliver(lp, cast), Next::Keep(receipt) => match shown { - Some(shown) => lp.ask_vta(VtaAsk::Keep { - election: shown.election_id().to_string(), - receipt, - }), + Some(shown) => ask_vta( + lp, + VtaAsk::Keep { + election: shown.election_id().to_string(), + receipt, + }, + ), None => true, }, - }; - // A lane that was busy: keep the step for the next tick. - if !taken && let Some(view) = view_mut(lp.state) { - view.next.get_or_insert(next); } } @@ -863,7 +854,7 @@ fn ask_notified(lp: &mut Loop<'_>) { if lp.in_flight.is_busy(VTA) { return; } - if lp.ask_vta(VtaAsk::Notified) + if ask_vta(lp, VtaAsk::Notified) && let Some(view) = view_mut(lp.state) { view.notified_due = false; @@ -872,221 +863,60 @@ fn ask_notified(lp: &mut Loop<'_>) { /// The loop's periodic pass: reply windows, and any step left waiting on a busy lane. pub(crate) async fn tick(lp: &mut Loop<'_>) { - let now = Instant::now(); - if let Some(view) = view_mut(lp.state) - && view - .pending - .as_ref() - .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) - && let Some(p) = view.pending.take() - { - elections::answered(&p.thid); - let secs = REPLY_WINDOW.as_secs(); - match p.purpose { - Purpose::List if view.elections.is_empty() => { - // A community that does not serve elections still answers, with a refusal - // shown in words, so silence means the request or its answer was lost (R6.4). - view.phase = ElectionsPhase::Failed(format!( - "no reply within {secs}s. A community that does not serve elections says \ + community_request::tick::(lp).await; +} + +/// The request made for `purpose` went unanswered for `secs` seconds. +fn timed_out(view: &mut ElectionsView, purpose: Purpose, secs: u64) { + match purpose { + Purpose::List if view.elections.is_empty() => { + // A community that does not serve elections still answers, with a refusal + // shown in words, so silence means the request or its answer was lost (R6.4). + view.phase = ElectionsPhase::Failed(format!( + "no reply within {secs}s. A community that does not serve elections says \ so, so this is not that: the request or its answer was lost — the \ community's VTC is offline or unreachable, or it answered on a route this \ persona does not collect. Press r to try again." - )); - } - Purpose::List | Purpose::Show { .. } => { - view.flow = None; - view.note( - Severity::Error, - format!("No reply from the community within {secs}s. Press r to try again."), - ); - } - Purpose::Issue { .. } => { - view.flow = None; - view.note( - Severity::Error, - format!( - "No reply to the ticket request within {secs}s. Taking the ticket again \ + )); + } + Purpose::List | Purpose::Show { .. } => { + view.flow = None; + view.note( + Severity::Error, + format!("No reply from the community within {secs}s. Press r to try again."), + ); + } + Purpose::Issue { .. } => { + view.flow = None; + view.note( + Severity::Error, + format!( + "No reply to the ticket request within {secs}s. Taking the ticket again \ is safe: your VTA repeats the same request, and the community answers \ it as before." - ), - ); - } + ), + ); } } - advance(lp).await; } /// A job ended without an outcome (it panicked): end what it was part of. pub(crate) fn job_lost(state: &mut State, msg: &str) { - if let Some(view) = view_mut(state) { - view.pending = None; - view.flow = None; - view.next = None; - view.note(Severity::Error, msg.to_string()); - } -} - -// **************************************************************************** -// Sending to the community -// **************************************************************************** - -/// One `vtc/elections/*` send, off the loop. -pub(crate) struct SendJob { - sender: Sender, - vtc_did: String, - persona: PersonaId, - doc: Doc, -} - -/// A document on its way to the community. -enum Doc { - /// Built here: signed as the persona before it goes. - Unsigned(Box), - /// Signed by the VTA as the member, carrying a presentation: sent exactly as it answered. - Signed(serde_json::Value), -} - -impl Doc { - /// The thread its answer carries: the document's id. - fn thread(&self) -> Option { - match self { - Doc::Unsigned(doc) => Some(correlation_thread(doc).to_string()), - Doc::Signed(doc) => doc - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string), - } - } -} - -impl SendJob { - async fn run(mut self) -> ElectionsOutcome { - let thid = self.doc.thread().unwrap_or_default(); - let result = match &mut self.doc { - Doc::Unsigned(doc) => { - crate::state_handler::repos_actions::sign_and_send(&self.sender, &self.vtc_did, doc) - .await - } - Doc::Signed(doc) => send_signed_document(&self.sender, &self.vtc_did, doc).await, - }; - ElectionsOutcome { - vtc_did: self.vtc_did, - persona: self.persona, - thid, - result, - } - } -} - -/// Send a document the VTA signed, unchanged, on the community's transport. -async fn send_signed_document( - s: &Sender, - vtc_did: &str, - doc: &serde_json::Value, -) -> Result<(), String> { - let id = doc - .get("id") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - let tsp_mediator = openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; - openvtc_core::community_send::send_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - id, - doc.clone(), - ) - .await - .map_err(|e| e.to_string()) -} - -/// What a send did. Applied on the loop. -pub(crate) struct ElectionsOutcome { - vtc_did: String, - persona: PersonaId, - thid: String, - result: Result<(), String>, -} - -impl ElectionsOutcome { - /// Say the send never left — while the view still waits on this request. - pub(crate) fn apply(self, state: &mut State) { - let Err(e) = self.result else { - return; - }; - elections::answered(&self.thid); - let Some(view) = view_mut(state) else { - return; - }; - if view.pending.as_ref().is_none_or(|p| p.to != self.vtc_did) - || view.persona != self.persona - || view.pending.as_ref().is_none_or(|p| p.thid != self.thid) - { - tracing::debug!(thid = %self.thid, "elections send failed for a request no longer awaited: {e}"); - return; - } - if let Some(p) = view.pending.take() { - fail( - state, - &p.purpose, - format!("couldn't reach the community: {e}"), - ); - } - } + community_request::job_lost::(state, msg); } /// Apply the community's answers. Returns whether any was taken, so the loop [`advance`]s. -pub(crate) fn apply_replies(state: &mut State, replies: Vec) -> bool { - let mut taken = false; - for inbound in replies { - // The community the outstanding request went to: the panel's own, or the one holding a - // notified election. - let Some(vtc) = view_mut(state).map(|v| { - v.pending - .as_ref() - .map_or_else(|| v.vtc_did.clone(), |p| p.to.clone()) - }) else { - continue; - }; - // A thread id is a correlation key, not a credential: an answer is taken only from the - // community the view is asking — sent by it and issued by it. - let from_vtc = vta_sdk::did_scid::same_reference(did_part(&inbound.from), &vtc); - let issued_by_vtc = inbound - .issuer - .as_deref() - .map(did_part) - .is_some_and(|issuer| vta_sdk::did_scid::same_reference(issuer, &vtc)); - if !(from_vtc && issued_by_vtc) { - tracing::warn!(from = %inbound.from, thid = %inbound.thid, "elections reply not from the community the view is asking — dropped"); - continue; - } - taken |= apply_reply(state, &inbound.thid, inbound.reply); - } - taken +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec, +) -> bool { + community_request::apply_replies::(state, pending_replies, replies) } -fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { - let Some(view) = view_mut(state) else { - return false; - }; - if view.pending.as_ref().is_none_or(|p| p.thid != thid) { - if !matches!(reply, Reply::Refused(_)) { - tracing::warn!(%thid, "elections reply matches no pending request — dropped"); - } - return false; - } - let Some(pending) = view.pending.take() else { - return false; - }; - elections::answered(&pending.thid); - match (reply, pending.purpose) { +/// Take the answer to the request made for `purpose`. +fn take_reply(view: &mut ElectionsView, reply: Reply, purpose: Purpose) { + match (reply, purpose) { (Reply::List(page), Purpose::List) => { let next = page.next_cursor.as_ref().map(|c| c.as_str().to_string()); view.take_page(page.elections, next.is_none()); @@ -1102,7 +932,7 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { Severity::Error, "The community answered about another election.", ); - return true; + return; } view.shown = Some(shown); match intent { @@ -1128,7 +958,7 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { }) => { let Some(n) = view.opening.clone() else { - return true; + return; }; view.flow = None; view.status = None; @@ -1173,7 +1003,6 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { ); } } - true } /// The election was read again for a ticket or a vote: go on, or say why not. diff --git a/openvtc/src/state_handler/elections_actions/tests.rs b/openvtc/src/state_handler/elections_actions/tests.rs index 65fb5714..19e8d2fc 100644 --- a/openvtc/src/state_handler/elections_actions/tests.rs +++ b/openvtc/src/state_handler/elections_actions/tests.rs @@ -1,5 +1,8 @@ use super::*; +use crate::state_handler::community_request::send_failed; +use crate::state_handler::main_page::elections::Pending; use openvtc_core::elections::{Refusal, TicketRequest}; +use openvtc_core::pending_replies::ReplyKind; use serde_json::{Value, json}; const VTC: &str = "did:key:zVtc"; @@ -104,6 +107,7 @@ fn the_list_is_read_a_page_at_a_time_and_ordered_by_what_can_be_done() { arm(&mut state, Purpose::List); assert!(apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page( json!([item("old", "tallied")]), Some("c1") @@ -118,6 +122,7 @@ fn the_list_is_read_a_page_at_a_time_and_ordered_by_what_can_be_done() { arm(&mut state, Purpose::List); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page(json!([item("now", "voting")]), None))], ); let v = view(&mut state); @@ -132,10 +137,18 @@ fn an_answer_from_anyone_else_is_dropped() { arm(&mut state, Purpose::List); let mut forged = inbound(list_page(json!([]), None)); forged.from = "did:key:zMallory".into(); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); let mut forged = inbound(list_page(json!([]), None)); forged.issuer = Some("did:key:zMallory".into()); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); assert!(view(&mut state).pending.is_some()); } @@ -156,7 +169,11 @@ fn a_vote_reads_the_election_again_then_registers() { intent: Intent::Vote, }, ); - apply_replies(&mut state, vec![inbound(Reply::Show(shown("voting")))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Show(shown("voting")))], + ); let v = view(&mut state); assert!(matches!(v.next, Some(Next::Register))); assert_eq!(v.flow.as_ref().unwrap().step, Step::Registering); @@ -177,7 +194,11 @@ fn a_closed_election_takes_no_ticket() { intent: Intent::Ticket, }, ); - apply_replies(&mut state, vec![inbound(Reply::Show(shown("closed")))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Show(shown("closed")))], + ); let v = view(&mut state); assert!(v.flow.is_none() && v.next.is_none()); assert!(v.status.as_ref().unwrap().text.contains("closed")); @@ -215,6 +236,7 @@ fn voting_without_a_ticket_takes_it_and_stops() { let issued = json!({ "electionId": "agm", "label": "voter/agm", "preCredential": "zPre" }); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::TicketIssued { typed: Box::new(serde_json::from_value(issued.clone()).unwrap()), raw: issued.clone(), @@ -352,6 +374,7 @@ fn a_community_refusal_ends_the_flow_in_words() { ); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::Refused(Refusal { code: "vtc/elections/tickets/issue:notEligible".into(), message: None, @@ -401,6 +424,7 @@ fn vti_ntf_063_the_list_carries_notified_elections_once() { arm(&mut state, Purpose::List); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(list_page(json!([item("agm", "voting")]), None))], ); vta( @@ -447,7 +471,11 @@ fn vti_vot_016_a_membership_is_presented_only_once_the_member_chooses() { // From the community the read went to — not the panel's own. refusal.from = HOLDER.into(); refusal.issuer = Some(HOLDER.into()); - assert!(apply_replies(&mut state, vec![refusal])); + assert!(apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![refusal] + )); let v = view(&mut state); assert_eq!(v.screen, Screen::Disclose); assert!(v.presenting.is_none(), "nothing presented yet"); @@ -527,11 +555,52 @@ fn an_answer_is_taken_from_the_community_the_request_went_to() { // The panel's own community is not the one asked. assert!(!apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(Reply::Show(shown("voting")))] )); let mut from_holder = inbound(Reply::Show(shown("voting"))); from_holder.from = HOLDER.into(); from_holder.issuer = Some(HOLDER.into()); - assert!(apply_replies(&mut state, vec![from_holder])); + assert!(apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![from_holder] + )); assert!(view(&mut state).shown.is_some()); } + +/// Once the view has taken its answer, or its send failed, the inbound gate no longer admits an +/// answer on that thread from a community this persona does not belong to. +#[test] +fn an_answer_taken_or_failed_is_no_longer_awaited_at_the_gate() { + let election = openvtc_core::pending_replies::Purpose::ElectionAnswer; + // Taken. + let mut state = state_with_view(); + arm(&mut state, Purpose::List); + let mut pending = PendingReplies::default(); + pending.record("t1", VTC, election.clone()); + assert!(apply_replies( + &mut state, + &mut pending, + vec![inbound(list_page(json!([]), None))] + )); + assert!(!pending.is_awaited(ReplyKind::ElectionAnswer, VTC, "t1")); + + // Its send failed. + let mut state = state_with_view(); + arm(&mut state, Purpose::List); + pending.record("t1", VTC, election); + let persona = view(&mut state).persona; + send_failed::( + &mut state, + &mut pending, + SendOutcome { + vtc_did: VTC.into(), + persona, + thid: "t1".into(), + result: Err("unreachable".into()), + }, + ); + assert!(!pending.is_awaited(ReplyKind::ElectionAnswer, VTC, "t1")); + assert!(view(&mut state).pending.is_none()); +} diff --git a/openvtc/src/state_handler/hidden_vetting_vta.rs b/openvtc/src/state_handler/hidden_vetting_vta.rs index 62681964..965ae4e3 100644 --- a/openvtc/src/state_handler/hidden_vetting_vta.rs +++ b/openvtc/src/state_handler/hidden_vetting_vta.rs @@ -118,6 +118,18 @@ pub(crate) struct PcsJob { pub work: Vec, } +impl crate::state_handler::background_dispatch::Job for PcsJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::HiddenVetting + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::HiddenVetting(Box::new( + self.run().await, + )) + } +} + impl PcsJob { pub(crate) async fn run(self) -> PcsOutcome { let mut results = Vec::with_capacity(self.work.len()); diff --git a/openvtc/src/state_handler/inbound_glue_tests.rs b/openvtc/src/state_handler/inbound_glue_tests.rs index b7e2a317..dcf19e3e 100644 --- a/openvtc/src/state_handler/inbound_glue_tests.rs +++ b/openvtc/src/state_handler/inbound_glue_tests.rs @@ -90,6 +90,7 @@ async fn dispatch( tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), m, m.from.as_deref(), &mut effects, diff --git a/openvtc/src/state_handler/join_cmd.rs b/openvtc/src/state_handler/join_cmd.rs index 6a5e7ad4..cd705221 100644 --- a/openvtc/src/state_handler/join_cmd.rs +++ b/openvtc/src/state_handler/join_cmd.rs @@ -2,7 +2,7 @@ //! //! Covers the two ways in that need no questions answered: presenting an //! invitation, or sending an open request. The submit mirrors -//! `join_flow::run_join_sequence` for an existing persona; the wait reuses +//! the TUI's join launch (`join_flow::driver`) for an existing persona; the wait reuses //! core's inbound dispatch and join status poll, so a verdict is applied exactly //! as the runtime loop would apply it. @@ -21,6 +21,7 @@ use openvtc_core::config::{ use openvtc_core::didcomm::{DIDCommEvent, Messaging, MessagingTransport}; use openvtc_core::logs::LogFamily; use openvtc_core::messaging::SeenMessages; +use openvtc_core::pending_replies::PendingReplies; use openvtc_core::vetting::disclosure::{Describe, DirectoryStatement}; use openvtc_core::vetting::protocol::JoinProtocol; use serde_json::Value; @@ -221,7 +222,7 @@ impl Job<'_> { .await; if let Err(e) = submitted { // A persona minted for this join and left unreferenced by a - // membership is taken back out, as `rollback_minted_persona` does. + // membership is taken back out, as the TUI launch's saga does. if let Some((keys, prior_friendly_name)) = minted && config.account.membership(vtc_did, persona_id).is_none() { @@ -383,10 +384,11 @@ impl Job<'_> { ) .await?; let persona_id = minted - .persist(config, self.tdk, self.profile) + .write_into(config, self.tdk) .await .with_context(|| format!("persona {} minted but not saved", minted.did))?; - save(config, self.profile)?; + save(config, self.profile) + .with_context(|| format!("persona {} minted but not saved", minted.did))?; progress(format!("persona created: {}", minted.did)); Ok((persona_id, minted.setup.did_keys.take())) } @@ -411,7 +413,7 @@ impl Job<'_> { } } - /// `run_join_sequence` for an existing persona: context, listener, + /// The TUI's join launch for an existing persona: context, listener, /// presentation, transport, submit, pending record. async fn submit( &self, @@ -624,6 +626,9 @@ impl Job<'_> { let vtc_did = self.vtc_did; let deadline = tokio::time::Instant::now() + wait; let mut seen = SeenMessages::new(); + // This command's own table of replies awaited by thread — it asks + // nothing the gate admits only on a recorded thread, so it stays empty. + let mut pending = PendingReplies::default(); let mut pacer = openvtc_core::join::status_poll::PollPacer::default(); let mut poll = tokio::time::interval_at(tokio::time::Instant::now() + POLL_FIRST, POLL_EVERY); @@ -675,7 +680,13 @@ impl Job<'_> { note_verdict(&message); } if self - .dispatch(config, &mut seen, &message, authenticated.as_deref()) + .dispatch( + config, + &mut seen, + &mut pending, + &message, + authenticated.as_deref(), + ) .await { save(config, self.profile)?; @@ -724,6 +735,7 @@ impl Job<'_> { &self, config: &mut Config, seen: &mut SeenMessages, + pending: &mut PendingReplies, message: &Message, authenticated: Option<&str>, ) -> bool { @@ -732,6 +744,7 @@ impl Job<'_> { self.tdk, self.messaging, seen, + pending, message, authenticated, ) diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index e6634b62..3b07c3ff 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -1,21 +1,29 @@ //! State-B "join a community" orchestration (R-A-5 Stage 4). //! -//! [`StateHandler::join_flow`] is a nested `tokio::select!` loop modelled on -//! [`setup_wizard`](crate::state_handler::setup_wizard): it owns the screen -//! while [`ActivePage::Join`] is -//! active, processes the join actions, renders via `state_tx`, and returns to -//! the main page when the user cancels or the sequence finishes. +//! The join is driven by the loop that owns it — the runtime loop, or the +//! State-A loop — through [`driver::JoinDriver`], a state machine with no event +//! loop of its own (#529). Its long network work runs off the loop as jobs on +//! `DispatchDomain::Join`; the launch is a saga whose steps a failure or a +//! cancel undoes (`openvtc_core::join::saga`). See [`driver`] for the shape. //! -//! The actual work runs in [`run_join_sequence`]: mint a fresh persona (reusing -//! the setup VTA helpers), derive + register the per-community sub-context, -//! submit the join, and persist a `Pending` [`CommunityRecord`]. Every failure -//! is surfaced into the join log as a [`MessageType::Error`] — the loop never -//! `?`-bubbles a sequence error in a way that would kill the app. +//! This module holds what the driver decides with: the pure page logic (which +//! routes a community offers, how a vetting row reads, which contexts a persona +//! can join from), the bounded sends a join makes while it waits on a +//! community (its requirements, a challenge, a wallet session), and the hand-off +//! types the runtime loop uses to hear those answers ([`AwaitingRequirements`], +//! [`AwaitingChallenge`], [`AwaitingWallet`]). Every failure is surfaced into +//! the join log as a [`MessageType::Error`] — nothing `?`-bubbles a join error +//! in a way that would kill the app. +pub(crate) mod driver; + +pub(crate) use driver::{JoinCtx, JoinDriver, JoinOutcome, JoinStep}; + +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use std::time::Duration; use affinidi_tdk::TDK; -use anyhow::Result; use chrono::Utc; use openvtc_core::config::{ Config, @@ -32,7 +40,6 @@ use openvtc_core::vetting::disclosure::{Describe, DirectoryStatement}; use openvtc_core::vetting::guide::describe_requirements; use openvtc_core::vetting::queries::{CommunityAnswer, CommunityQuery, QueryKind}; use openvtc_core::vetting::wire; -use tokio::sync::{broadcast, mpsc::UnboundedReceiver}; use tracing::{debug, warn}; use vta_sdk::{ client::VtaClient, @@ -43,7 +50,6 @@ use vta_sdk::{ use crate::{ Interrupted, state_handler::{ - StateHandler, actions::Action, join::{ Applicant, ApplyAs, AvailableVic, FirstStepKind, IdentityPick, JoinAnswers, @@ -62,21 +68,21 @@ use crate::{ vetting_actions, }, }; +use driver::JoinIo; /// How the join flow is entered. +/// +/// Whether the flow can ask a community for its requirements and hand the +/// wait for the answer to the calling loop is the [`JoinDriver`]'s: only a +/// loop that reads inbound messages can. pub(crate) enum JoinEntry { - /// From the Communities panel. `hears_replies` says whether the calling - /// loop reads inbound messages: only then can the flow ask a community for - /// its requirements and hand the wait for the answer to that loop. - Fresh { hears_replies: bool }, + /// From the Communities panel. + Fresh, /// For a community already chosen elsewhere — `j` on a vetting application, /// which is the join that application was made for. The DID entry page is /// skipped: an application names its community, and making someone find and /// retype that DID is what turned "go and get vetted" into a dead end. - ForCommunity { - vtc_did: String, - hears_replies: bool, - }, + ForCommunity { vtc_did: String }, /// Back from waiting for a community's requirements. Resume { vtc_did: String, @@ -141,11 +147,11 @@ pub(crate) enum RequirementsOutcome { Skipped, } -/// A join waiting for a community's manifest. The join flow cannot wait -/// itself: its loop selects only on actions and interrupts, and the answer -/// arrives on the runtime loop's inbound arm. So the flow asks, returns this, -/// and the runtime loop keeps the join screen up, hears the answer — or gives -/// up after [`REQUIREMENTS_WAIT`] — and enters the flow again. +/// A join waiting for a community's manifest. The answer arrives on the +/// runtime loop's inbound arm, not as a join action, so the flow asks, returns +/// this ([`JoinStep::AwaitRequirements`]), and the runtime loop keeps the join +/// screen up, hears the answer — or gives up after [`REQUIREMENTS_WAIT`] — and +/// enters the flow again. pub(crate) struct AwaitingRequirements { pub vtc_did: String, /// The manifest request's document id. @@ -162,8 +168,8 @@ pub(crate) const REQUIREMENTS_WAIT: Duration = Duration::from_secs(15); /// A community that hides its vetters accepts a proof only over a challenge it /// issued itself, once (`vtc/vetting/pcs-challenge/0.1`). The launch asks for /// one and then, like [`AwaitingRequirements`], hands the wait to the runtime -/// loop, which is the only loop that hears the answer: the join flow's own -/// `select!` reads actions and interrupts, nothing inbound. When the answer +/// loop, which is the only loop that hears the answer: the join driver takes +/// actions and its own jobs' results, nothing inbound. When the answer /// arrives — or [`CHALLENGE_WAIT`] passes without one — that loop enters the /// flow again with [`JoinEntry::Challenge`], and the launch carries on from /// where it stopped rather than sending the person off to ask for it themselves. @@ -281,7 +287,7 @@ pub(crate) fn challenge_resume_for( enum ChallengeStep { /// Go on: nothing hidden to prove, a challenge was just issued for this /// launch, or this loop cannot hear an answer anyway (and - /// [`hidden_proof_refusal`] says what to do when none is held). + /// [`hidden_proof_precheck`] says what to do when none is held). Proceed, /// Ask the community for a fresh challenge, and wait for it. Ask, @@ -320,7 +326,7 @@ fn challenge_step( /// The vetting application a join as `pick` to `vtc_did` presents: the one /// made under that persona, and only while it was made for that persona's DID /// — the statements and proof it holds name the DID they were gathered for. -/// The same match `run_join_sequence` makes when it attaches them. +/// The same match the launch makes when it attaches them. fn presented_application<'a>( config: &'a Config, vtc_did: &str, @@ -1059,7 +1065,7 @@ pub(crate) fn vetting_view( let evaluation = app.checklist(now); // The statements name the DID the application was made for, and // the join attaches them only when that is the DID joining - // (`run_join_sequence`). A persona whose DID has moved on since + // (the launch's presenting stage). A persona whose DID has moved on since // cannot present them, so the page must not call them ready. let current_did = personas .iter() @@ -1272,10 +1278,15 @@ fn known_vetting(state: &mut State) -> Option<&mut KnownVetting> { /// The persona is the row's own, passed in: what this does follows from /// whether *that* persona has an application here, never from whether any /// other persona does. -fn apply_for_vetting( +/// +/// A new application is saved at once (`io.save`, a flush of the loop's +/// scheduler): the Vetting page it opens is where the person goes next, and the +/// application must survive whatever they do there. +async fn apply_for_vetting( state: &mut State, config: &mut Config, - profile: &str, + io: &dyn JoinIo, + save: &mut crate::state_handler::save_coalesce::SaveScheduler, vtc_did: &str, persona: PersonaId, ) -> Result<(), String> { @@ -1349,7 +1360,9 @@ fn apply_for_vetting( .find(|a| a.id == id) .map(|a| vetting_actions::next_step_words(&a.next_step(now))) .unwrap_or_default(); - save_config(config, profile).map_err(|e| format!("Could not save the application: {e}"))?; + io.save(save, config) + .await + .map_err(|e| format!("Could not save the application: {e}"))?; vetting_actions::focus_application( state, config, @@ -1383,1502 +1396,6 @@ fn joined_session(js: &JoinState) -> Option { }) } -impl StateHandler { - /// Run the join flow until the user cancels or the sequence finishes. - /// - /// Mirrors `setup_wizard`'s loop shape. `admin_vta` is the always-on admin - /// VTA session (threaded in from the caller); `config` is mutated in place - /// and persisted by the sequence on success. - /// - /// `messaging` is the live DIDComm service when there is one, so the - /// sequence can bring the applicant persona's mediator socket up *before* - /// it submits (see [`start_persona_listener`]). The State-A degraded loop has - /// no service and passes `None`; the join still works there, it just cannot - /// receive the community's reply until the process restarts into the full - /// pipeline — which is what that path does anyway. - #[allow(clippy::too_many_arguments)] - pub(crate) async fn join_flow( - &self, - action_rx: &mut UnboundedReceiver, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - entry: JoinEntry, - ) -> Result { - // Enter the flow on a fresh EnterDid page — unless this is a launch - // coming back with its challenge, which must find everything the person - // chose on the way to it exactly as they left it. - // Whichever way a parked wait ends, it has ended: Esc is no longer - // the cheap way out once the launch carries on. - state.join.waiting_on_challenge = false; - if !matches!( - entry, - JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } - ) { - state.join.reset(); - // Surface the launch-supplied invitation on the entry page (reset - // clears the transient join sub-state, so mirror the flag back in - // afterwards). - state.join.has_invitation = state.invitation_credential.is_some(); - state.join.invitation_foreign_subject = state - .invitation_credential - .as_ref() - .and_then(|vic| foreign_invitation_subject(config, vic)); - } - state.active_page = ActivePage::Join; - let hears_replies = match &entry { - JoinEntry::Fresh { hears_replies } | JoinEntry::ForCommunity { hears_replies, .. } => { - *hears_replies - } - // Only the runtime loop waits on an answer, so only it resumes. - JoinEntry::Resume { .. } | JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } => { - true - } - }; - state.join.hears_replies = hears_replies; - match entry { - JoinEntry::Fresh { .. } => {} - // The community is already chosen, so the flow opens where the DID - // entry page would have led. - JoinEntry::ForCommunity { vtc_did, .. } => { - if let Some(exit) = self - .enter_community( - vtc_did, - hears_replies, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - let _ = self.state_tx.send(state.clone()); - return Ok(exit); - } - } - JoinEntry::Resume { vtc_did, outcome } => { - // `reset` above cleared what the asking pass collected, and the - // routes cannot be drawn without it. - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - match outcome { - RequirementsOutcome::Unanswered(reason) => { - // We got here by sending the question, so the DID - // resolved: it is the answer that never came. - show_unknown(state, config, &vtc_did, reason, true, true); - } - outcome => { - let learned = matches!(outcome, RequirementsOutcome::Learned); - if learned { - // The manifest came over DIDComm, which never - // resolved the community's DID document. - sequence::learn_signing(config, tdk, &vtc_did).await; - } - let shown = learned && show_vetting(state, config, &vtc_did); - if !shown - && let Some(interrupted) = self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - } - } - JoinEntry::Challenge { awaiting, outcome } => match outcome { - ChallengeOutcome::Issued => { - // The stored challenge is the one this launch asked for; - // the launch takes the flag, so it serves exactly one submit. - state.join.challenge_fresh = true; - if let Some(interrupted) = self - .launch_join_sequence( - awaiting.choice, - awaiting.vtc_did, - awaiting.context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - ChallengeOutcome::Unanswered(reason) => { - let name = vetting_actions::community_display(config, &awaiting.vtc_did); - state.join.page = JoinPage::Progress; - state.join.fail(challenge_failed_words(&name, &reason)); - } - }, - JoinEntry::Wallet { end } => { - state.join.processing = false; - match end { - WalletEnd::Presented => { - let parked = state.join.wallet.as_mut().and_then(|w| { - state.join.wallet_outcome = - Some(WalletOutcome::Presented(w.requirements_digest.clone())); - w.parked.take() - }); - state.join.wallet = None; - if let Some((pick, vtc_did, context_id)) = parked - && let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - WalletEnd::Unfinished(why) => { - state.join.page = JoinPage::Wallet; - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Offered { note: Some(why) }; - } - } - } - } - } - let _ = self.state_tx.send(state.clone()); - - loop { - // A launch that stopped to ask a hidden-vetting community for its - // challenge leaves the flow here, whichever page or entry started - // it: only the caller's loop hears the answer. - if let Some(awaiting) = state.join.challenge_wait.take() { - let _ = self.state_tx.send(state.clone()); - return Ok(JoinExit::AwaitChallenge(awaiting)); - } - // Likewise a wallet session: the runtime loop hears its answers. - if let Some(awaiting) = state.join.wallet_wait.take() { - let _ = self.state_tx.send(state.clone()); - return Ok(JoinExit::AwaitWallet(awaiting)); - } - tokio::select! { - maybe_action = action_rx.recv() => { - let Some(action) = maybe_action else { - // Channel closed — treat as a user-initiated exit. - return Ok(JoinExit::Exit(Interrupted::UserInt)); - }; - match action { - Action::Exit => return Ok(JoinExit::Exit(Interrupted::UserInt)), - Action::UXError(interrupted) => { - return Ok(JoinExit::Exit(interrupted)); - } - Action::JoinCancel => { - // Leave the flow; the caller restores the main page. - // Hand back the joined session (if any) so the runtime - // loop can bring its live session up (R-B-5). - state.active_page = ActivePage::Main; - return Ok(JoinExit::Returned(joined_session(&state.join))); - } - Action::JoinPasteVic(text) => { - // #3: a pasted invitation credential — validate it is a - // VIC and stash it so the join presents it (mirrors the - // `--invitation ` launch flag). On the entry page - // the community isn't chosen yet, so only the credential - // shape is checked; on the invitation step it is, so the - // paste is also matched against it. - let vtc = if state.join.page == JoinPage::InvitationChoice { - state.join.pending_vtc.clone() - } else { - None - }; - state.join.messages.clear(); - load_pasted_vic(state, tdk.did_resolver(), &text, vtc.as_deref()).await; - state.join.invitation_foreign_subject = state - .invitation_credential - .as_ref() - .and_then(|vic| foreign_invitation_subject(config, vic)); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinClipboardFailed(why) => { - // Only the failure reaches the loop: `[Ctrl+V]` reads - // and applies the text where the input lives, so a - // pasted DID never takes a trip through here to be - // mistaken for an invitation. - state.join.messages.clear(); - state.join.messages.push(MessageType::Error(format!( - "Could not read the clipboard ({why}). Paste directly into \ - this screen instead — that works over SSH, where reading \ - the clipboard cannot." - ))); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinClearVic => { - // Explicit "proceed without a VIC": drop the loaded - // invitation so it isn't presented, and flag the clear so - // the entry page shows "joining without an invitation". - state.invitation_credential = None; - state.join.has_invitation = false; - state.join.invitation_issuer = None; - state.join.invitation_foreign_subject = None; - state.join.vic_cleared = true; - state.join.messages.clear(); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinSubmitVtc(vtc_did) => { - let vtc_did = match validate_join_input(&vtc_did) { - Ok(did) => did, - // Say why nothing happened. A keypress that - // cannot proceed must not be a silent no-op — - // that reads as a frozen screen (issue #29). - Err(why) => { - state.join.messages.push(MessageType::Error(why)); - let _ = self.state_tx.send(state.clone()); - continue; - } - }; - // Accept an agent name (`example.com/@acme`) in place - // of the VTC DID. Only resolve when the input actually - // looks like a name, so pasting a DID keeps its - // existing (no-extra-round-trip) path; the resolved - // DID is what everything downstream persists. - let vtc_did = if openvtc_core::agent_name::looks_like_agent_name( - &vtc_did, - ) { - // Say what the wait is for: a name costs a - // redirect, a resolve and a check (bounded by - // IDENTIFIER_sequence::RESOLVE_TIMEOUT). - state.join.messages.push(MessageType::Info(format!( - "Resolving {vtc_did}…" - ))); - let _ = self.state_tx.send(state.clone()); - match openvtc_core::agent_name::resolve_identifier( - tdk.did_resolver(), - &vtc_did, - ) - .await - { - Ok(did) => did, - Err(e) => { - state.join.fail(e.to_string()); - let _ = self.state_tx.send(state.clone()); - continue; - } - } - } else { - vtc_did - }; - if let Some(exit) = self - .enter_community( - vtc_did, - hears_replies, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - let _ = self.state_tx.send(state.clone()); - return Ok(exit); - } - } - Action::JoinIdentitySelect(i) => { - // Clamp to the reuse rows plus the trailing "mint" row. - state.join.identity_selected = i.min(state.join.mint_row()); - // Moving the highlight dismisses any armed warning. - state.join.reuse_confirm = None; - let _ = self.state_tx.send(state.clone()); - } - Action::JoinNewPersonaMethodCycle => { - state.join.new_persona_did_method = - next_did_method(state.join.new_persona_did_method); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinIdentityChoose => { - if state.join.mint_row_selected() { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - state.join.present_invitation = - mint_can_present(config, state, &vtc_did); - if let Some(context_id) = - offer_contexts(state, config, IdentityPick::Mint, &vtc_did) - && let Some(interrupted) = self - .launch_join_sequence( - IdentityPick::Mint, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } else if let Some(opt) = - state.join.persona_options.get(state.join.identity_selected) - { - // Arm the cross-community linkage warning (D1). - state.join.reuse_confirm = Some(opt.id); - let _ = self.state_tx.send(state.clone()); - } - } - Action::JoinReuseConfirm => { - let Some(persona_id) = state.join.reuse_confirm else { - continue; - }; - // The community DID stays parked in `pending_vtc`; the - // invitation step is what launches the join now. - if state.join.pending_vtc.is_none() { - continue; - } - state.join.reuse_confirm = None; - open_invitation_choice(state, config, persona_id); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinReuseCancel => { - state.join.reuse_confirm = None; - let _ = self.state_tx.send(state.clone()); - } - Action::JoinInvitationSelect(i) => { - // Rows: 0..len an invitation, len = paste, len+1 = without. - let max = state.join.invitation_without_row(); - state.join.invitation_use_selected = i.min(max); - let _ = self.state_tx.send(state.clone()); - } - Action::JoinInvitationChoose => { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - let Some(persona_id) = state.join.invitation_for_persona else { - continue; - }; - // The paste row loads a VIC instead of launching: it is - // the answer to "I have one, it just isn't in the - // vault". Read the OS clipboard as a convenience; the - // portable path is the terminal's own bracketed paste, - // which arrives as `JoinPasteVic` from anywhere on this - // page. Either way we stay on the step so the operator - // sees the loaded invitation before committing to it. - if state.join.invitation_use_selected - == state.join.invitation_paste_row() - { - state.join.messages.clear(); - match crate::clipboard::read_clipboard() { - Ok(text) => { - load_pasted_vic(state, tdk.did_resolver(), &text, Some(&vtc_did)).await; - } - Err(why) => { - state.join.messages.push(MessageType::Error(format!( - "Could not read the clipboard ({why}). Paste the \ - invitation JSON directly into this screen instead." - ))); - } - } - let _ = self.state_tx.send(state.clone()); - continue; - } - // A selected invitation row presents that VIC; the - // trailing row joins without one. - let sel = state.join.invitation_use_selected; - if let Some(av) = state.join.invitation_options.get(sel) { - state.invitation_credential = Some(av.body.clone()); - state.join.present_invitation = true; - } else { - state.invitation_credential = None; - state.join.present_invitation = false; - } - let pick = IdentityPick::Reuse(persona_id); - if let Some(context_id) = offer_contexts(state, config, pick, &vtc_did) - && let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinAnswersSelect(i) => { - if let (Some(answers), Some(client)) = - (state.join.answers.as_mut(), admin_vta) - { - answers.selected = i.min(answers.faces.len().saturating_sub(1)); - load_shown(answers, client).await; - } - } - Action::JoinAnswersChoose => { - let Some(answers) = state.join.answers.as_mut() else { - continue; - }; - let Some((face_id, face_name)) = - answers.faces.get(answers.selected).cloned() - else { - answers.error = Some( - "You have no face to answer with. Make one under Identity, \ - then join again." - .into(), - ); - let _ = self.state_tx.send(state.clone()); - continue; - }; - let missing = answers.unanswered(); - if !missing.is_empty() { - answers.error = Some(format!( - "{face_name} does not show {} — the community requires it. \ - Pick another face, or add it to this one under Identity.", - missing.join(", ") - )); - let _ = self.state_tx.send(state.clone()); - continue; - } - // What the holder approves is these values, exactly. - let approved = answers - .shown - .iter() - .filter_map(|(t, v)| v.clone().map(|v| (t.clone(), v))) - .collect(); - answers.approved = Some((face_id, approved)); - let Some((pick, vtc_did, context_id)) = answers.parked.take() else { - continue; - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinWalletStart => { - let Some(wallet) = state.join.wallet.as_ref() else { - continue; - }; - if !matches!(wallet.phase, WalletPhase::Offered { .. }) { - continue; - } - let Some((IdentityPick::Reuse(persona), vtc_did, _)) = - wallet.parked.clone() - else { - continue; - }; - let digest = wallet.requirements_digest.clone(); - match ask_wallet_start(config, tdk, messaging, &vtc_did, persona, &digest) - .await - { - Ok(awaiting) => { - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Opening; - } - state.join.wallet_wait = Some(awaiting); - } - Err(why) => { - if let Some(wallet) = state.join.wallet.as_mut() { - wallet.phase = WalletPhase::Offered { note: Some(why) }; - } - } - } - } - Action::JoinWalletSkip => { - let parked = state.join.wallet.as_mut().and_then(|w| { - matches!(w.phase, WalletPhase::Offered { .. }) - .then(|| w.parked.take()) - .flatten() - }); - let Some((pick, vtc_did, context_id)) = parked else { - continue; - }; - state.join.wallet = None; - state.join.wallet_outcome = Some(WalletOutcome::Skipped); - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinRegistryToggle => { - if let Some(registry) = state.join.registry.as_mut() { - registry.publish = !registry.publish; - } - } - Action::JoinRegistryConfirm => { - // Either answer is an answer. What is sent is the box - // as it stands now — read again by the sequence via - // `RegistryChoice::consent_for`, never cached apart - // from the page that showed it. - let Some(registry) = state.join.registry.as_mut() else { - continue; - }; - registry.confirmed = true; - let Some((pick, vtc_did, context_id)) = registry.parked.take() else { - continue; - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinContextSelect(i) => { - let last = state.join.context_options.len().saturating_sub(1); - state.join.context_selected = i.min(last); - state.join.messages.clear(); - } - Action::JoinContextSlug(slug) => { - state.join.context_slug = slug; - state.join.messages.clear(); - } - Action::JoinContextChoose => { - let (Some(vtc_did), Some(pick)) = - (state.join.pending_vtc.clone(), state.join.picked_identity) - else { - continue; - }; - let Some(option) = state - .join - .context_options - .get(state.join.context_selected) - .cloned() - else { - continue; - }; - let context_id = if option.kind == ContextKind::New { - match community_context::new_context_id( - &config.account.top_context_id, - &state.join.context_slug, - |id| sequence::context_taken(config, id), - ) { - Ok(id) => id, - Err(e) => { - state.join.messages.clear(); - state - .join - .messages - .push(MessageType::Error(e.to_string())); - let _ = self.state_tx.send(state.clone()); - continue; - } - } - } else { - option.context_id - }; - if let Some(interrupted) = self - .launch_join_sequence( - pick, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::JoinVettingRow(forward) => { - if let Some(known) = known_vetting(state) { - known.step(forward); - } - } - Action::JoinVettingTake => { - // Enter on the routes list. A route the page drew as - // blocked must say why rather than do nothing — a - // keypress that cannot proceed reads as a frozen - // screen (issue #29). - let Some(option) = known_vetting(state) - .and_then(|known| known.selected_route().cloned()) - else { - continue; - }; - if !option.available() { - let why = option.blocked().unwrap_or_default(); - state.join.messages.clear(); - state.join.messages.push(MessageType::Error(format!( - "{}: {why}", - option.label - ))); - let _ = self.state_tx.send(state.clone()); - continue; - } - // A route that starts with the join doing something: - // do it, and remember what it was on the way to. - // That is the difference between being guided and - // being told to go and configure something - // elsewhere. - // - // A step that is merely the next page needs none of - // this — taking the route reaches it. - if option.first_step_kind() == Some(FirstStepKind::CreatePersona) { - state.join.messages.clear(); - state.join.resume_route = Some(option.route); - super::handle_nav_action(state, &Action::StartCreatePersona); - let _ = self.state_tx.send(state.clone()); - continue; - } - if let Some(exit) = self - .take_route( - option.route, - option.applicant, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(exit); - } - } - Action::JoinVettingApply => { - // `a`: read the highlighted row's own application. - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - let Some(persona) = - known_vetting(state).and_then(|k| k.selected_persona()) - else { - continue; - }; - state.join.messages.clear(); - match apply_for_vetting(state, config, profile, &vtc_did, persona) { - Ok(()) => { - state.active_page = ActivePage::Main; - return Ok(JoinExit::Returned(None)); - } - Err(why) => state.join.messages.push(MessageType::Error(why)), - } - } - Action::JoinVettingJoin => { - // `j`, offered only while the community is being - // asked or could not be: there are no rows yet, so - // no persona has been chosen and none of its - // statements are presumed. The identity step asks, - // and the submit attaches the chosen persona's own. - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - state.join.messages.clear(); - if let Some(interrupted) = self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - // The create-persona overlay, hosted here rather than - // on the main page. Everything except the mint is the - // shared pure reducer, so the phases and keys cannot - // drift from the ones the main page shows. - action if super::handle_nav_action(state, &action) => { - // A persona minted behind this page changes what the - // routes say, so they are re-derived once the overlay - // is gone. Cheap, idempotent, and it means the row - // that was blocked on having no persona unblocks - // itself rather than waiting for a re-entry. - if state.join.page == JoinPage::Vetting - && state.main_page.create_persona.is_none() - && let Some(vtc_did) = state.join.pending_vtc.clone() - { - show_vetting(state, config, &vtc_did); - // And carry on where the step interrupted — as - // the persona the step made, on that persona's - // own row. Only when the step actually produced - // one: an overlay closed with Esc leaves the - // page as it was, with the "new persona" row - // still saying what it starts with. - let minted = state.join.minted_persona; - let resumable = state.join.resume_route.and_then(|route| { - let known = known_vetting(state)?; - let row = match route { - JoinRoute::Vetting => { - known.row_for(Applicant::Persona(minted?))? - } - other => known.row_of(other)?, - }; - let option = known.routes.get(row)?; - (option.first_step().is_none() && option.available()) - .then_some((row, option.route, option.applicant)) - }); - if let Some((row, route, applicant)) = resumable { - state.join.resume_route = None; - if let Some(known) = known_vetting(state) { - known.row = row; - } - if let Some(exit) = self - .take_route( - route, - applicant, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(exit); - } - } - } - } - Action::CreatePersonaSubmit => { - if let Some(interrupted) = self - .mint_persona_inline( - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - { - return Ok(JoinExit::Exit(interrupted)); - } - } - Action::CreatePersonaCopy => { - if let Some(did) = state - .main_page - .create_persona - .as_ref() - .and_then(|o| o.did.clone()) - { - let copied = - crate::clipboard::copy_to_clipboard(&did).is_ok(); - if let Some(o) = state.main_page.create_persona.as_mut() { - o.copied = copied; - } - } - } - Action::JoinVettingAskAgain => { - let Some(vtc_did) = state.join.pending_vtc.clone() else { - continue; - }; - if hears_replies - && let Ok(awaiting) = - ask_requirements(state, config, tdk, messaging, &vtc_did).await - { - let _ = self.state_tx.send(state.clone()); - return Ok(JoinExit::AwaitRequirements(awaiting)); - } - } - _ => {} - } - } - Ok(interrupted) = interrupt_rx.recv() => { - return Ok(JoinExit::Exit(interrupted)); - } - } - let _ = self.state_tx.send(state.clone()); - } - } - - /// Enter `vtc_did` into the flow: learn what it asks of the people who join, - /// and put up the page that answers it. - /// - /// `Some` means leave the flow with that exit; `None` means a page is now up - /// and the loop carries on. Shared by the DID entry page and by a join - /// started for a community already chosen (a vetting application's), so both - /// get the same discovery rather than the entry page's alone. - #[allow(clippy::too_many_arguments)] - async fn enter_community( - &self, - vtc_did: String, - hears_replies: bool, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - // The invitations before the routes: whether presenting one is a way in - // to this community is a fact about this account, and the vetting page - // cannot say what the options are without it. - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - // Peer identity vetting: a community that vets says what it requires - // before anything about the applicant is sent (vetting-process.md §6.1). - // What the book already knows decides at once. Otherwise the community - // is asked, and the wait happens in the caller's loop — the one that - // hears the answer — which enters this flow again. - let known = |config: &Config| match config.private.vetting.knowledge(&vtc_did) { - Knowledge::Vetting(_) => Some(true), - Knowledge::NoVetting => Some(false), - Knowledge::Unknown => None, - }; - let mut knowledge = known(config); - // Known before is not known now. A community changes what it asks — - // turns on hidden vetting, raises a count, starts vetting at all — and - // a join decided on the last visit's copy shows the person a community - // that no longer exists: vetters "named" where they are now hidden, or - // no vetting where it is now required. So the manifest is read again on - // every join, over the endpoint that needs nothing of us (bounded, R1.2), - // which also carries the change into any application already under way. - // Only when the community cannot be reached does the stored copy stand - // in, and the log says the page is showing an older answer. - if knowledge.is_some() { - match sequence::learn_over_http(config, tdk, &vtc_did).await { - Ok(()) => knowledge = known(config), - Err(why) => { - debug!(community = %vtc_did, reason = %why.reason(), "manifest not re-read"); - state.main_page.log(format!( - "Could not read {}'s current requirements ({}); showing what it said \ - last time.", - vetting_actions::community_display(config, &vtc_did), - why.reason() - )); - } - } - } - match knowledge { - Some(true) => { - // The re-read above records how it signs; if it could not be - // made, the DID document may still resolve on its own. - sequence::learn_signing(config, tdk, &vtc_did).await; - show_vetting(state, config, &vtc_did); - None - } - None => { - // The community's own endpoint first. It needs no mediator, no - // persona and no loop that can hear a reply, so it answers on a - // first join — where the DIDComm question cannot be asked at - // all — and it answers in one round trip everywhere else. - match sequence::learn_over_http(config, tdk, &vtc_did).await { - Ok(()) => { - if show_vetting(state, config, &vtc_did) { - return None; - } - // It answered, and the answer is that it does not vet. - self.continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit) - } - // Nothing resolved, so there is nobody to ask by any - // route: a DIDComm question needs an endpoint the document - // would have named. Stop here rather than send a request - // into the dark. - Err(why) if !why.resolvable() => { - show_unknown( - state, - config, - &vtc_did, - why.reason().to_string(), - false, - false, - ); - None - } - Err(why) if hears_replies => { - debug!(community = %vtc_did, reason = %why.reason(), "manifest not read over REST"); - match ask_requirements(state, config, tdk, messaging, &vtc_did).await { - Ok(awaiting) => Some(JoinExit::AwaitRequirements(awaiting)), - // The page now says why the question could not be sent. - Err(()) => None, - } - } - Err(why) => { - // Both ways of asking are shut: its endpoint did not - // answer, and this loop has no inbound arm to hear a - // message on. Say so rather than sending an open request - // on the quiet — a community that vets refers that - // request to its moderators, and the applicant would - // never learn there was a way in they could have taken. - show_unknown( - state, - config, - &vtc_did, - why.reason().to_string(), - false, - true, - ); - None - } - } - } - Some(false) => self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit), - } - } - - /// Take one of the community's ways in, as `applicant` when it is a - /// vetting row. - /// - /// Split out of the keypress so the join can also take a route it had to - /// interrupt: a route whose first step was creating a persona resumes here - /// once that persona exists, rather than putting the person back on the - /// list to press the same key again. - /// - /// Which persona presents statements is decided here and nowhere else on - /// this page, and only from the row's own persona: its application, ready - /// or not. The invitation and open-request rows name no persona, so they go - /// to the identity step, and the submit attaches whatever the persona - /// chosen there gathered for this community — its own, matched on its DID - /// (`run_join_sequence`), which the review page shows before it is sent. - #[allow(clippy::too_many_arguments)] - async fn take_route( - &self, - route: JoinRoute, - applicant: Option, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - let vtc_did = state.join.pending_vtc.clone()?; - state.join.messages.clear(); - match route { - // The invitation is chosen further on, once the persona it is bound - // to is known: the invitation step lists this community's VICs for - // that persona and is where one is picked. - JoinRoute::Invitation | JoinRoute::OpenRequest => self - .continue_join( - vtc_did, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await - .map(JoinExit::Exit), - JoinRoute::Vetting => { - let ready = known_vetting(state).and_then(|k| k.ready_as(applicant)); - match (ready, applicant) { - // This persona's own statements meet the requirements: - // joining is what presents them, and they fix the persona - // — so the identity step has nothing left to ask and the - // flow goes straight to the invitation choice. - (Some(persona), _) => { - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - open_invitation_choice(state, config, persona); - None - } - // Anything else is taken by working on this persona's own - // application — carrying it on, or starting it. - (None, Some(Applicant::Persona(persona))) => { - match apply_for_vetting(state, config, profile, &vtc_did, persona) { - Ok(()) => { - state.active_page = ActivePage::Main; - Some(JoinExit::Returned(None)) - } - Err(why) => { - state.join.messages.push(MessageType::Error(why)); - None - } - } - } - // A new persona's row starts by making it (its first step - // is taken before this is reached) and comes back on that - // persona's own row. Reaching here without one is a page - // out of step with itself; say so rather than guess. - (None, Some(Applicant::NewPersona) | None) => { - state.join.messages.push(MessageType::Error( - "Applying as a new persona starts by making it — press Enter on \ - \"Apply for vetting as a new persona\"." - .to_string(), - )); - None - } - } - } - } - } - - /// Mint a persona from the overlay this flow is hosting, without leaving it. - /// - /// The runtime loop spawns this job through its dispatcher; this loop has - /// none, so it awaits it the way it already awaits the join sequence — - /// raced against the interrupt, so a mint that hangs does not take Ctrl-C - /// with it (R15). Progress lines stream onto the overlay as they arrive - /// rather than after, because minting a DID is not instant and a frozen - /// "Creating persona…" is indistinguishable from a wedged one. - /// - /// On success the persona is persisted and brought online here: a persona - /// the join can see but cannot send as would be worse than none, since the - /// route it unblocks is the one that then fails. - #[allow(clippy::too_many_arguments)] - async fn mint_persona_inline( - &self, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - use crate::state_handler::background_dispatch::{DispatchOutcome, ProgressUpdate}; - - let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); - let Some(job) = super::advance_persona_overlay(state, config, tdk, admin_vta, &progress_tx) - else { - // A phase moved on, or refused and said why on the overlay. - let _ = self.state_tx.send(state.clone()); - return None; - }; - drop(progress_tx); - let _ = self.state_tx.send(state.clone()); - - let run = job.run(); - tokio::pin!(run); - let outcome = loop { - tokio::select! { - outcome = &mut run => break outcome, - Some(update) = progress_rx.recv() => { - if let DispatchOutcome::Progress(ProgressUpdate::PersonaMint(line)) = update - && let Some(o) = state.main_page.create_persona.as_mut() - { - o.messages.push(line); - let _ = self.state_tx.send(state.clone()); - } - } - Ok(interrupted) = interrupt_rx.recv() => return Some(interrupted), - } - }; - - if let Some(minted) = outcome.apply(state) { - // The identity names the listener; the shareable form (a - // did:scid's DID URL with `src`) is what is shown and copied. - let did = minted.did.clone(); - let shareable = minted.shareable_did(); - match minted.persist(config, tdk, profile).await { - Ok(persona_id) => { - state.join.minted_persona = Some(persona_id); - let copied = crate::clipboard::copy_to_clipboard(&shareable).is_ok(); - if let Some(o) = state.main_page.create_persona.as_mut() { - o.phase = - crate::state_handler::main_page::content::CreatePersonaPhase::Done; - o.did = Some(shareable.clone()); - o.copied = copied; - o.messages.push("Persona created.".to_string()); - } - state.main_page.sync_from_config(config); - state - .main_page - .log(format!("Created persona DID {shareable}")); - // Bring it online — in the background. The join is about to - // offer it as a way in, and a persona that cannot send is - // not one; but this loop reads no keys while it awaits, so - // waiting here (up to `add_listener`'s connect bound) left - // Esc and F10 unanswered on a page that had nothing left to - // do. Started and left: the submit waits, bounded, for the - // socket right before the request goes out - // (`await_persona_online`), which is where being online - // matters. A no-op in State A, which has no service. - spawn_persona_listener(messaging, config, tdk, persona_id, &did).await; - state - .join - .info("Connecting the new persona to its mediator…".to_string()); - } - Err(e) => { - // The DID exists at the VTA but is not in the config. Say so - // plainly: it is not lost, and a retry would mint a second. - if let Some(o) = state.main_page.create_persona.as_mut() { - o.phase = - crate::state_handler::main_page::content::CreatePersonaPhase::Failed; - o.messages - .push(format!("Minted, but could not be saved: {e}")); - } - state - .main_page - .log_error("Persona minted but not saved", &e); - } - } - } - let _ = self.state_tx.send(state.clone()); - None - } - - /// Go on with a join once any vetting has been dealt with: collect the - /// community's invitations, then choose the identity to present, or mint - /// one on a first join. `Some` when the person cancelled mid-sequence. - #[allow(clippy::too_many_arguments)] - async fn continue_join( - &self, - vtc_did: String, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - state.join.vetting = None; - // Idempotency is now per-persona (R-B-9): a community may - // be joined as more than one persona, so the duplicate - // check happens once the identity is chosen (in the - // sequence) rather than at the community level here. - // Identity first: collect the invitations available for - // this community so each persona can be badged with its - // usable-invitation count, then let the operator pick the - // identity to present (the invitation choice, if any, - // follows for the chosen persona). - ensure_invitations(state, admin_vta, tdk.did_resolver(), &vtc_did).await; - let options = build_persona_options(config, &state.join.available_vics); - if options.is_empty() { - // First join — nothing to reuse; mint a fresh identity. With no - // persona to vouch for it, the new one cannot present an invitation - // (`mint_can_present` is false); the loaded one is kept so the - // sequence stores it in the vault and says why it went unused. - state.join.present_invitation = mint_can_present(config, state, &vtc_did); - state.join.pending_vtc = Some(vtc_did.clone()); - if let Some(context_id) = offer_contexts(state, config, IdentityPick::Mint, &vtc_did) { - return self - .launch_join_sequence( - IdentityPick::Mint, - vtc_did, - context_id, - interrupt_rx, - state, - tdk, - config, - admin_vta, - profile, - messaging, - ) - .await; - } - } else { - state.join.pending_vtc = Some(vtc_did); - state.join.persona_options = options; - state.join.identity_selected = 0; - state.join.reuse_confirm = None; - state.join.page = JoinPage::IdentityChoice; - } - let _ = self.state_tx.send(state.clone()); - None - } - - /// Move to the progress page and run [`run_join_sequence`] for the chosen - /// identity, raced against the interrupt (R15). Returns `Some(interrupted)` - /// when the user cancelled mid-sequence (the caller then exits the flow); - /// `None` when the sequence ran to its own success/failure terminal. - #[allow(clippy::too_many_arguments)] - async fn launch_join_sequence( - &self, - choice: IdentityPick, - vtc_did: String, - context_id: String, - interrupt_rx: &mut broadcast::Receiver, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - messaging: Option<&Messaging>, - ) -> Option { - // A community that asks the applicant to tell it about themselves is - // answered before anything else happens: which face, and exactly what - // it would send. The launch waits on the Answers page and resumes from - // `JoinAnswersChoose`. - let asked = config.private.vetting.requested_attributes(&vtc_did); - let answered = state - .join - .answers - .as_ref() - .is_some_and(|a| a.approved.is_some()); - if !asked.is_empty() && !answered { - open_answers(state, admin_vta, asked, (choice, vtc_did, context_id)).await; - let _ = self.state_tx.send(state.clone()); - return None; - } - - // A directory statement the community published but this client could - // not read stops the join: the person would otherwise apply without - // seeing what joining makes visible about them (`manifest/0.4`). - if let Some(DirectoryStatement::Malformed(why)) = config.private.vetting.directory(&vtc_did) - { - let name = vetting_actions::community_display(config, &vtc_did); - state.join.page = JoinPage::Progress; - state.join.messages.clear(); - state.join.fail(format!( - "{name} published a statement of what its member directory would show about you, and it could not be read ({}). Nothing has been sent: joining without seeing it is not offered. Ask the community to fix it, then try again.", - sanitize_display(why, 200) - )); - let _ = self.state_tx.send(state.clone()); - return None; - } - - // Then the trust-registry question, last, so it is the final thing the - // person sees before the request goes out — and on every join, because - // there is no right answer to assume. Every launch passes through here - // (identity, invitation, context, vetting and answers all end in this - // function), so no path can submit without the person having answered. - let registry_answered = state - .join - .registry - .as_ref() - .is_some_and(|r| r.answered_for(&vtc_did)); - if !registry_answered { - let community_name = config.agent_name_for(&vtc_did).map(str::to_string); - let review = join_review(config, state, &vtc_did, &choice); - let directory = match config.private.vetting.directory(&vtc_did) { - Some(DirectoryStatement::Stated(d)) => { - Some(crate::state_handler::join::DirectoryReview { - sentences: d.describe(|label| sanitize_display(label, 64)), - statement_digest: d.statement_digest.to_string(), - }) - } - _ => None, - }; - open_registry_choice(state, community_name, (choice, vtc_did, context_id)); - if let Some(registry) = state.join.registry.as_mut() { - registry.review = Some(review); - registry.directory = directory; - } - let _ = self.state_tx.send(state.clone()); - return None; - } - - // A criterion asking for a credential a wallet presents: offer to - // collect it now, before the request goes out, so the submit can name - // the criterion and the community counts the evidence it already holds. - if let Some(offer) = wallet_offer(config, state, &vtc_did, &choice) { - state.join.wallet = Some(WalletStep { - parked: Some((choice, vtc_did, context_id)), - ..offer - }); - state.join.page = JoinPage::Wallet; - state.join.processing = false; - let _ = self.state_tx.send(state.clone()); - return None; - } - - // A community that hides its vetters takes a proof bound to a challenge - // it issues — once, and fresh for each attempt (see `challenge_step`). - // Asked for here, after every question the person answers, so the - // challenge is as young as it can be when the proof is built over it. - // The flow hands the wait to the loop that can hear the answer and comes - // back through `JoinEntry::Challenge`, which lands here again. - let fresh = std::mem::take(&mut state.join.challenge_fresh); - let step = challenge_step( - presented_application(config, &vtc_did, &choice), - state.join.hears_replies, - fresh, - ); - if step == ChallengeStep::Ask - && let IdentityPick::Reuse(persona) = choice - { - state.join.page = JoinPage::Progress; - state.join.completed = Completion::NotFinished; - state.join.messages.clear(); - let name = vetting_actions::community_display(config, &vtc_did); - match ask_challenge(config, tdk, messaging, &vtc_did, persona).await { - Ok(document_id) => { - // Locked while the answer is awaited: the wait is bounded - // (`CHALLENGE_WAIT`), and nothing on this page can usefully - // be done in the meantime. - state.join.processing = true; - state.join.waiting_on_challenge = true; - state.join.info(format!( - "This community hides its vetters. Asking {name} for a fresh challenge \ - to bind your proof to — this gives up after {} seconds. Nothing has \ - been sent yet; Esc stops waiting.", - CHALLENGE_WAIT.as_secs() - )); - state.join.challenge_wait = Some(AwaitingChallenge { - vtc_did, - document_id, - asked_at: std::time::Instant::now(), - choice, - context_id, - }); - } - Err(reason) => state.join.fail(challenge_failed_words(&name, &reason)), - } - let _ = self.state_tx.send(state.clone()); - return None; - } - - // Move to the progress page and lock input. - state.join.page = JoinPage::Progress; - state.join.processing = true; - state.join.completed = Completion::NotFinished; - state.join.messages.clear(); - state.join.info(format!("Joining {vtc_did}…")); - let _ = self.state_tx.send(state.clone()); - - // R15: race the multi-step VTA sequence against the interrupt so Ctrl-C / - // Exit stay live for its whole (network-bound) duration. On interrupt the - // sequence future is DROPPED — cancelled at whatever `.await` it parked - // on. `minted_persona` is the only handle to mid-sequence persisted state - // (`mint_persona_into` writes a persona before the receipt), so a cancel - // after that point rolls it back. It lives outside the future so it stays - // readable after the drop. A *reused* persona is never set here, so it is - // never rolled back. - let mut minted_persona: Option = None; - // The listener [`start_persona_listener`] installed, for the same reason - // and by the same discipline as `minted_persona`: it is state the - // sequence created before the join was committed, and a cancel drops - // the future at whatever await it parked on, so the only place that can - // still tear it down is out here. Left behind, it is a mediator socket - // held open for a persona that was just rolled back. - let mut started_listener: Option = None; - // Captured before the mint so a rollback (cancel or failure) can restore - // it — `mint_persona_into` overwrites `public.friendly_name` with the - // attempted community's persona name. - let prior_friendly_name = config.public.friendly_name.clone(); - let sequence = run_join_sequence( - self, - state, - tdk, - config, - admin_vta, - profile, - vtc_did, - choice, - context_id, - &mut minted_persona, - &prior_friendly_name, - messaging, - &mut started_listener, - ); - let interrupted = race_against_interrupt(sequence, interrupt_rx).await; - - if let Some(interrupted) = interrupted { - if let (Some(service), Some(listener_id)) = (messaging, started_listener.as_deref()) { - service.remove_listener(listener_id).await; - } - if let Some(persona_id) = minted_persona - && !config.account.persona_referenced(&persona_id) - { - rollback_minted_persona(config, persona_id, state, profile, &prior_friendly_name); - } - state.join.processing = false; - state.join.completed = Completion::CompletedFail; - state.join.info( - "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA.", - ); - state.main_page.log("Join cancelled by user."); - let _ = self.state_tx.send(state.clone()); - return Some(interrupted); - } - - state.join.processing = false; - let _ = self.state_tx.send(state.clone()); - None - } -} - /// Open the invitation step for a join as `persona_id`: this community's valid /// invitations bound to that persona, plus one loaded by hand. fn open_invitation_choice(state: &mut State, config: &Config, persona_id: PersonaId) { @@ -3072,34 +1589,20 @@ fn build_persona_options(config: &Config, vics: &[AvailableVic]) -> Vec, - resolver: &affinidi_tdk::did_resolver::DIDCacheClient, - vtc_did: &str, -) { - if state.join.invitations_for.as_deref() == Some(vtc_did) { - return; - } - state.join.available_vics = collect_available_vics(state, admin_vta, resolver, vtc_did).await; - state.join.invitations_for = Some(vtc_did.to_string()); -} - +/// Two screens need them — the vetting page, to say whether presenting one is +/// a way in, and the identity step, to badge each persona with its count — and +/// the collection is a vault listing plus a fetch per descriptor, so it runs off +/// the loop and once per community: `JoinState::invitations_for`, rather than +/// the emptiness of the list, is what says it has been done (holding none is a +/// legitimate answer). async fn collect_available_vics( - state: &State, + loaded: Option<&serde_json::Value>, admin_vta: Option<&VtaClient>, resolver: &affinidi_tdk::did_resolver::DIDCacheClient, vtc_did: &str, @@ -3146,7 +1649,7 @@ async fn collect_available_vics( // A loaded VIC (--invitation / paste) that matches this community, if not // already surfaced from the vault. - if let Some(vic) = state.invitation_credential.as_ref() + if let Some(vic) = loaded && usable(vic).await && let Some(av) = to_available_vic(vic) && !out.iter().any(|o| o.id == av.id) @@ -3171,26 +1674,6 @@ fn to_available_vic(body: &serde_json::Value) -> Option { }) } -/// Outcome of a `join_flow` invocation. -pub(crate) enum JoinExit { - /// User cancelled / finished — return to the main page and resume the - /// caller's loop. Carries the just-joined session when a join succeeded, so - /// the runtime loop can register it + start its listener live (R-B-5). - Returned(Option), - /// The community was asked what it requires. The caller keeps the join - /// screen up, waits for the answer, and enters the flow again - /// ([`AwaitingRequirements`]). - AwaitRequirements(AwaitingRequirements), - /// The launch is parked on the challenge its hidden-vetting proof binds; - /// the caller hears the answer and enters the flow again - /// ([`AwaitingChallenge`]). - AwaitChallenge(AwaitingChallenge), - /// The launch is parked on a wallet session ([`AwaitingWallet`]). - AwaitWallet(AwaitingWallet), - /// Application is exiting (Exit / UXError / interrupt). - Exit(Interrupted), -} - /// Accept a pasted / clipboard-read invitation credential (VIC). /// /// `vtc_did` is `Some` only on the invitation step, where the community is @@ -3428,36 +1911,6 @@ async fn load_invitation_from_vault( serde_json::to_value(got).ok()?.get("credential").cloned() } -/// #1b: build a subject-linkage proof when the presenting DID differs from the -/// loaded VIC's subject — the invited persona authorizes the presenter. Returns -/// `None` on the join-as-subject path (presenter == subject), when no invitation -/// is loaded, or when the subject isn't one of our personas (we can't sign for a -/// key we don't hold; the VTC then refuses the mismatched binding). Best-effort: -/// a signing failure is logged and yields `None`. -async fn build_linkage_proof( - config: &Config, - admin_vta: &VtaClient, - state: &State, - presenter_did: &str, -) -> Option { - let vic = state.invitation_credential.as_ref()?; - let subject = openvtc_core::join::invitation_subject(vic)?; - if subject == presenter_did { - return None; // join-as-subject — no linkage needed - } - let vic_id = openvtc_core::join::invitation_id(vic)?; - match config - .build_subject_linkage(subject, Some(admin_vta), vic_id, presenter_did) - .await - { - Ok(linkage) => Some(linkage), - Err(e) => { - debug!(subject = %subject, error = %e, "subject-linkage proof unavailable"); - None - } - } -} - /// Idempotency decision (R-B-9): is there already a *live* (Active/Pending) /// membership for this VTC? /// @@ -3470,770 +1923,6 @@ fn is_duplicate_membership(config: &Config, vtc_did: &str, persona: PersonaId) - config.account.has_live_membership(vtc_did, persona) } -/// Run the automated mint → sub-context → join-submit → persist sequence. -/// -/// All progress and errors land in `state.join`. On success -/// `state.join.completed` is `CompletedOK` and `created_community` holds the new -/// pending record; on any failure it is `CompletedFail` with the error logged. -/// -/// `minted_persona` is written as soon as the persona is minted-and-persisted so -/// the caller can roll it back if the whole future is cancelled (R15) before the -/// persona is bound to a community. -#[allow(clippy::too_many_arguments)] -async fn run_join_sequence( - handler: &StateHandler, - state: &mut State, - tdk: &TDK, - config: &mut Config, - admin_vta: Option<&VtaClient>, - profile: &str, - vtc_did: String, - choice: IdentityPick, - context_id: String, - minted_persona: &mut Option, - prior_friendly_name: &str, - messaging: Option<&Messaging>, - started_listener: &mut Option, -) { - // Idempotency (R-B-9) was already enforced at submit, before the identity - // choice — no re-check here. - - // The person's trust-registry answer, consumed by this one submit. Taken, - // not read: a later join from the same open flow — another community, or a - // retry after a failure — asks again rather than inheriting it. - let registry = state.join.registry.take(); - let registry_consent = registry.as_ref().is_some_and(|r| r.consent_for(&vtc_did)); - // The directory statement the person was shown, named by its digest, kept - // with the join so a later change to the terms can be told apart. - let directory_statement_digest = registry - .filter(|r| r.community == vtc_did) - .and_then(|r| r.directory) - .map(|d| d.statement_digest); - - // The mint + join sequence needs the admin VTA session. - let Some(admin_vta) = admin_vta else { - state - .join - .fail("VTA session unavailable — cannot join right now."); - return; - }; - - // 2. Resolve the community's verified agent name for display (best-effort). - // A verified name (`example.com/@acme`) is the community's human-readable - // handle; `None` when it has no verifiable name, so the sub-context - // derivation falls back to the DID-derived token (D9). The lookup is also - // seeded into the persisted cache so the communities list shows it without - // waiting for the background sweep. - let display_name = - openvtc_core::agent_name::resolve_verified_name(tdk.did_resolver(), &vtc_did).await; - config.set_cached_agent_name(&vtc_did, display_name.clone(), chrono::Utc::now()); - state.join.display_name = display_name.clone(); - - // 3. Preflight the community's transports, before anything is minted. - // - // A peer that advertises no messaging service cannot be joined at all, and - // discovering that after the persona mint leaves an orphaned identity - // attached to a request nobody could receive. This is a check on what the - // document *offers* — not on whether the peer can actually serve it, which - // no client can know before sending, and which is the peer's defect to fix - // rather than ours to route around. - let transports = openvtc_core::config::peer_messaging_transports(&vtc_did).await; - let Some(peer_preferred) = transports.preferred() else { - state.join.fail(format!( - "This community advertises no messaging transport ({}). Its DID \ - document offers neither a TSP nor a DIDComm service, so a join \ - request cannot reach it. Nothing was created.", - state.join.display_name.as_deref().unwrap_or(&vtc_did) - )); - return; - }; - // What the community *offers* — not yet the wire the submit goes out on. - // That needs our own leg too, and is settled at step 8 once the persona (and - // so the mediator we post through) is known. - state - .join - .info(format!("Community reachable over {peer_preferred}.")); - let _ = handler.state_tx.send(state.clone()); - - let top_context_id = config.account.top_context_id.clone(); - - // 4. The community's context exists before anything is minted into it. A - // retried join reuses a context an interrupted one left behind. - let context_name = display_name - .clone() - .unwrap_or_else(|| shorten_did(&vtc_did, 48)); - match community_context::ensure_context(admin_vta, &top_context_id, &context_id, &context_name) - .await - { - Ok(true) => state.join.info(format!("Created context {context_id}.")), - Ok(false) => state.join.info(format!("Using context {context_id}.")), - Err(e) => { - state.join.fail(format!( - "Could not prepare context {context_id}: {e}. Nothing was created." - )); - return; - } - } - let _ = handler.state_tx.send(state.clone()); - - // Resolve the persona to present: reuse an existing account persona (R-B-3) - // or mint a fresh, self-contained one (D6). Only a *minted* persona is - // recorded in `minted_persona` for rollback — a reused persona pre-exists and - // must never be rolled back. - let (persona_id, persona_did) = match choice { - IdentityPick::Reuse(persona_id) => match config.identities.get(&persona_id) { - Some(ident) => { - // Per-persona idempotency (R-B-9): block a second live membership - // as the *same* persona, while still allowing other personas. - if is_duplicate_membership(config, &vtc_did, persona_id) { - state.join.fail( - "Already a member of (or have a pending request for) this community as this persona.", - ); - return; - } - // A persona's keys live in one context; one minted into a - // sub-context is only ever presented from it. - if let Some(record) = config.account.personas.get(&persona_id) { - let home = community_context::persona_context(record, &top_context_id); - if community_context::is_sub_context(home, &top_context_id) - && home != context_id - { - state.join.fail(format!( - "This persona's keys live in {home}, so it can only join from there." - )); - return; - } - } - let did = ident.persona_did().to_string(); - state.join.info(format!("Reusing persona {did}…")); - let _ = handler.state_tx.send(state.clone()); - (persona_id, did) - } - None => { - state - .join - .fail("Selected persona is unavailable — cannot reuse it."); - return; - } - }, - IdentityPick::Mint => { - // The persona's mediator is the account's VTA mediator: the DID minted via - // the VTA's webvh server advertises that mediator in its DIDComm service, so - // the persona listener must use the same one. Hardcoding `None` (the public - // default) left the persona with no usable mediator — the listener then - // failed with "No Mediator is configured" and retried forever. A VTA that - // advertised no mediator refuses the mint here, before any key or DID is - // created at the VTA: there is no built-in mediator to fall back on. - let Some(mediator) = setup_config::vta_mediator(config) else { - state.join.fail(setup_config::NO_VTA_MEDIATOR); - return; - }; - state.setup.custom_mediator = Some(mediator); - - // 5. Mint a fresh persona into `state.setup` (reusing the setup - // helpers), with every key and the DID in the community's context. - state - .join - .info("Creating persona keys (signing, authentication, encryption)…"); - let _ = handler.state_tx.send(state.clone()); - match vta::create_persona_keys(admin_vta, Some(&context_id)).await { - Ok(keys) => state.setup.did_keys = Some(keys), - Err(e) => { - state - .join - .fail(format!("Failed to create persona keys: {e}")); - return; - } - } - // WebVH update keys. - state.join.info("Creating DID update keys…"); - let _ = handler.state_tx.send(state.clone()); - match vta::create_update_keys(admin_vta, Some(&context_id)).await { - Ok((update, next_update)) => { - state.setup.vta.update_secret = Some(update); - state.setup.vta.next_update_secret = Some(next_update); - } - Err(e) => { - state - .join - .fail(format!("Failed to create update keys: {e}")); - return; - } - } - - // Pick the first WebVH server. Serverless mint is a deliberate follow-up. - state.join.info("Finding a DID hosting server…"); - let _ = handler.state_tx.send(state.clone()); - let server_id = match vta::list_webvh_servers(admin_vta).await { - Ok(servers) => match servers.into_iter().next() { - Some(s) => s.id, - None => { - state.join.fail( - "No WebVH server available from the VTA (serverless mint not yet supported).", - ); - return; - } - }, - Err(e) => { - state - .join - .fail(format!("Failed to list WebVH servers: {e}")); - return; - } - }; - - // Create the persona did:webvh via the server (auto-assigned path). - state - .join - .info(format!("Creating persona DID via {server_id}…")); - let _ = handler.state_tx.send(state.clone()); - // How the new persona is presented: the operator's choice on the - // identity page, else the account's preference (VTI-VTA-036). - let presentation = openvtc_core::config::account::persona_presentation( - &config.account, - state.join.new_persona_did_method, - ); - match vta::create_did_via_server( - admin_vta, - tdk, - &context_id, - &server_id, - WebvhPathMode::AutoAssign, - presentation, - ) - .await - { - Ok(vta::MintedDid { - keys, - did, - document, - locator, - }) => { - // A persona the VTA minted without `#tsp` can never reach a - // TSP-only community — and this join may be to one. Say so - // here rather than letting it surface later as a request - // that goes out and is never answered. - if let Some(warning) = - openvtc_core::config::did::tsp_advertisement_warning(&document) - { - state.join.info(warning); - } - state.setup.did_keys = Some(keys); - state.setup.webvh_address.did = did; - state.setup.webvh_address.document = document; - state.setup.webvh_address.locator = locator; - } - Err(e) => { - state - .join - .fail(format!("Failed to create persona DID: {e}")); - return; - } - } - - // A join mints the persona *unlabelled*. `display_name` here is the - // COMMUNITY's name — naming the persona after it (or, with no - // verified name, after a DID-derived rendering of the VTC DID) put a - // community identifier in the persona's own label, and - // `mint_persona_into` copied that on into `public.friendly_name`, so - // the header announced the community twice and the DID manager - // listed a persona labelled with a community DID. The persona's - // community binding is recorded on the membership record and shown - // beside it; the label is for a name the operator chose. - state.setup.username = String::new(); - - // 5. Persist the persona into the account. `mint_persona_into` writes the - // persona record + runtime identity + key info to disk *immediately* (a - // synchronous `Config::save`), so from here until the community record is - // persisted (step 9) the on-disk config holds a persona with no community. - // Record its id so a cancel (R15) or later failure can roll it back. - let persona_id = - match Config::mint_persona_into(config, &state.setup, tdk, profile).await { - Ok(id) => id, - Err(e) => { - state.join.fail(format!("Failed to save persona: {e}")); - return; - } - }; - *minted_persona = Some(persona_id); - // Where its keys live, which decides every context it can join from. - if let Some(record) = config.account.personas.get_mut(&persona_id) { - record.origin_context_id = context_id.clone(); - } - let persona_did = state.setup.webvh_address.did.clone(); - state.join.info(format!("Persona created: {persona_did}")); - let _ = handler.state_tx.send(state.clone()); - (persona_id, persona_did) - } - }; - // Only a freshly-minted persona is rolled back on a later failure; a reused - // persona pre-existed and is left intact. - let minted = minted_persona.is_some(); - - // 6. The membership lives in the context chosen for it, prepared at step 4. - // A vetting application's context was offered as the only choice, so the - // community sees the face the vetters checked. - let sub_context_id = context_id; - - // 8. Submit the join request to the VTC over DIDComm. The persona is - // the authcrypt sender (the VTC reads the applicant from the - // envelope — no holder-binding signature, and a did:webvh persona - // can't use the VTC's did:key-only REST signature path). The minted - // persona's runtime identity (ATM profile + mediator) was built into - // `config.identities` by `mint_persona_into`. The VTC's - // submit-receipt (with the authoritative requestId) returns - // asynchronously to the persona's mediator; until that receipt - // handler lands, the request message id is the correlation handle - // stored on the Pending record. - state.join.info("Submitting join request…"); - let _ = handler.state_tx.send(state.clone()); - - if tdk.atm.is_none() { - state - .join - .fail("Messaging (ATM) unavailable — cannot submit the join request."); - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - let (applicant_did, persona_mediator) = match config.identities.get(&persona_id) { - Some(ident) => ( - ident.persona_did().to_string(), - ident.mediator_did.clone().unwrap_or_default(), - ), - None => { - state - .join - .fail("Persona identity unavailable after mint — cannot submit."); - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - }; - - // Open the applicant's mailbox before knocking on the door. The VTC decides - // a well-formed invited join in under a second and pushes the VMC + role VEC - // straight back, so the reply is in flight while this sequence is still - // running — and a mediator only *live-streams* a message whose recipient is - // connected at the instant it lands. Bringing the socket up after the - // sequence returned (which is what `register_joined_session` did alone) left - // a window as long as the operator's own reading speed, and a community - // reply that landed inside it was stored and never pushed: the VTC's outbox - // read "sent", the mediator held two messages, and the membership sat - // Pending with nothing in any log to say why. Connecting first closes the - // window at the only end we control. - // - // Started here, waited for at the submit ([`await_persona_online`]): the - // connect is I/O the invitation resolution and VP build below do not depend - // on, so overlapping them spends the connect out of work already being done - // rather than out of the operator's time. - *started_listener = start_persona_listener( - handler, - state, - messaging, - config, - tdk, - persona_id, - &applicant_did, - ) - .await; - // #2: resolve the VIC to present for THIS community. A VIC's issuer is the - // community's VTC DID, so a presentable invitation must match `vtc_did` and - // be unexpired — presenting a mismatched or expired one only earns a VTC - // rejection (and reads as a failed invitation rather than an open request). - // An explicitly loaded VIC (--invitation / paste) is still stored in the - // vault regardless (its durable home), but only *presented* when it matches; - // otherwise we fall back to a community-matched VIC the vault already holds, - // else submit as an open request. Setting `state.invitation_credential` to - // the resolved VIC keeps the VP, the linkage proof, and the summary all - // consistent with what is actually presented. All vault calls are - // best-effort — the join proceeds regardless. - let loaded = state.invitation_credential.take(); - let mut presentable: Option = None; - if state.join.present_invitation { - // The operator chose to present an invitation (or one was available and - // they accepted the default). Resolve the one to present. - if let Some(vic) = loaded { - if let Err(e) = admin_vta.cred_vault_receive(vic.clone(), None).await { - debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); - } - if !openvtc_core::join::invitation_matches_community(&vic, &vtc_did) { - state.join.info( - "Loaded invitation is for a different community — \ - looking for one that matches…", - ); - } else if openvtc_core::join::invitation_is_expired(&vic, Utc::now()) { - state - .join - .info("Loaded invitation has expired — looking for a valid one…"); - } else { - presentable = Some(vic); - } - } - if presentable.is_none() { - presentable = load_invitation_from_vault(admin_vta, &vtc_did).await; - } - } else if let Some(vic) = loaded { - // The operator chose to join *without* an invitation. Still store a - // loaded VIC in the vault (its durable home) but present nothing — this - // is what honours the choice over the vault fallback above. - let foreign = foreign_invitation_subject(config, &vic); - if let Err(e) = admin_vta.cred_vault_receive(vic, None).await { - debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); - } - match foreign { - // Not a choice the operator made: say what stopped the invitation. - Some(subject) => state.join.info(format!( - "Your invitation names {}, which is not one of your personas — \ - the community accepts it only from that DID, so this is an open \ - request (awaiting approval).", - shorten_did(&sanitize_display(&subject, 256), 48) - )), - None => state.join.info( - "Joining without an invitation — submitting an open request (awaiting approval).", - ), - } - } - // Completeness gate before presenting: a VIC resolved from the vault may - // predate the ingest-time validation (or have lost fields in storage). An - // incomplete VIC is unusable — the VTC can't extract it and silently refers - // the join to a moderator — so drop it here and fall to an open request with - // a clear reason, rather than presenting junk. - if let Some(vic) = &presentable - && let Err(why) = - openvtc_core::join::verify_invitation_credential(vic, tdk.did_resolver(), Utc::now()) - .await - { - state.join.info(format!( - "Resolved invitation is not usable ({why}) — submitting as an open request instead." - )); - presentable = None; - } - state.invitation_credential = presentable; - state.join.has_invitation = state.invitation_credential.is_some(); - state.join.presented_invitation = state.invitation_credential.as_ref().map(|vic| { - let subject = openvtc_core::join::invitation_subject(vic).map(str::to_string); - PresentedInvitation { - id: openvtc_core::join::invitation_id(vic) - .unwrap_or_default() - .to_string(), - // Verified-only, from the persisted cache: the subject is one - // of our own personas, so the sweep normally has a name for it. - subject_agent_name: subject - .as_deref() - .and_then(|s| config.agent_name_for(s)) - .map(|n| sanitize_display(n, 256)), - subject, - } - }); - - // Present the holder VP. When a matching, unexpired invitation (VIC) is - // resolved it rides in the VP's `verifiableCredential` array; the VTC - // verifies it and auto-admits on a valid, trusted, unconsumed invitation (no - // manual approval). With no presentable invitation the join is an open - // request the community reviews and approves manually. - if state.invitation_credential.is_some() { - state - .join - .info("Presenting your invitation credential to the community…"); - } else if state.join.present_invitation { - // Wanted to present one, but none resolved to a usable VIC. - state.join.info( - "No valid invitation for this community — \ - submitting as an open request (awaiting approval).", - ); - } - // (When the operator chose to join without an invitation, the note was - // already surfaced above where the VIC was suppressed.) - let _ = handler.state_tx.send(state.clone()); - // Subject-linkage (#1b): when the presenting DID differs from the VIC - // subject, prove the subject authorized this presenter (signed with the - // subject persona's key). On the join-as-subject path (#1a) this is `None`. - let linkage = build_linkage_proof(config, admin_vta, state, &applicant_did).await; - if linkage.is_some() - && let Some(subject) = state - .invitation_credential - .as_ref() - .and_then(|vic| openvtc_core::join::invitation_subject(vic)) - { - // Presenting through another persona is a disclosure: the community - // learns that the persona it invited vouches for this one. - let name = config - .agent_name_for(subject) - .map(|n| sanitize_display(n, 256)) - .unwrap_or_else(|| shorten_did(subject, 48)); - state.join.info(format!( - "The invitation names {name}, which signs for this persona — the community \ - will see that the two are linked." - )); - } - let mut vp = openvtc_core::join::build_join_vp( - &applicant_did, - state.invitation_credential.as_ref(), - linkage.as_ref(), - ); - // The community's questions, answered from the face the holder chose on the - // Answers page — through a disclosure, so the holder's own history records - // what they told the community. The persona wears the face here first: - // a disclosure is always of what a persona wears in a context. - let approved_answers = state.join.answers.as_ref().and_then(|a| { - a.approved - .clone() - .map(|(face, values)| (a.asked.clone(), face, values)) - }); - let attributes = match approved_answers { - None => Vec::new(), - Some((asked, face_id, approved)) => { - state - .join - .info("Answering the community's questions from your face…"); - let _ = handler.state_tx.send(state.clone()); - let answered = answer_from_face( - admin_vta, - &sub_context_id, - &applicant_did, - &vtc_did, - &asked, - &face_id, - &approved, - ) - .await; - match answered { - Ok(attributes) => attributes, - Err(e) => { - state.join.fail(e); - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) - { - service.remove_listener(&listener_id).await; - } - if minted { - rollback_minted_persona( - config, - persona_id, - state, - profile, - prior_friendly_name, - ); - } - return; - } - } - } - }; - - // Peer identity vetting: present the statements gathered for this community - // under this persona, and name the requirements they were gathered against - // so the community applies the same criterion (vetting-process.md §10.1). - // Under a hidden-vetting criterion the proof is built here, bound to a challenge of this - // submission's own, and rides in `extensions` (design §8). The statements themselves never - // travel: there are none that name a vetter. - // - // Every way the proof can be missing stops the join here. Submitting without it sends no - // vetting at all — the named statements never travel on this path — so the community would - // decide on nothing while the holder believed they had presented their vetting. - let hidden_refusal = match config - .private - .vetting - .application_mut(&vtc_did, persona_id) - .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) - { - None => None, - Some(application) => hidden_proof_refusal(application, admin_vta, &mut state.join).await, - }; - if let Some(refusal) = hidden_refusal { - state.join.fail(refusal); - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) { - service.remove_listener(&listener_id).await; - } - if minted { - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - } - return; - } - let protocol = config.private.vetting.protocol_for(&vtc_did); - let presentation = match config.private.vetting.application(&vtc_did, persona_id) { - Some(application) if application.join_did == applicant_did => { - let statements = application.presentable_statements(chrono::Utc::now()); - if !statements.is_empty() { - state.join.info(format!( - "Presenting {} vetting statement(s) to the community…", - statements.len() - )); - } - openvtc_core::join::attach_credentials(&mut vp, statements); - openvtc_core::join::JoinPresentation { - vp, - extensions: application.join_extensions(), - attributes, - protocol, - // The vetting criterion this application was made under. - criterion: application.requirements_digest.clone(), - registry_consent, - } - } - // No application: the community decides under the first criterion this - // submission meets — an invitation, a credential it recognises, or - // review — which is the route the person chose. - // With wallet evidence, under the criterion it was collected for: the - // community counts it only there. - _ => openvtc_core::join::JoinPresentation { - attributes, - protocol, - registry_consent, - criterion: match &state.join.wallet_outcome { - Some(WalletOutcome::Presented(digest)) => Some(digest.clone()), - _ => None, - }, - ..vp.into() - }, - }; - // Settle the wire (TSP needs both legs; `choose_submit_route` says why). - let route = - openvtc_core::join::sequence::choose_submit_route(&vtc_did, &persona_mediator, &transports) - .await; - if let Some(note) = route.note { - state.join.info(note.describe()); - let _ = handler.state_tx.send(state.clone()); - } - let submit_transport = route.transport(); - // Last gate before the request goes out: the socket started above has had - // the invitation + VP work to come up, so this is normally already true. - let applicant_online = await_persona_online(handler, state, messaging, &applicant_did).await; - - let request_id = match openvtc_core::join::sequence::submit( - config, - tdk, - persona_id, - &vtc_did, - &route, - presentation, - ) - .await - { - Ok(id) => id, - // A persona whose key cannot be read cannot apply. Nothing was sent; - // the persona is kept, as it always was on this path. - Err(openvtc_core::join::sequence::SubmitError::Signer(e)) => { - state - .join - .fail(format!("Couldn't sign the application: {e}")); - return; - } - Err(e) => { - state - .join - .fail(format!("Failed to submit join request: {e}")); - // Nothing was submitted, so nothing will reply — release the socket - // we opened for this attempt before the persona is rolled back, or - // it outlives the identity it speaks for. - if let (Some(service), Some(listener_id)) = (messaging, started_listener.take()) { - service.remove_listener(&listener_id).await; - } - rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); - return; - } - }; - - // The challenge a hidden proof was bound to is spent now: the community - // consumes it the moment it reads the proof, whatever it then decides. Kept, - // it would be bound into the next attempt's proof and refused there. Saved - // with the membership below. - if let Some(application) = config - .private - .vetting - .application_mut(&vtc_did, persona_id) - .filter(|a| a.join_did == applicant_did) - { - application.hidden_submission_sent(); - } - - // 9. Record the pending membership and persist. - let record = sequence::pending_record( - vtc_did.clone(), - display_name, - sub_context_id, - persona_id, - request_id, - Utc::now(), - submit_transport, - ); - let record = CommunityRecord { - directory_statement_digest, - ..record - }; - config.account.add_membership(record.clone()); - if let Err(e) = save_config(config, profile) { - state - .join - .fail(format!("Failed to save community record: {e}")); - return; - } - - // 10. Sent — refresh the communities panel and surface the relaunch prompt. - // - // "Sent", not "delivered". Every word below is deliberately about what this - // client actually witnessed: `submit_join_request` resolves `Ok` when *our - // own mediator* accepts the frame, which says nothing about whether the - // community's mediator received it or the community ever read it. Claiming - // otherwise is R1.1 — a send `Ok` is not a delivery — and it is the reason a - // join that died two hops away read here as an unqualified success while the - // community's log showed no trace of it. - // - // The acknowledgement is asynchronous and cannot be waited for here: the - // join flow's `select!` loop reads UI actions only, so the inbound dispatch - // that reconciles a receipt (`CommunityRecord::receipt_at`) does not run - // until this sequence returns. `pending_unacknowledged` flags the record in - // the communities panel if nothing arrives within `PENDING_ACK_GRACE_SECS`, - // so the honest thing to do here is name what is still outstanding and point - // at where the answer will show up. - // - // What the pre-submit connect changed is *where* an early reply waits, not - // when it is read: the persona's socket is up, so a reply that arrives - // during this window is streamed and queued on the DIDComm event channel for - // the runtime loop to drain, rather than sitting unstreamed in a mailbox - // whose owner never connected. - state.main_page.sync_from_config(config); - // Durable, unlike the ceremony commentary in `state.join`, which is - // transient UI and gone on the next launch. A submitted join is the thing - // you most want a record of when it does not complete. - // - // Whether the applicant was connected is part of that record. A join - // submitted from an unconnected persona is still valid — the community - // admits, and `Messaging::pickup_stored` collects the reply when the socket - // comes up — but it is the one that takes the slow path, and an operator - // reading this log later cannot otherwise tell that from a community that - // never answered. - config.private.logs.insert( - LogFamily::Community, - format!( - "Join request sent to ({}) as persona ({}) over {} — Pending, not yet acknowledged.{}", - state.join.display_name.as_deref().unwrap_or(&vtc_did), - persona_did, - submit_transport, - if applicant_online { - "" - } else { - " The applicant was not connected at submit; a reply that arrives before it \ - connects is collected from the mediator rather than streamed." - } - ), - ); - state.main_page.log(format!( - "Join request sent over {submit_transport} — Pending in your Communities list, awaiting \ - the community's acknowledgement." - )); - state.join.created_community = Some(record); - state.join.created_persona_did = Some(persona_did.clone()); - state.join.registry_consent_sent = Some(registry_consent); - state.join.completed = Completion::CompletedOK; - state.join.info(format!( - "Join request sent over {submit_transport}. Waiting for the community to acknowledge it — \ - it's Pending in your Communities list, which will flag it if no response arrives." - )); -} - /// How long the join sequence waits for the applicant persona's mediator socket /// before submitting anyway (R1.2: the wait is finite). /// @@ -4244,77 +1933,12 @@ async fn run_join_sequence( /// operator a pause, not the join: the submit goes out either way. const PERSONA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Install the applicant persona's mediator listener so its socket is coming up -/// while the rest of the sequence runs. Paired with [`await_persona_online`], -/// which does the waiting at the submit. -/// -/// Returns the listener id **only when this call installed one** — that is the -/// caller's handle for tearing it down if the join then fails or is cancelled. -/// `None` covers every case where there is nothing to undo: no messaging service -/// (State-A), a reused persona that is already live, or an install that failed. -/// -/// Failure is never fatal. A join whose applicant could not connect is still a -/// valid join: it is submitted, the community still admits, and the reply waits -/// in the mailbox for the persona's listener to pick up — on the mediator's -/// redelivery when the socket does come up, or on the next launch. So the -/// outcome is reported and the sequence continues (R6.4: the operator is told -/// what happened, not handed one generic hint). -async fn start_persona_listener( - handler: &StateHandler, - state: &mut State, - messaging: Option<&Messaging>, - config: &Config, - tdk: &TDK, - persona_id: PersonaId, - applicant_did: &str, -) -> Option { - // State-A has no service to install into. The degraded loop restarts into - // the full pipeline after a first join, and startup registration brings the - // persona up there. - let service = messaging?; - let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); - - // A reused persona already serving another community has its socket — the - // one identity, one listener rule (D1/D11). Re-adding would error on the - // duplicate id, and it is not ours to claim: tearing it down on a cancelled - // join would take the other community's session with it. - if service.has_listener(&listener_id).await { - return None; - } - - let Some(spec) = - openvtc_core::didcomm::persona_listener_config_for(config, tdk, persona_id).await - else { - // The mint wrote this identity moments ago, so this is unexpected — but - // it costs the join nothing, so log it and carry on. - debug!( - persona = %applicant_did, - "no listener config for the applicant persona; submitting without a live session" - ); - return None; - }; - if let Err(e) = openvtc_core::didcomm::add_listener(service, &spec).await { - state.join.info(format!( - "Couldn't open this persona's mediator session ({e}) — submitting anyway; the \ - community's reply will be collected when it connects." - )); - let _ = handler.state_tx.send(state.clone()); - return None; - } - - state - .join - .info("Connecting the new persona to its mediator…"); - let _ = handler.state_tx.send(state.clone()); - Some(listener_id) -} - /// Start `persona_id`'s listener without waiting for it to connect. /// /// The connect runs on its own task, so the page that made the persona keeps /// answering keys meanwhile. What it needs from the config is read here, before /// the task starts; the task only connects. A failure is logged — the submit's -/// own wait for the socket ([`await_persona_online`]) is what tells the person +/// own wait for the socket (bounded by [`PERSONA_CONNECT_TIMEOUT`]) is what tells the person /// whether the request went out live. async fn spawn_persona_listener( messaging: Option<&Messaging>, @@ -4344,104 +1968,6 @@ async fn spawn_persona_listener( }); } -/// Wait — bounded — for the applicant persona's socket to be live, immediately -/// before the join request goes out. -/// -/// Split from [`start_persona_listener`] so the connect overlaps the invitation -/// resolution and VP build rather than adding to them; by the time this runs the -/// answer is normally already `Connected` and it returns at once. A persona with -/// no listener at all (State-A, or an install that failed) is nothing to wait -/// for, and the submit proceeds either way. -/// Returns whether the applicant was live at the moment of the submit — which -/// the durable join record then states, because "sent while offline" is the one -/// fact that explains a join taking the slow (collect-on-connect) path, and it -/// has to survive the relaunch you make to go looking. -async fn await_persona_online( - handler: &StateHandler, - state: &mut State, - messaging: Option<&Messaging>, - applicant_did: &str, -) -> bool { - let Some(service) = messaging else { - return false; - }; - let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); - if !service.has_listener(&listener_id).await { - return false; - } - - let online = match service - .wait_connected(&listener_id, PERSONA_CONNECT_TIMEOUT) - .await - { - Ok(()) => { - state - .join - .info("Persona connected — ready to receive the community's reply."); - true - } - Err(e) => { - // Installed but not up yet. The listener's own restart policy keeps - // working on it, so this is a slow connect, not a dead one. - debug!(listener = %listener_id, error = %e, "applicant persona not connected before submit"); - state.join.info( - "The persona's mediator session is still connecting — submitting now; the \ - community's reply will be collected once it is up.", - ); - false - } - }; - let _ = handler.state_tx.send(state.clone()); - online -} - -/// Have the VTA build a hidden-vetting application's proof for this submission -/// ([`openvtc_core::vetting::pcs_vta::prove`]), or say why it cannot be. -/// -/// `None` when the proof is built and will ride in `extensions`. `Some` is the sentence the -/// progress page fails with — each naming what the holder can do, because "the proof could not -/// be built" alone leaves them nowhere to go. -async fn hidden_proof_refusal( - application: &mut openvtc_core::vetting::applicant::Application, - admin_vta: &VtaClient, - join: &mut crate::state_handler::join::JoinState, -) -> Option { - let challenge = match hidden_proof_precheck(application) { - Ok(challenge) => challenge, - Err(refusal) => return Some(refusal), - }; - // Hidden parameters but no application at the VTA: no vetter's attestation has arrived. - let (Some(params), Some(app)) = (application.hidden.clone(), application.hidden_app.clone()) - else { - return Some(NO_ATTESTATION.to_string()); - }; - join.info("Proving that enough vetters vetted you, without naming them…".to_string()); - let digest = application.requirements_digest.clone().unwrap_or_default(); - match openvtc_core::vetting::pcs_vta::prove( - admin_vta, - &app.application_id, - ¶ms, - &digest, - &challenge, - ) - .await - { - Ok(proof) => { - application.set_hidden_submission(proof); - None - } - Err(e) - if e.is(openvtc_core::vetting::pcs_vta::tasks::prove::v0_1::error_codes::NO_USABLE_ATTESTATION) => - { - Some(NO_ATTESTATION.to_string()) - } - Err(e) => Some(format!( - "The zero-knowledge proof of your vetting could not be built ({e}). Nothing was sent \ - — joining without it would present no vetting at all." - )), - } -} - /// Everything that stops a hidden-vetting join before the VTA is asked for a proof: the /// community's challenge it must bind, or the sentence the join fails with. fn hidden_proof_precheck( @@ -4501,14 +2027,6 @@ const NO_ATTESTATION: &str = "This community hides its vetters, and no vetter's reached this application yet, so there is nothing to prove. \ Nothing was sent."; -/// Persist the config, abstracting over the openpgp-card touch prompt. -/// Roll back a just-minted persona when a later join step fails before the -/// persona is bound to a community. The mint (`mint_persona_into`) persists the -/// persona record + runtime identity + key info *before* the submit; without -/// this, a failed join (e.g. a submit error) leaves an orphan persona in the -/// account — a spurious identity with no membership, which then confuses the -/// active-identity display. Best-effort re-save; the VTA-side keys are cleaned -/// separately via the DID manager. /// Wear `face_id` in the community's context, preview the disclosure of what /// it asks, refuse if that differs from what the holder approved, and release /// it. `Err` is the sentence the progress page shows; nothing was sent. @@ -4580,7 +2098,7 @@ async fn open_answers( } /// What the join request for `vtc_did` as `pick` will carry, read from the -/// same records `run_join_sequence` builds it from: the persona's DID, the +/// same records the launch builds it from: the persona's DID, the /// invitation chosen, the application's statements or hidden proof, and the /// face's approved answers. fn join_review( @@ -4715,7 +2233,7 @@ fn vetting_decision(config: &Config, vtc_did: &str, app: &Application) -> Option } /// The vetting a join by `persona_id` to `vtc_did` will present, whatever is -/// chosen on the invitation page — `run_join_sequence` attaches it either way. +/// chosen on the invitation page — the launch attaches it either way. fn vetting_in_hand( config: &Config, vtc_did: &str, @@ -4784,67 +2302,12 @@ async fn load_shown(answers: &mut JoinAnswers, client: &VtaClient) { } } -fn rollback_minted_persona( - config: &mut Config, - persona_id: PersonaId, - state: &State, - profile: &str, - prior_friendly_name: &str, -) { - sequence::forget_minted_persona( - config, - persona_id, - state.setup.did_keys.as_ref(), - prior_friendly_name, - ); - if let Err(e) = save_config(config, profile) { - debug!("persona rollback re-save failed after a failed join: {e}"); - } -} - -fn save_config(config: &Config, profile: &str) -> Result<(), openvtc_core::errors::OpenVTCError> { - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| { - eprintln!("Touch confirmation needed for decryption"); - }, - ) -} - -/// Race a sequence future against the interrupt channel (R15). -/// -/// Returns `None` if `sequence` completed first, or `Some(interrupted)` if an -/// interrupt arrived while it was still running — in which case `sequence` is -/// DROPPED (cancelled at its current `.await` point) by the `select!`. Dropping -/// the future is what makes Ctrl-C / Exit take effect within ~1 s even while a -/// network await is parked; the caller is responsible for any state cleanup the -/// dropped future may have left behind (e.g. a persisted-but-unbound persona). -async fn race_against_interrupt( - sequence: F, - interrupt_rx: &mut broadcast::Receiver, -) -> Option -where - F: std::future::Future, -{ - tokio::select! { - () = sequence => None, - Ok(interrupted) = interrupt_rx.recv() => Some(interrupted), - } -} - #[cfg(test)] mod tests { - //! R15: these tests cover the *select-against-interrupt wiring* in - //! isolation — i.e. that an interrupt delivered while the join sequence is - //! still running wins the race, drops the sequence future, and surfaces the - //! interrupt. The full end-to-end cancel-safety property (a Ctrl-C against a - //! live/unreachable VTA leaves no persisted-but-unbound persona) needs a real - //! `StateHandler` + `TDK` + VTA session and is NOT unit-testable here; it is - //! covered by manual verification and the in-code rollback at the cancel site - //! (`join_flow` → `rollback_minted_persona` when `!persona_referenced`). - - use super::race_against_interrupt; + //! The join's pure page logic and its bounded helpers. Cancel safety — a + //! Ctrl-C or F10 mid-launch undoing what the launch made (R15) — is the + //! driver's, and is tested there against injected failures + //! (`driver::tests`). /// A community that asks the applicant about themselves stops the join on /// the Answers page before anything is minted or sent, with the launch @@ -4976,23 +2439,19 @@ mod tests { assert!(!given.consent_for("did:web:another.example")); assert!(!given.answered_for("did:web:another.example")); } + use super::driver::applicant_listener; use super::{ is_duplicate_membership, joined_session, load_pasted_vic, parse_pasted_vic, - start_persona_listener, stash_verified_vic, validate_join_input, + stash_verified_vic, validate_join_input, }; - use crate::Interrupted; use crate::state_handler::dispatch_util::test_config; use crate::state_handler::join::JoinState; use crate::state_handler::setup_sequence::MessageType; use crate::state_handler::state::State; - use crate::state_handler::{StartingMode, StateHandler}; use affinidi_tdk::{TDK, common::config::TDKConfig}; use openvtc_core::config::account::{CommunityRecord, CommunityStatus, PersonaId}; use openvtc_core::didcomm::Messaging; use serde_json::json; - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - use tokio::sync::broadcast; // ---- Pure-decision tests (peeled out of the join sequence) ---- @@ -5204,65 +2663,6 @@ mod tests { assert_eq!(joined.vtc_did, "did:vtc:c"); } - #[tokio::test] - async fn completes_when_no_interrupt() { - let (_tx, mut rx) = broadcast::channel::(4); - let ran = Arc::new(AtomicBool::new(false)); - let ran2 = ran.clone(); - let outcome = race_against_interrupt( - async move { - ran2.store(true, Ordering::SeqCst); - }, - &mut rx, - ) - .await; - assert!(outcome.is_none(), "no interrupt → sequence wins the race"); - assert!( - ran.load(Ordering::SeqCst), - "sequence future ran to completion" - ); - } - - #[tokio::test] - async fn interrupt_cancels_pending_sequence() { - let (tx, mut rx) = broadcast::channel::(4); - // Deliver the interrupt before the race so the recv arm is immediately - // ready; the sequence is a never-completing future, so the only way to - // return is via the interrupt arm dropping it. - tx.send(Interrupted::UserInt).expect("send interrupt"); - let completed = Arc::new(AtomicBool::new(false)); - let completed2 = completed.clone(); - let outcome = race_against_interrupt( - async move { - std::future::pending::<()>().await; - // Unreachable: the future is dropped at the await above. - completed2.store(true, Ordering::SeqCst); - }, - &mut rx, - ) - .await; - assert!( - matches!(outcome, Some(Interrupted::UserInt)), - "interrupt wins and is surfaced: {outcome:?}" - ); - assert!( - !completed.load(Ordering::SeqCst), - "pending sequence future was dropped, not run to completion" - ); - } - - #[tokio::test] - async fn surfaces_os_sigint_variant() { - let (tx, mut rx) = broadcast::channel::(4); - tx.send(Interrupted::OsSignal("SIGINT")) - .expect("send interrupt"); - let outcome = race_against_interrupt(std::future::pending::<()>(), &mut rx).await; - assert!( - matches!(outcome, Some(Interrupted::OsSignal("SIGINT"))), - "the specific interrupt variant propagates: {outcome:?}" - ); - } - // ---- `load_pasted_vic`: the paste row on the invitation step ---- const COMMUNITY: &str = "did:webvh:example.com:community"; @@ -5545,10 +2945,10 @@ mod tests { // ---- Pre-submit persona connect ---- // - // What `start_persona_listener` returns is an *ownership claim*: the cancel - // and submit-failure paths remove whatever id they are handed, so handing - // back one this call did not install would tear down a socket serving - // something else. Both no-op paths are covered here; the install-and-connect + // What `applicant_listener` returns is an *ownership claim*: the launch's + // saga records it, and a cancel or failure removes whatever it recorded, so + // handing back one the launch should not install would tear down a socket + // serving something else. Both no-op paths are covered here; the install-and-connect // path needs a live mediator and is covered by the messaging e2e suite. /// Offline TDK — no environment load, no network. @@ -5568,14 +2968,10 @@ mod tests { /// nothing is claimed — and a cancel has nothing to remove. #[tokio::test] async fn without_a_messaging_service_nothing_is_claimed() { - let (handler, _state_rx) = StateHandler::new("test", StartingMode::NotSet); - let mut state = State::default(); let config = test_config(); let tdk = test_tdk().await; - let claimed = start_persona_listener( - &handler, - &mut state, + let claimed = applicant_listener( None, &config, &tdk, @@ -5592,17 +2988,13 @@ mod tests { /// on regardless — an applicant that cannot connect still gets admitted. #[tokio::test] async fn an_unresolvable_persona_is_not_claimed() { - let (handler, _state_rx) = StateHandler::new("test", StartingMode::NotSet); - let mut state = State::default(); // `test_config` has no identities, so any persona id is unresolvable. let config = test_config(); let tdk = test_tdk().await; let (event_tx, _event_rx) = tokio::sync::mpsc::unbounded_channel(); let service = Messaging::start(event_tx); - let claimed = start_persona_listener( - &handler, - &mut state, + let claimed = applicant_listener( Some(&service), &config, &tdk, diff --git a/openvtc/src/state_handler/join_flow/driver.rs b/openvtc/src/state_handler/join_flow/driver.rs new file mode 100644 index 00000000..1cc1ec44 --- /dev/null +++ b/openvtc/src/state_handler/join_flow/driver.rs @@ -0,0 +1,3277 @@ +//! The join flow, driven by the loop that owns it (#529). +//! +//! The join used to be a nested `tokio::select!` loop the runtime awaited +//! inline, so while the join screen was up every other arm — saves, VTA +//! supervision, inbound messages, timers — was parked. It is now a state +//! machine: [`JoinDriver`] holds the open join (a [`JoinSession`]) and each of +//! its methods does one step and returns. +//! +//! - **Actions** reach [`JoinDriver::on_action`] from the owning loop's action +//! arm. The pure page logic runs there, on the loop, unchanged. +//! - **Long network work** runs as an off-loop job on +//! [`DispatchDomain::Join`], one at a time. Its result comes back as +//! [`DispatchOutcome::Join`] carrying the job's generation, which +//! [`JoinDriver::on_outcome`] matches against the job it is waiting for (an +//! aborted or superseded job's result is ignored), and its progress lines as +//! [`ProgressUpdate::Join`], applied by [`JoinDriver::on_progress`]. +//! - **While a job is in flight** the join actions are queued and replayed, in +//! order, once it has finished — what the old loop's channel did — except +//! the ways out: `JoinCancel`, `Exit` and `UXError` abort the job at once. +//! - **A launch** is a saga ([`JoinSaga`]): off-loop stages with on-loop +//! applies between them, each recording what it did. Every way a launch can +//! stop short — a failed stage, a cancel, an interrupt — undoes it from +//! [`JoinSaga::compensations`], so what is rolled back no longer depends on +//! where an `.await` happened to be when a future was dropped. +//! +//! Short, bounded sends (a manifest or challenge question, a wallet-session +//! request) and the VTA reads behind the Answers page stay inline awaits on +//! the loop, as they are everywhere else in the runtime. +//! +//! Everything that touches the network, the disk or a live messaging account +//! goes through [`JoinIo`], so a test can drive a whole launch offline and fail +//! it at any stage. + +use std::{collections::VecDeque, future::Future, pin::Pin, sync::Arc}; + +use affinidi_tdk::{did_resolver::DIDCacheClient, secrets_resolver::secrets::Secret}; +use openvtc_core::config::{PeerTransports, PersonaDIDKeys, keys::SubjectSigner}; +use openvtc_core::didcomm::{ListenerSpec, MessagingTransport}; +use openvtc_core::errors::OpenVTCError; +use openvtc_core::join::{ + SubjectLinkage, + saga::{Compensation, JoinSaga}, + sequence::{NotLearned, OverHttp, PreparedSubmit, SubmitError}, +}; +use openvtc_core::persona::join_answers::Asked; +use openvtc_core::vetting::hidden::HiddenParams; +use tokio::sync::{ + mpsc::{UnboundedReceiver, UnboundedSender}, + watch, +}; +use tracing::error; +use vta_sdk::protocols::join_requests::JoinRequestAttribute; + +use super::*; +use crate::state_handler::{ + background_dispatch::{DispatchDomain, DispatchOutcome, InFlight, ProgressUpdate}, + save_coalesce::SaveScheduler, + setup_sequence::SetupState, +}; + +#[cfg(test)] +mod tests; + +/// A boxed future, as [`JoinIo`] returns them. +pub(crate) type IoFuture<'a, T> = Pin + Send + 'a>>; + +/// Everything a join step acts on, lent by the loop that owns the join. +/// +/// The runtime loop lends its own; the State-A loop lends what it has, with +/// `messaging` its listener-less service. +pub(crate) struct JoinCtx<'a> { + pub state: &'a mut State, + pub config: &'a mut Config, + pub tdk: &'a TDK, + pub admin_vta: Option<&'a VtaClient>, + pub messaging: Option<&'a Messaging>, + pub profile: &'a str, + pub save: &'a mut SaveScheduler, + pub dispatch_tx: &'a UnboundedSender, + pub in_flight: &'a mut InFlight, + pub state_tx: &'a watch::Sender>, +} + +/// What a join step asks of the loop that owns it. +pub(crate) enum JoinStep { + /// The join goes on: nothing for the loop to do. + Continue, + /// User cancelled / finished — return to the main page and resume the + /// caller's loop. Carries the just-joined session when a join succeeded, so + /// the runtime loop can register it + start its listener live (R-B-5). + Returned(Option), + /// The community was asked what it requires. The caller keeps the join + /// screen up, waits for the answer, and enters the flow again + /// ([`AwaitingRequirements`]). + AwaitRequirements(AwaitingRequirements), + /// The launch is parked on the challenge its hidden-vetting proof binds; + /// the caller hears the answer and enters the flow again + /// ([`AwaitingChallenge`]). + AwaitChallenge(AwaitingChallenge), + /// The launch is parked on a wallet session ([`AwaitingWallet`]). + AwaitWallet(AwaitingWallet), + /// Application is exiting (Exit / UXError / interrupt). + Exit(Interrupted), +} + +/// A finished off-loop join job, as it comes back over the dispatch channel. +pub(crate) struct JoinOutcome { + generation: u64, + result: JobResult, +} + +/// What a job brought back. +enum JobResult { + Resolved(Result), + Discovered(Box), + Reached(Reached), + Context(Result), + Minted(Box), + Invited(Invited), + Presented(Presented), + Submitted(Submitted), + /// The job panicked and brought back nothing. + Panicked, +} + +/// Where a running job says what it is doing: the join page's log, as it +/// happens rather than when the job ends. +#[derive(Clone)] +pub(crate) struct Progress { + tx: UnboundedSender, + generation: u64, +} + +impl Progress { + /// Say `line` on the join page. + pub(crate) fn say(&self, line: impl Into) { + let _ = self + .tx + .send(DispatchOutcome::Progress(ProgressUpdate::Join { + generation: self.generation, + line: line.into(), + })); + } +} + +// --------------------------------------------------------------------------- +// The I/O seam +// --------------------------------------------------------------------------- + +/// What reading a community needs: its invitations, its manifest, its DID +/// document — any of them. +pub(crate) struct DiscoverReq { + pub resolver: DIDCacheClient, + pub admin_vta: Option, + pub vtc_did: String, + /// Collect the community's usable invitations, with the one loaded by hand + /// (if any). `None` when they are already collected. + pub invitations: Option>, + /// Read the manifest from the community's REST endpoint. + pub manifest: bool, + pub document: DocumentWhen, +} + +/// When to resolve a community's DID document, to learn how it signs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DocumentWhen { + Never, + Always, + /// Only when the manifest could not be read: a read manifest records it. + IfManifestFails, +} + +/// What reading a community found. +#[derive(Default)] +pub(crate) struct Discovered { + pub invitations: Option>, + pub manifest: Option>, + pub document: Option, +} + +/// A launch's first stage: how the community is named, and what it offers. +pub(crate) struct ReachReq { + pub resolver: DIDCacheClient, + pub vtc_did: String, +} + +pub(crate) struct Reached { + pub display_name: Option, + pub transports: PeerTransports, +} + +/// The community's context at the VTA, made if it is missing. +pub(crate) struct ContextReq { + pub admin_vta: VtaClient, + pub top_context_id: String, + pub context_id: String, + pub name: String, +} + +/// The four VTA calls that mint a persona. +pub(crate) struct MintReq { + pub admin_vta: VtaClient, + pub tdk: TDK, + pub context_id: String, + pub presentation: vta_sdk::client::DidPresentation, +} + +/// What the mint got as far as making, and why it stopped if it did. +#[derive(Default)] +pub(crate) struct MintedParts { + pub keys: Option, + pub update: Option<(Secret, Secret)>, + pub did: Option, + pub failure: Option, +} + +/// Bring the applicant online and settle the invitation to present. +pub(crate) struct InviteReq { + pub messaging: Option, + /// The listener to install — `None` when there is none to install (no + /// service, or one already serving this persona). + pub listener: Option, + pub admin_vta: VtaClient, + pub resolver: DIDCacheClient, + pub vtc_did: String, + /// The person chose to present an invitation. + pub present: bool, + /// The invitation loaded by hand, if any. + pub loaded: Option, + /// The loaded invitation's subject when it is none of our personas. + pub foreign: Option, +} + +pub(crate) struct Invited { + /// Whether the listener came up; `None` when none was installed. + pub listener_up: Option, + /// The invitation to present, verified. + pub presentable: Option, +} + +/// The VTA work of presenting: the subject-linkage proof, the answers from a +/// face, the hidden-vetting proof. +pub(crate) struct PresentReq { + pub admin_vta: VtaClient, + pub linkage: Option, + pub answers: Option, + pub hidden: Option, +} + +pub(crate) struct LinkagePlan { + pub signer: SubjectSigner, + pub subject: String, + pub vic_id: String, + pub presenter: String, + /// What the join page says once the proof is built. + pub said: String, +} + +pub(crate) struct AnswersPlan { + pub context_id: String, + pub persona_did: String, + pub community_did: String, + pub asked: Vec, + pub face_id: String, + pub approved: Vec<(String, serde_json::Value)>, +} + +/// A hidden-vetting launch's proof: refused before the VTA is asked, or asked +/// for. +pub(crate) enum HiddenPlan { + Refuse(String), + Prove { + application_id: String, + params: Box, + digest: String, + challenge: String, + }, +} + +pub(crate) struct Presented { + pub linkage: Option, + pub attributes: Vec, + pub proof: Option, + /// Why the join stops here; nothing was sent. + pub failure: Option, +} + +/// The send. +pub(crate) struct SubmitReq { + pub messaging: Option, + pub vtc_did: String, + pub applicant_did: String, + pub mediator: String, + pub transports: PeerTransports, + /// What the send needs from the config, or why it could not be read — + /// said only after the wire is settled, as it always was. + pub prepared: Result, SubmitError>, + pub presentation: openvtc_core::join::JoinPresentation, +} + +pub(crate) struct Submitted { + pub transport: MessagingTransport, + pub online: bool, + pub request: Result, +} + +/// Everything the join does that touches the network, the disk or a live +/// messaging account. [`LiveIo`] in production; a test supplies its own. +/// +/// The `'static` methods are the off-loop jobs. The borrowing ones run on the +/// loop, where they always did: they are here only because they need an ATM, +/// a keyring or a mediator that a test does not have. +pub(crate) trait JoinIo: Send + Sync { + fn resolve( + &self, + resolver: DIDCacheClient, + name: String, + ) -> IoFuture<'static, Result>; + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered>; + fn reach(&self, req: ReachReq, progress: Progress) -> IoFuture<'static, Reached>; + fn context(&self, req: ContextReq) -> IoFuture<'static, Result>; + fn mint(&self, req: MintReq, progress: Progress) -> IoFuture<'static, MintedParts>; + fn invite(&self, req: InviteReq, progress: Progress) -> IoFuture<'static, Invited>; + fn present(&self, req: PresentReq, progress: Progress) -> IoFuture<'static, Presented>; + fn submit(&self, req: SubmitReq, progress: Progress) -> IoFuture<'static, Submitted>; + + /// Write a just-minted persona into the account (and save it). + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + setup: &'a SetupState, + tdk: &'a TDK, + profile: &'a str, + ) -> IoFuture<'a, anyhow::Result>; + /// Whether an ATM is up to send with. + fn can_message(&self, tdk: &TDK) -> bool; + /// A persona's messaging identity: the DID it sends as and its mediator. + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)>; + /// The listener to install for the applicant, when there is one to install. + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option>; + /// Read what the send needs out of the config. + fn prepare_submit<'a>( + &'a self, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>>; + fn remove_listener<'a>(&'a self, messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()>; + /// Persist the config now. + fn save<'a>( + &'a self, + save: &'a mut SaveScheduler, + config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>>; +} + +/// The join's I/O, for real. +pub(crate) struct LiveIo; + +impl JoinIo for LiveIo { + fn resolve( + &self, + resolver: DIDCacheClient, + name: String, + ) -> IoFuture<'static, Result> { + Box::pin(async move { + openvtc_core::agent_name::resolve_identifier(&resolver, &name) + .await + .map_err(|e| e.to_string()) + }) + } + + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered> { + Box::pin(async move { + let DiscoverReq { + resolver, + admin_vta, + vtc_did, + invitations, + manifest, + document, + } = req; + let invitations = match invitations { + Some(loaded) => Some( + collect_available_vics( + loaded.as_ref(), + admin_vta.as_ref(), + &resolver, + &vtc_did, + ) + .await, + ), + None => None, + }; + let manifest = if manifest { + Some(sequence::fetch_over_http(&resolver, &vtc_did).await) + } else { + None + }; + let resolve = match document { + DocumentWhen::Never => false, + DocumentWhen::Always => true, + DocumentWhen::IfManifestFails => matches!(manifest, Some(Err(_))), + }; + let document = if resolve { + sequence::resolve_document(&resolver, &vtc_did).await + } else { + None + }; + Discovered { + invitations, + manifest, + document, + } + }) + } + + fn reach(&self, req: ReachReq, _progress: Progress) -> IoFuture<'static, Reached> { + Box::pin(async move { + // Resolve the community's verified agent name for display + // (best-effort). A verified name (`example.com/@acme`) is the + // community's human-readable handle; `None` when it has no + // verifiable name, so the sub-context derivation falls back to the + // DID-derived token (D9). + let display_name = + openvtc_core::agent_name::resolve_verified_name(&req.resolver, &req.vtc_did).await; + // Preflight the community's transports, before anything is minted. + let transports = openvtc_core::config::peer_messaging_transports(&req.vtc_did).await; + Reached { + display_name, + transports, + } + }) + } + + fn context(&self, req: ContextReq) -> IoFuture<'static, Result> { + Box::pin(async move { + community_context::ensure_context( + &req.admin_vta, + &req.top_context_id, + &req.context_id, + &req.name, + ) + .await + .map_err(|e| e.to_string()) + }) + } + + fn mint(&self, req: MintReq, progress: Progress) -> IoFuture<'static, MintedParts> { + Box::pin(async move { + let MintReq { + admin_vta, + tdk, + context_id, + presentation, + } = req; + let mut parts = MintedParts::default(); + // Every key and the DID in the community's context. + match vta::create_persona_keys(&admin_vta, Some(&context_id)).await { + Ok(keys) => parts.keys = Some(keys), + Err(e) => { + parts.failure = Some(format!("Failed to create persona keys: {e}")); + return parts; + } + } + // WebVH update keys. + progress.say("Creating DID update keys…"); + match vta::create_update_keys(&admin_vta, Some(&context_id)).await { + Ok(update) => parts.update = Some(update), + Err(e) => { + parts.failure = Some(format!("Failed to create update keys: {e}")); + return parts; + } + } + // Pick the first WebVH server. Serverless mint is a deliberate follow-up. + progress.say("Finding a DID hosting server…"); + let server_id = match vta::list_webvh_servers(&admin_vta).await { + Ok(servers) => match servers.into_iter().next() { + Some(s) => s.id, + None => { + parts.failure = Some( + "No WebVH server available from the VTA (serverless mint not yet supported)." + .to_string(), + ); + return parts; + } + }, + Err(e) => { + parts.failure = Some(format!("Failed to list WebVH servers: {e}")); + return parts; + } + }; + // Create the persona did:webvh via the server (auto-assigned path). + progress.say(format!("Creating persona DID via {server_id}…")); + match vta::create_did_via_server( + &admin_vta, + &tdk, + &context_id, + &server_id, + WebvhPathMode::AutoAssign, + presentation, + ) + .await + { + Ok(minted) => parts.did = Some(minted), + Err(e) => parts.failure = Some(format!("Failed to create persona DID: {e}")), + } + parts + }) + } + + fn invite(&self, req: InviteReq, progress: Progress) -> IoFuture<'static, Invited> { + Box::pin(async move { + let InviteReq { + messaging, + listener, + admin_vta, + resolver, + vtc_did, + present, + loaded, + foreign, + } = req; + // Open the applicant's mailbox before knocking on the door. The VTC + // decides a well-formed invited join in under a second and pushes + // the VMC + role VEC straight back, and a mediator only + // *live-streams* a message whose recipient is connected at the + // instant it lands. Connecting first closes that window at the only + // end we control; the submit waits, bounded, for the socket + // (`applicant_online`). + // + // Failure is never fatal. A join whose applicant could not connect + // is still a valid join: the reply waits in the mailbox for the + // persona's listener to pick up (R6.4: the operator is told what + // happened, not handed one generic hint). + let listener_up = match (messaging.as_ref(), listener.as_ref()) { + (Some(service), Some(spec)) => Some( + match openvtc_core::didcomm::add_listener(service, spec).await { + Err(e) => { + progress.say(format!( + "Couldn't open this persona's mediator session ({e}) — submitting \ + anyway; the community's reply will be collected when it connects." + )); + false + } + Ok(()) => { + progress.say("Connecting the new persona to its mediator…"); + true + } + }, + ), + _ => None, + }; + // #2: resolve the VIC to present for THIS community. A VIC's issuer + // is the community's VTC DID, so a presentable invitation must + // match `vtc_did` and be unexpired. An explicitly loaded VIC is + // still stored in the vault regardless (its durable home), but only + // *presented* when it matches; otherwise fall back to a + // community-matched VIC the vault already holds, else submit as an + // open request. All vault calls are best-effort. + let mut presentable: Option = None; + if present { + if let Some(vic) = loaded { + if let Err(e) = admin_vta.cred_vault_receive(vic.clone(), None).await { + debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); + } + if !openvtc_core::join::invitation_matches_community(&vic, &vtc_did) { + progress.say( + "Loaded invitation is for a different community — \ + looking for one that matches…", + ); + } else if openvtc_core::join::invitation_is_expired(&vic, Utc::now()) { + progress.say("Loaded invitation has expired — looking for a valid one…"); + } else { + presentable = Some(vic); + } + } + if presentable.is_none() { + presentable = load_invitation_from_vault(&admin_vta, &vtc_did).await; + } + } else if let Some(vic) = loaded { + // The operator chose to join *without* an invitation. Still + // store a loaded VIC in the vault but present nothing — this is + // what honours the choice over the vault fallback above. + if let Err(e) = admin_vta.cred_vault_receive(vic, None).await { + debug!(error = %e, "storing invitation in the VTA vault failed (continuing)"); + } + match foreign { + // Not a choice the operator made: say what stopped the invitation. + Some(subject) => progress.say(format!( + "Your invitation names {}, which is not one of your personas — \ + the community accepts it only from that DID, so this is an open \ + request (awaiting approval).", + shorten_did(&sanitize_display(&subject, 256), 48) + )), + None => progress.say( + "Joining without an invitation — submitting an open request (awaiting approval).", + ), + } + } + // Completeness gate before presenting: a VIC resolved from the vault + // may predate the ingest-time validation. An incomplete VIC is + // unusable — the VTC can't extract it and silently refers the join to + // a moderator — so drop it and fall to an open request with a reason. + if let Some(vic) = &presentable + && let Err(why) = + openvtc_core::join::verify_invitation_credential(vic, &resolver, Utc::now()) + .await + { + progress.say(format!( + "Resolved invitation is not usable ({why}) — submitting as an open request instead." + )); + presentable = None; + } + Invited { + listener_up, + presentable, + } + }) + } + + fn present(&self, req: PresentReq, progress: Progress) -> IoFuture<'static, Presented> { + Box::pin(async move { + let PresentReq { + admin_vta, + linkage, + answers, + hidden, + } = req; + let refused = |linkage, attributes, why: String| Presented { + linkage, + attributes, + proof: None, + failure: Some(why), + }; + // Subject-linkage (#1b): when the presenting DID differs from the + // VIC subject, prove the subject authorized this presenter (signed + // with the subject persona's key). Best-effort: a signing failure is + // logged and the join presents without it. + let linkage = match linkage { + None => None, + Some(plan) => match plan + .signer + .sign(Some(&admin_vta), &plan.vic_id, &plan.presenter) + .await + { + Ok(linkage) => { + // Presenting through another persona is a disclosure: + // the community learns that the persona it invited + // vouches for this one. + progress.say(plan.said); + Some(linkage) + } + Err(e) => { + debug!(subject = %plan.subject, error = %e, "subject-linkage proof unavailable"); + None + } + }, + }; + // The community's questions, answered from the face the holder chose + // on the Answers page — through a disclosure, so the holder's own + // history records what they told the community. + let attributes = match answers { + None => Vec::new(), + Some(a) => { + progress.say("Answering the community's questions from your face…"); + match answer_from_face( + &admin_vta, + &a.context_id, + &a.persona_did, + &a.community_did, + &a.asked, + &a.face_id, + &a.approved, + ) + .await + { + Ok(attributes) => attributes, + Err(e) => return refused(linkage, Vec::new(), e), + } + } + }; + // Under a hidden-vetting criterion the proof is built here, bound to + // a challenge of this submission's own (design §8). Every way the + // proof can be missing stops the join: submitting without it sends no + // vetting at all. + let proof = match hidden { + None => None, + Some(HiddenPlan::Refuse(why)) => return refused(linkage, attributes, why), + Some(HiddenPlan::Prove { + application_id, + params, + digest, + challenge, + }) => { + progress.say("Proving that enough vetters vetted you, without naming them…"); + match openvtc_core::vetting::pcs_vta::prove( + &admin_vta, + &application_id, + ¶ms, + &digest, + &challenge, + ) + .await + { + Ok(proof) => Some(proof), + Err(e) + if e.is(openvtc_core::vetting::pcs_vta::tasks::prove::v0_1::error_codes::NO_USABLE_ATTESTATION) => + { + return refused(linkage, attributes, NO_ATTESTATION.to_string()); + } + Err(e) => { + return refused( + linkage, + attributes, + format!( + "The zero-knowledge proof of your vetting could not be built \ + ({e}). Nothing was sent — joining without it would present \ + no vetting at all." + ), + ); + } + } + } + }; + Presented { + linkage, + attributes, + proof, + failure: None, + } + }) + } + + fn submit(&self, req: SubmitReq, progress: Progress) -> IoFuture<'static, Submitted> { + Box::pin(async move { + let SubmitReq { + messaging, + vtc_did, + applicant_did, + mediator, + transports, + prepared, + presentation, + } = req; + // Settle the wire (TSP needs both legs; `choose_submit_route` says why). + let route = sequence::choose_submit_route(&vtc_did, &mediator, &transports).await; + if let Some(note) = route.note { + progress.say(note.describe()); + } + let transport = route.transport(); + // Last gate before the request goes out: the socket started earlier + // has had the invitation + VP work to come up, so this is normally + // already true. + let online = applicant_online(messaging.as_ref(), &applicant_did, &progress).await; + let request = match prepared { + Ok(Some(prepared)) => { + sequence::submit_prepared(&prepared, &vtc_did, &route, presentation).await + } + Ok(None) => Err(SubmitError::NoMessaging), + Err(e) => Err(e), + }; + Submitted { + transport, + online, + request, + } + }) + } + + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + setup: &'a SetupState, + tdk: &'a TDK, + profile: &'a str, + ) -> IoFuture<'a, anyhow::Result> { + Box::pin(::mint_persona_into( + config, setup, tdk, profile, + )) + } + + fn can_message(&self, tdk: &TDK) -> bool { + tdk.atm.is_some() + } + + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)> { + config.identities.get(&persona).map(|ident| { + ( + ident.persona_did().to_string(), + ident.mediator_did.clone().unwrap_or_default(), + ) + }) + } + + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option> { + Box::pin(applicant_listener(messaging, config, tdk, persona, did)) + } + + fn prepare_submit<'a>( + &'a self, + config: &'a Config, + tdk: &'a TDK, + persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>> { + Box::pin(async move { + sequence::prepare_submit(config, tdk, persona) + .await + .map(Some) + }) + } + + fn remove_listener<'a>(&'a self, messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()> { + Box::pin(messaging.remove_listener(id)) + } + + fn save<'a>( + &'a self, + save: &'a mut SaveScheduler, + config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>> { + Box::pin(save.flush(config)) + } +} + +/// The listener to install for the applicant persona, so its socket is coming +/// up while the rest of the launch runs. +/// +/// `Some` **only when the launch should install one** — that is the launch's +/// claim on it, recorded in its saga so a failure or cancel tears it down. +/// `None` covers every case where there is nothing to install, and so nothing +/// to undo: no messaging service (State A with none), a reused persona that is +/// already live (one identity, one listener — D1/D11; tearing it down on a +/// cancelled join would take another community's session with it), or a +/// persona with no listener config. +pub(super) async fn applicant_listener( + messaging: Option<&Messaging>, + config: &Config, + tdk: &TDK, + persona_id: PersonaId, + applicant_did: &str, +) -> Option { + let service = messaging?; + let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); + if service.has_listener(&listener_id).await { + return None; + } + let spec = openvtc_core::didcomm::persona_listener_config_for(config, tdk, persona_id).await; + if spec.is_none() { + // The mint wrote this identity moments ago, so this is unexpected — but + // it costs the join nothing, so log it and carry on. + debug!( + persona = %applicant_did, + "no listener config for the applicant persona; submitting without a live session" + ); + } + spec +} + +/// Wait — bounded by [`PERSONA_CONNECT_TIMEOUT`] — for the applicant persona's +/// socket to be live, immediately before the join request goes out. +/// +/// Returns whether the applicant was live at the moment of the submit — which +/// the durable join record then states, because "sent while offline" is the one +/// fact that explains a join taking the slow (collect-on-connect) path. A +/// persona with no listener at all is nothing to wait for. +async fn applicant_online( + messaging: Option<&Messaging>, + applicant_did: &str, + progress: &Progress, +) -> bool { + let Some(service) = messaging else { + return false; + }; + let listener_id = openvtc_core::didcomm::persona_listener_id(applicant_did); + if !service.has_listener(&listener_id).await { + return false; + } + match service + .wait_connected(&listener_id, PERSONA_CONNECT_TIMEOUT) + .await + { + Ok(()) => { + progress.say("Persona connected — ready to receive the community's reply."); + true + } + Err(e) => { + // Installed but not up yet. The listener's own restart policy keeps + // working on it, so this is a slow connect, not a dead one. + debug!(listener = %listener_id, error = %e, "applicant persona not connected before submit"); + progress.say( + "The persona's mediator session is still connecting — submitting now; the \ + community's reply will be collected once it is up.", + ); + false + } + } +} + +// --------------------------------------------------------------------------- +// The driver +// --------------------------------------------------------------------------- + +/// The join flow, as a state machine its owning loop drives. +pub(crate) struct JoinDriver { + /// Whether the owning loop hears inbound messages, and so can wait for a + /// community's answer. + hears_replies: bool, + /// Bumped for every job, so a result is matched to the job that asked. + generation: u64, + session: Option, + /// Actions queued during a job that are now due, for the loop to take + /// before reading its channel again. + leftover: VecDeque, + io: Arc, +} + +/// An open join. +struct JoinSession { + hears_replies: bool, + job: Option, + launch: Option, + /// Join actions that arrived while a job was in flight. + queued: VecDeque, +} + +/// The job in flight, and what its result is for. +struct Job { + generation: u64, + abort: tokio::task::AbortHandle, + then: Then, + /// The submit stage: the request may already be on the wire, so the job + /// is past the point where cancelling can undo it. + submitting: bool, +} + +enum Then { + /// An agent name was resolved: enter the community it names. + Enter, + /// A community was read. + Discovered { vtc_did: String, purpose: Purpose }, + /// A launch stage finished. + Stage, +} + +/// Why a community was read. +enum Purpose { + /// Entering it: decide which page answers it. `knew` is what the book knew + /// before the manifest was read again. + Enter { knew: Option }, + /// Back from waiting for its requirements. + Resume(RequirementsOutcome), + /// Only its invitations were missing: go on with the join. + ContinueJoin, + /// Only its invitations were missing: open the invitation step. + InvitationChoice(PersonaId), +} + +/// A launch in progress: what was chosen, what it has done, and what it has +/// learned on the way. +struct Launch { + vtc_did: String, + choice: IdentityPick, + context_id: String, + admin: VtaClient, + saga: JoinSaga, + /// `mint_persona_into` may overwrite `public.friendly_name`; a rollback + /// restores it. + prior_friendly_name: String, + registry_consent: bool, + directory_statement_digest: Option, + display_name: Option, + transports: PeerTransports, + /// The keys a minted persona was made with, for its rollback. + minted_keys: Option, + /// The persona presented, and its DID. + persona: Option<(PersonaId, String)>, + /// The applicant's messaging DID and mediator. + applicant: Option<(String, String)>, +} + +impl JoinDriver { + /// A driver for a loop that hears inbound messages (`hears_replies`), or + /// does not (State A). + pub(crate) fn new(hears_replies: bool) -> Self { + Self::with_io(hears_replies, Arc::new(LiveIo)) + } + + pub(crate) fn with_io(hears_replies: bool, io: Arc) -> Self { + Self { + hears_replies, + generation: 0, + session: None, + leftover: VecDeque::new(), + io, + } + } + + /// Whether a join is open — its screen up and its actions this driver's. + pub(crate) fn is_active(&self) -> bool { + self.session.is_some() + } + + /// The next action for the owning loop: one queued during a join job that + /// is now due, else the next from the channel. + pub(crate) async fn next_action( + &mut self, + action_rx: &mut UnboundedReceiver, + ) -> Option { + if let Some(action) = self.leftover.pop_front() { + return Some(action); + } + action_rx.recv().await + } + + /// Open the join flow. + pub(crate) async fn enter(&mut self, ctx: &mut JoinCtx<'_>, entry: JoinEntry) -> JoinStep { + if self.session.is_some() { + debug!("a join was entered while one was open; the open one is cancelled"); + self.cancel(ctx).await; + } + let state = &mut *ctx.state; + // Enter the flow on a fresh EnterDid page — unless this is a launch + // coming back with its challenge, which must find everything the person + // chose on the way to it exactly as they left it. + // Whichever way a parked wait ends, it has ended: Esc is no longer + // the cheap way out once the launch carries on. + state.join.waiting_on_challenge = false; + if !matches!( + entry, + JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } + ) { + state.join.reset(); + // Surface the launch-supplied invitation on the entry page (reset + // clears the transient join sub-state, so mirror the flag back in + // afterwards). + state.join.has_invitation = state.invitation_credential.is_some(); + state.join.invitation_foreign_subject = state + .invitation_credential + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)); + } + state.active_page = ActivePage::Join; + let hears_replies = match &entry { + JoinEntry::Fresh | JoinEntry::ForCommunity { .. } => self.hears_replies, + // Only the runtime loop waits on an answer, so only it resumes. + JoinEntry::Resume { .. } | JoinEntry::Challenge { .. } | JoinEntry::Wallet { .. } => { + true + } + }; + state.join.hears_replies = hears_replies; + self.session = Some(JoinSession { + hears_replies, + job: None, + launch: None, + queued: VecDeque::new(), + }); + let step = match entry { + JoinEntry::Fresh => JoinStep::Continue, + // The community is already chosen, so the flow opens where the DID + // entry page would have led. + JoinEntry::ForCommunity { vtc_did } => { + self.enter_community(ctx, vtc_did); + JoinStep::Continue + } + JoinEntry::Resume { vtc_did, outcome } => { + // `reset` above cleared what the asking pass collected, and the + // routes cannot be drawn without it. The manifest came over + // DIDComm, which never resolved the community's DID document. + let document = if matches!(outcome, RequirementsOutcome::Learned) + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + { + DocumentWhen::Always + } else { + DocumentWhen::Never + }; + self.discover(ctx, vtc_did, Purpose::Resume(outcome), false, document); + JoinStep::Continue + } + JoinEntry::Challenge { awaiting, outcome } => { + match outcome { + ChallengeOutcome::Issued => { + // The stored challenge is the one this launch asked for; + // the launch takes the flag, so it serves exactly one submit. + ctx.state.join.challenge_fresh = true; + self.launch(ctx, awaiting.choice, awaiting.vtc_did, awaiting.context_id) + .await; + } + ChallengeOutcome::Unanswered(reason) => { + let name = + vetting_actions::community_display(ctx.config, &awaiting.vtc_did); + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.fail(challenge_failed_words(&name, &reason)); + } + } + JoinStep::Continue + } + JoinEntry::Wallet { end } => { + ctx.state.join.processing = false; + match end { + WalletEnd::Presented => { + let join = &mut ctx.state.join; + let parked = join.wallet.as_mut().and_then(|w| { + join.wallet_outcome = + Some(WalletOutcome::Presented(w.requirements_digest.clone())); + w.parked.take() + }); + join.wallet = None; + if let Some((pick, vtc_did, context_id)) = parked { + self.launch(ctx, pick, vtc_did, context_id).await; + } + } + WalletEnd::Unfinished(why) => { + ctx.state.join.page = JoinPage::Wallet; + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Offered { note: Some(why) }; + } + } + } + JoinStep::Continue + } + }; + self.settle(ctx, step) + } + + /// A UI action, while the join is open. + pub(crate) async fn on_action(&mut self, ctx: &mut JoinCtx<'_>, action: Action) -> JoinStep { + let Some(session) = self.session.as_mut() else { + return JoinStep::Continue; + }; + if session.job.as_ref().is_some_and(|job| job.submitting) + && matches!( + action, + Action::Exit | Action::UXError(_) | Action::JoinCancel + ) + { + // The request may already be on the wire. Cancelling now would + // forget the persona the community is about to answer, with no + // record of the request: let the submit finish and be recorded, + // then take this way out. + session.queued.push_back(action); + ctx.state + .join + .info("Sending the join request — stopping once it has been sent."); + return JoinStep::Continue; + } + if session.job.is_some() { + // The ways out take effect now, whatever the job is waiting on; + // everything else waits its turn, as it did in the old loop's + // channel. + let step = match action { + Action::Exit => JoinStep::Exit(Interrupted::UserInt), + Action::UXError(interrupted) => JoinStep::Exit(interrupted), + Action::JoinCancel => JoinStep::Returned(None), + action => { + session.queued.push_back(action); + return JoinStep::Continue; + } + }; + self.interrupt(ctx).await; + if let JoinStep::Returned(_) = step { + ctx.state.active_page = ActivePage::Main; + return self.settle(ctx, JoinStep::Returned(joined_session(&ctx.state.join))); + } + return self.settle(ctx, step); + } + let step = self.act(ctx, action).await; + self.settle(ctx, step) + } + + /// A join job finished. + pub(crate) async fn on_outcome( + &mut self, + ctx: &mut JoinCtx<'_>, + outcome: JoinOutcome, + ) -> JoinStep { + let JoinOutcome { generation, result } = outcome; + // Stale — aborted, or from a session that is over: the domain it held + // was freed when it was abandoned. + let Some(job) = self + .session + .as_mut() + .and_then(|s| s.job.take_if(|j| j.generation == generation)) + else { + return JoinStep::Continue; + }; + ctx.in_flight.finish(DispatchDomain::Join); + let step = match (job.then, result) { + (Then::Stage, JobResult::Panicked) => { + self.fail_and_undo(ctx, JOB_LOST).await; + JoinStep::Continue + } + (_, JobResult::Panicked) => { + ctx.state.join.fail(JOB_LOST); + JoinStep::Continue + } + (Then::Enter, JobResult::Resolved(resolved)) => { + match resolved { + Ok(did) => self.enter_community(ctx, did), + Err(e) => ctx.state.join.fail(e), + } + JoinStep::Continue + } + (Then::Discovered { vtc_did, purpose }, JobResult::Discovered(found)) => { + self.discovered(ctx, vtc_did, purpose, *found).await + } + (Then::Stage, JobResult::Reached(reached)) => { + self.on_reached(ctx, reached); + JoinStep::Continue + } + (Then::Stage, JobResult::Context(context)) => { + self.on_context(ctx, context).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Minted(parts)) => { + self.on_minted(ctx, *parts).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Invited(invited)) => { + self.on_invited(ctx, invited).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Presented(presented)) => { + self.on_presented(ctx, presented).await; + JoinStep::Continue + } + (Then::Stage, JobResult::Submitted(submitted)) => { + self.on_submitted(ctx, submitted).await; + JoinStep::Continue + } + _ => { + debug!("a join job's result did not match what it was started for"); + JoinStep::Continue + } + }; + self.settle(ctx, step) + } + + /// A running join job said what it is doing. + pub(crate) fn on_progress(&mut self, state: &mut State, generation: u64, line: String) { + if self + .session + .as_ref() + .and_then(|s| s.job.as_ref()) + .is_some_and(|j| j.generation == generation) + { + state.join.info(line); + } + } + + /// End the open join now — Exit, an interrupt, the loop stopping. A job in + /// flight is aborted and a launch undone; the session is over. + pub(crate) async fn cancel(&mut self, ctx: &mut JoinCtx<'_>) { + if self.session.is_none() { + return; + } + self.interrupt(ctx).await; + self.end_session(); + } + + /// A persona the join's create-persona overlay minted was just persisted + /// (through the loop's ordinary dispatch path). + /// + /// It is brought online here — in the background: the join is about to + /// offer it as a way in, and a persona that cannot send is not one. The + /// submit waits, bounded, for the socket right before the request goes + /// out, which is where being online matters. A no-op without a service. + pub(crate) async fn on_persona_minted(&mut self, ctx: &mut JoinCtx<'_>, persona_id: PersonaId) { + ctx.state.join.minted_persona = Some(persona_id); + if let Some(did) = ctx + .config + .identities + .get(&persona_id) + .map(|ident| ident.did.clone()) + { + spawn_persona_listener(ctx.messaging, ctx.config, ctx.tdk, persona_id, &did).await; + } + ctx.state + .join + .info("Connecting the new persona to its mediator…".to_string()); + } + + // ---- session bookkeeping ---- + + /// Finish a step: hand a parked launch to the loop that can hear its + /// answer, release the actions a finished job held back, and close the + /// session when the step leaves the flow. + fn settle(&mut self, ctx: &mut JoinCtx<'_>, step: JoinStep) -> JoinStep { + let Some(session) = self.session.as_mut() else { + return step; + }; + let idle = session.job.is_none(); + let step = match step { + // A launch that stopped to ask a hidden-vetting community for its + // challenge leaves the flow here, whichever page or entry started + // it: only the caller's loop hears the answer. Likewise a wallet + // session. + JoinStep::Continue if idle => { + if let Some(awaiting) = ctx.state.join.challenge_wait.take() { + JoinStep::AwaitChallenge(awaiting) + } else if let Some(awaiting) = ctx.state.join.wallet_wait.take() { + JoinStep::AwaitWallet(awaiting) + } else { + JoinStep::Continue + } + } + step => step, + }; + match step { + JoinStep::Continue => { + if idle { + let queued = std::mem::take(&mut session.queued); + for action in queued.into_iter().rev() { + self.leftover.push_front(action); + } + } + } + _ => { + if let Some(job) = session.job.take() { + job.abort.abort(); + ctx.in_flight.finish(DispatchDomain::Join); + } + self.end_session(); + } + } + step + } + + fn end_session(&mut self) { + if let Some(session) = self.session.take() { + for action in session.queued.into_iter().rev() { + self.leftover.push_front(action); + } + } + } + + fn hears_replies(&self) -> bool { + self.session.as_ref().is_some_and(|s| s.hears_replies) + } + + /// Abort the job in flight, and undo a launch that was under way. + async fn interrupt(&mut self, ctx: &mut JoinCtx<'_>) { + let mut submitting = false; + if let Some(job) = self.session.as_mut().and_then(|s| s.job.take()) { + submitting = job.submitting; + job.abort.abort(); + ctx.in_flight.finish(DispatchDomain::Join); + } + if submitting && self.session.as_ref().is_some_and(|s| s.launch.is_some()) { + // Interrupted mid-submit (the app is stopping): the request may + // have gone out, so the persona and its listener are kept rather + // than forgotten — deleting a DID the community may now hold is + // worse than leaving one unused. + self.end_launch(ctx); + ctx.state.join.completed = Completion::CompletedFail; + ctx.state.join.info( + "Stopped while sending the join request. It may have been sent; the persona was kept. Check the community's status from Communities.", + ); + ctx.state + .main_page + .log("Join stopped while its request was being sent; persona kept."); + return; + } + if self.session.as_ref().is_some_and(|s| s.launch.is_some()) { + self.compensate(ctx).await; + self.end_launch(ctx); + ctx.state.join.completed = Completion::CompletedFail; + ctx.state.join.info( + "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA.", + ); + ctx.state.main_page.log("Join cancelled by user."); + } + } + + /// Start a job off the loop. `make` builds its future from the I/O seam; + /// `wrap` says which result it is. + fn start( + &mut self, + ctx: &mut JoinCtx<'_>, + then: Then, + make: impl FnOnce(&dyn JoinIo, Progress) -> IoFuture<'static, T>, + wrap: fn(T) -> JobResult, + ) { + let Some(session) = self.session.as_mut() else { + return; + }; + self.generation += 1; + let generation = self.generation; + let progress = Progress { + tx: ctx.dispatch_tx.clone(), + generation, + }; + let work = make(self.io.as_ref(), progress); + if !ctx.in_flight.try_begin(DispatchDomain::Join) { + debug!("the join domain was still claimed when a join job started"); + } + let task = tokio::spawn(async move { wrap(work.await) }); + let abort = task.abort_handle(); + let tx = ctx.dispatch_tx.clone(); + tokio::spawn(async move { + let result = match task.await { + Ok(result) => result, + // Aborted: whoever aborted it has moved on and freed the domain. + Err(e) if e.is_cancelled() => return, + Err(e) => { + error!(error = %e, "join job panicked"); + JobResult::Panicked + } + }; + let _ = tx.send(DispatchOutcome::Join(Box::new(JoinOutcome { + generation, + result, + }))); + }); + session.job = Some(Job { + generation, + abort, + then, + submitting: false, + }); + } + + fn launch_mut(&mut self) -> Option<&mut Launch> { + self.session.as_mut().and_then(|s| s.launch.as_mut()) + } + + // ---- the pages ---- + + /// One action on an idle join: the old loop's `match`, arm for arm. + async fn act(&mut self, ctx: &mut JoinCtx<'_>, action: Action) -> JoinStep { + match action { + Action::Exit => return JoinStep::Exit(Interrupted::UserInt), + Action::UXError(interrupted) => return JoinStep::Exit(interrupted), + Action::JoinCancel => { + // Leave the flow; the caller restores the main page. Hand back + // the joined session (if any) so the runtime loop can bring its + // live session up (R-B-5). + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(joined_session(&ctx.state.join)); + } + Action::JoinPasteVic(text) => { + // #3: a pasted invitation credential — validate it is a VIC and + // stash it so the join presents it (mirrors the `--invitation + // ` launch flag). On the entry page the community isn't + // chosen yet, so only the credential shape is checked; on the + // invitation step it is, so the paste is also matched against it. + let vtc = if ctx.state.join.page == JoinPage::InvitationChoice { + ctx.state.join.pending_vtc.clone() + } else { + None + }; + ctx.state.join.messages.clear(); + load_pasted_vic(ctx.state, ctx.tdk.did_resolver(), &text, vtc.as_deref()).await; + ctx.state.join.invitation_foreign_subject = ctx + .state + .invitation_credential + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)); + } + Action::JoinClipboardFailed(why) => { + // Only the failure reaches the loop: `[Ctrl+V]` reads and applies + // the text where the input lives, so a pasted DID never takes a + // trip through here to be mistaken for an invitation. + ctx.state.join.messages.clear(); + ctx.state.join.messages.push(MessageType::Error(format!( + "Could not read the clipboard ({why}). Paste directly into \ + this screen instead — that works over SSH, where reading \ + the clipboard cannot." + ))); + } + Action::JoinClearVic => { + // Explicit "proceed without a VIC": drop the loaded invitation so + // it isn't presented, and flag the clear so the entry page shows + // "joining without an invitation". + ctx.state.invitation_credential = None; + ctx.state.join.has_invitation = false; + ctx.state.join.invitation_issuer = None; + ctx.state.join.invitation_foreign_subject = None; + ctx.state.join.vic_cleared = true; + ctx.state.join.messages.clear(); + } + Action::JoinSubmitVtc(vtc_did) => { + let vtc_did = match validate_join_input(&vtc_did) { + Ok(did) => did, + // Say why nothing happened. A keypress that cannot proceed + // must not be a silent no-op — that reads as a frozen screen + // (issue #29). + Err(why) => { + ctx.state.join.messages.push(MessageType::Error(why)); + return JoinStep::Continue; + } + }; + // Accept an agent name (`example.com/@acme`) in place of the VTC + // DID. Only resolve when the input actually looks like a name, + // so pasting a DID keeps its existing (no-extra-round-trip) + // path; the resolved DID is what everything downstream persists. + if openvtc_core::agent_name::looks_like_agent_name(&vtc_did) { + // Say what the wait is for: a name costs a redirect, a + // resolve and a check (bounded by IDENTIFIER_RESOLVE_TIMEOUT). + // Off the loop, so Esc and F10 are heard while it runs. + ctx.state + .join + .messages + .push(MessageType::Info(format!("Resolving {vtc_did}…"))); + let resolver = ctx.tdk.did_resolver().clone(); + self.start( + ctx, + Then::Enter, + |io, _| io.resolve(resolver, vtc_did), + JobResult::Resolved, + ); + } else { + self.enter_community(ctx, vtc_did); + } + } + Action::JoinIdentitySelect(i) => { + // Clamp to the reuse rows plus the trailing "mint" row. + ctx.state.join.identity_selected = i.min(ctx.state.join.mint_row()); + // Moving the highlight dismisses any armed warning. + ctx.state.join.reuse_confirm = None; + } + Action::JoinNewPersonaMethodCycle => { + ctx.state.join.new_persona_did_method = + next_did_method(ctx.state.join.new_persona_did_method); + } + Action::JoinIdentityChoose => { + if ctx.state.join.mint_row_selected() { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.present_invitation = + mint_can_present(ctx.config, ctx.state, &vtc_did); + if let Some(context_id) = + offer_contexts(ctx.state, ctx.config, IdentityPick::Mint, &vtc_did) + { + self.launch(ctx, IdentityPick::Mint, vtc_did, context_id) + .await; + } + } else if let Some(opt) = ctx + .state + .join + .persona_options + .get(ctx.state.join.identity_selected) + { + // Arm the cross-community linkage warning (D1). + ctx.state.join.reuse_confirm = Some(opt.id); + } + } + Action::JoinReuseConfirm => { + let Some(persona_id) = ctx.state.join.reuse_confirm else { + return JoinStep::Continue; + }; + // The community DID stays parked in `pending_vtc`; the + // invitation step is what launches the join now. + if ctx.state.join.pending_vtc.is_none() { + return JoinStep::Continue; + } + ctx.state.join.reuse_confirm = None; + open_invitation_choice(ctx.state, ctx.config, persona_id); + } + Action::JoinReuseCancel => { + ctx.state.join.reuse_confirm = None; + } + Action::JoinInvitationSelect(i) => { + // Rows: 0..len an invitation, len = paste, len+1 = without. + let max = ctx.state.join.invitation_without_row(); + ctx.state.join.invitation_use_selected = i.min(max); + } + Action::JoinInvitationChoose => { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + let Some(persona_id) = ctx.state.join.invitation_for_persona else { + return JoinStep::Continue; + }; + // The paste row loads a VIC instead of launching: it is the + // answer to "I have one, it just isn't in the vault". Read the OS + // clipboard as a convenience; the portable path is the + // terminal's own bracketed paste, which arrives as + // `JoinPasteVic` from anywhere on this page. Either way we stay + // on the step so the operator sees the loaded invitation before + // committing to it. + if ctx.state.join.invitation_use_selected == ctx.state.join.invitation_paste_row() { + ctx.state.join.messages.clear(); + match crate::clipboard::read_clipboard() { + Ok(text) => { + load_pasted_vic( + ctx.state, + ctx.tdk.did_resolver(), + &text, + Some(&vtc_did), + ) + .await; + } + Err(why) => { + ctx.state.join.messages.push(MessageType::Error(format!( + "Could not read the clipboard ({why}). Paste the \ + invitation JSON directly into this screen instead." + ))); + } + } + return JoinStep::Continue; + } + // A selected invitation row presents that VIC; the trailing row + // joins without one. + let sel = ctx.state.join.invitation_use_selected; + if let Some(av) = ctx.state.join.invitation_options.get(sel) { + ctx.state.invitation_credential = Some(av.body.clone()); + ctx.state.join.present_invitation = true; + } else { + ctx.state.invitation_credential = None; + ctx.state.join.present_invitation = false; + } + let pick = IdentityPick::Reuse(persona_id); + if let Some(context_id) = offer_contexts(ctx.state, ctx.config, pick, &vtc_did) { + self.launch(ctx, pick, vtc_did, context_id).await; + } + } + Action::JoinAnswersSelect(i) => { + if let (Some(answers), Some(client)) = + (ctx.state.join.answers.as_mut(), ctx.admin_vta) + { + answers.selected = i.min(answers.faces.len().saturating_sub(1)); + load_shown(answers, client).await; + } + } + Action::JoinAnswersChoose => { + let Some(answers) = ctx.state.join.answers.as_mut() else { + return JoinStep::Continue; + }; + let Some((face_id, face_name)) = answers.faces.get(answers.selected).cloned() + else { + answers.error = Some( + "You have no face to answer with. Make one under Identity, \ + then join again." + .into(), + ); + return JoinStep::Continue; + }; + let missing = answers.unanswered(); + if !missing.is_empty() { + answers.error = Some(format!( + "{face_name} does not show {} — the community requires it. \ + Pick another face, or add it to this one under Identity.", + missing.join(", ") + )); + return JoinStep::Continue; + } + // What the holder approves is these values, exactly. + let approved = answers + .shown + .iter() + .filter_map(|(t, v)| v.clone().map(|v| (t.clone(), v))) + .collect(); + answers.approved = Some((face_id, approved)); + let Some((pick, vtc_did, context_id)) = answers.parked.take() else { + return JoinStep::Continue; + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinWalletStart => { + let Some(wallet) = ctx.state.join.wallet.as_ref() else { + return JoinStep::Continue; + }; + if !matches!(wallet.phase, WalletPhase::Offered { .. }) { + return JoinStep::Continue; + } + let Some((IdentityPick::Reuse(persona), vtc_did, _)) = wallet.parked.clone() else { + return JoinStep::Continue; + }; + let digest = wallet.requirements_digest.clone(); + match ask_wallet_start( + ctx.config, + ctx.tdk, + ctx.messaging, + &vtc_did, + persona, + &digest, + ) + .await + { + Ok(awaiting) => { + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Opening; + } + ctx.state.join.wallet_wait = Some(awaiting); + } + Err(why) => { + if let Some(wallet) = ctx.state.join.wallet.as_mut() { + wallet.phase = WalletPhase::Offered { note: Some(why) }; + } + } + } + } + Action::JoinWalletSkip => { + let parked = ctx.state.join.wallet.as_mut().and_then(|w| { + matches!(w.phase, WalletPhase::Offered { .. }) + .then(|| w.parked.take()) + .flatten() + }); + let Some((pick, vtc_did, context_id)) = parked else { + return JoinStep::Continue; + }; + ctx.state.join.wallet = None; + ctx.state.join.wallet_outcome = Some(WalletOutcome::Skipped); + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinRegistryToggle => { + if let Some(registry) = ctx.state.join.registry.as_mut() { + registry.publish = !registry.publish; + } + } + Action::JoinRegistryConfirm => { + // Either answer is an answer. What is sent is the box as it + // stands now — read again by the launch via + // `RegistryChoice::consent_for`, never cached apart from the + // page that showed it. + let Some(registry) = ctx.state.join.registry.as_mut() else { + return JoinStep::Continue; + }; + registry.confirmed = true; + let Some((pick, vtc_did, context_id)) = registry.parked.take() else { + return JoinStep::Continue; + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinContextSelect(i) => { + let last = ctx.state.join.context_options.len().saturating_sub(1); + ctx.state.join.context_selected = i.min(last); + ctx.state.join.messages.clear(); + } + Action::JoinContextSlug(slug) => { + ctx.state.join.context_slug = slug; + ctx.state.join.messages.clear(); + } + Action::JoinContextChoose => { + let (Some(vtc_did), Some(pick)) = ( + ctx.state.join.pending_vtc.clone(), + ctx.state.join.picked_identity, + ) else { + return JoinStep::Continue; + }; + let Some(option) = ctx + .state + .join + .context_options + .get(ctx.state.join.context_selected) + .cloned() + else { + return JoinStep::Continue; + }; + let context_id = if option.kind == ContextKind::New { + match community_context::new_context_id( + &ctx.config.account.top_context_id, + &ctx.state.join.context_slug, + |id| sequence::context_taken(ctx.config, id), + ) { + Ok(id) => id, + Err(e) => { + ctx.state.join.messages.clear(); + ctx.state + .join + .messages + .push(MessageType::Error(e.to_string())); + return JoinStep::Continue; + } + } + } else { + option.context_id + }; + self.launch(ctx, pick, vtc_did, context_id).await; + } + Action::JoinVettingRow(forward) => { + if let Some(known) = known_vetting(ctx.state) { + known.step(forward); + } + } + Action::JoinVettingTake => { + // Enter on the routes list. A route the page drew as blocked must + // say why rather than do nothing — a keypress that cannot proceed + // reads as a frozen screen (issue #29). + let Some(option) = + known_vetting(ctx.state).and_then(|known| known.selected_route().cloned()) + else { + return JoinStep::Continue; + }; + if !option.available() { + let why = option.blocked().unwrap_or_default(); + ctx.state.join.messages.clear(); + ctx.state + .join + .messages + .push(MessageType::Error(format!("{}: {why}", option.label))); + return JoinStep::Continue; + } + // A route that starts with the join doing something: do it, and + // remember what it was on the way to. That is the difference + // between being guided and being told to go and configure + // something elsewhere. + // + // A step that is merely the next page needs none of this — + // taking the route reaches it. + if option.first_step_kind() == Some(FirstStepKind::CreatePersona) { + ctx.state.join.messages.clear(); + ctx.state.join.resume_route = Some(option.route); + crate::state_handler::handle_nav_action(ctx.state, &Action::StartCreatePersona); + return JoinStep::Continue; + } + return self.take_route(ctx, option.route, option.applicant).await; + } + Action::JoinVettingApply => { + // `a`: read the highlighted row's own application. + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + let Some(persona) = known_vetting(ctx.state).and_then(|k| k.selected_persona()) + else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + match apply_for_vetting( + ctx.state, + ctx.config, + self.io.as_ref(), + ctx.save, + &vtc_did, + persona, + ) + .await + { + Ok(()) => { + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(None); + } + Err(why) => ctx.state.join.messages.push(MessageType::Error(why)), + } + } + Action::JoinVettingJoin => { + // `j`, offered only while the community is being asked or could + // not be: there are no rows yet, so no persona has been chosen + // and none of its statements are presumed. The identity step + // asks, and the submit attaches the chosen persona's own. + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + self.continue_join(ctx, vtc_did).await; + } + // The create-persona overlay, hosted here rather than on the main + // page. Everything except the mint is the shared pure reducer, so + // the phases and keys cannot drift from the ones the main page + // shows. + action if crate::state_handler::handle_nav_action(ctx.state, &action) => { + // A persona minted behind this page changes what the routes say, + // so they are re-derived once the overlay is gone. Cheap, + // idempotent, and it means the row that was blocked on having no + // persona unblocks itself rather than waiting for a re-entry. + if ctx.state.join.page == JoinPage::Vetting + && ctx.state.main_page.create_persona.is_none() + && let Some(vtc_did) = ctx.state.join.pending_vtc.clone() + { + show_vetting(ctx.state, ctx.config, &vtc_did); + // And carry on where the step interrupted — as the persona + // the step made, on that persona's own row. Only when the + // step actually produced one: an overlay closed with Esc + // leaves the page as it was, with the "new persona" row + // still saying what it starts with. + let minted = ctx.state.join.minted_persona; + let resumable = ctx.state.join.resume_route.and_then(|route| { + let known = known_vetting(ctx.state)?; + let row = match route { + JoinRoute::Vetting => known.row_for(Applicant::Persona(minted?))?, + other => known.row_of(other)?, + }; + let option = known.routes.get(row)?; + (option.first_step().is_none() && option.available()).then_some(( + row, + option.route, + option.applicant, + )) + }); + if let Some((row, route, applicant)) = resumable { + ctx.state.join.resume_route = None; + if let Some(known) = known_vetting(ctx.state) { + known.row = row; + } + return self.take_route(ctx, route, applicant).await; + } + } + } + // The mint itself goes through the loop's ordinary dispatch path; + // when it settles, the loop hands the persona back to + // [`JoinDriver::on_persona_minted`] while this join is open. + Action::CreatePersonaSubmit => { + crate::state_handler::spawn_persona_mint( + ctx.dispatch_tx, + ctx.in_flight, + ctx.state, + ctx.config, + ctx.tdk, + ctx.admin_vta, + ); + } + Action::CreatePersonaCopy => { + if let Some(did) = ctx + .state + .main_page + .create_persona + .as_ref() + .and_then(|o| o.did.clone()) + { + let copied = crate::clipboard::copy_to_clipboard(&did).is_ok(); + if let Some(o) = ctx.state.main_page.create_persona.as_mut() { + o.copied = copied; + } + } + } + Action::JoinVettingAskAgain => { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + if self.hears_replies() + && let Ok(awaiting) = + ask_requirements(ctx.state, ctx.config, ctx.tdk, ctx.messaging, &vtc_did) + .await + { + return JoinStep::AwaitRequirements(awaiting); + } + } + _ => {} + } + JoinStep::Continue + } + + /// Enter `vtc_did` into the flow: learn what it asks of the people who + /// join, and put up the page that answers it. + /// + /// Shared by the DID entry page and by a join started for a community + /// already chosen (a vetting application's), so both get the same + /// discovery rather than the entry page's alone. The reading runs off the + /// loop; [`Self::entered`] decides on it. + fn enter_community(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + // Known before is not known now. A community changes what it asks, and + // a join decided on the last visit's copy shows the person a community + // that no longer exists. So the manifest is read again on every join, + // over the endpoint that needs nothing of us (bounded, R1.2). Only when + // the community cannot be reached does the stored copy stand in. + let knew = knowledge_of(ctx.config, &vtc_did); + // The re-read records how it signs; if it cannot be made, the DID + // document may still resolve on its own. + let document = if knew == Some(true) + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + { + DocumentWhen::IfManifestFails + } else { + DocumentWhen::Never + }; + self.discover(ctx, vtc_did, Purpose::Enter { knew }, true, document); + } + + /// Read a community off the loop: its invitations when they are not + /// collected yet (whether presenting one is a way in is a fact about this + /// account, and the vetting page cannot say what the options are without + /// it), its manifest, its DID document. + fn discover( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + purpose: Purpose, + manifest: bool, + document: DocumentWhen, + ) { + let invitations = (ctx.state.join.invitations_for.as_deref() != Some(vtc_did.as_str())) + .then(|| ctx.state.invitation_credential.clone()); + let req = DiscoverReq { + resolver: ctx.tdk.did_resolver().clone(), + admin_vta: ctx.admin_vta.cloned(), + vtc_did: vtc_did.clone(), + invitations, + manifest, + document, + }; + self.start( + ctx, + Then::Discovered { vtc_did, purpose }, + |io, _| io.discover(req), + |found| JobResult::Discovered(Box::new(found)), + ); + } + + /// A community was read: take in what was found and carry on with what it + /// was read for. + async fn discovered( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + purpose: Purpose, + found: Discovered, + ) -> JoinStep { + let Discovered { + invitations, + manifest, + document, + } = found; + if let Some(vics) = invitations { + ctx.state.join.available_vics = vics; + ctx.state.join.invitations_for = Some(vtc_did.clone()); + } + match purpose { + Purpose::Enter { knew } => { + return self.entered(ctx, vtc_did, knew, manifest, document).await; + } + Purpose::Resume(outcome) => match outcome { + RequirementsOutcome::Unanswered(reason) => { + // We got here by sending the question, so the DID resolved: + // it is the answer that never came. + show_unknown(ctx.state, ctx.config, &vtc_did, reason, true, true); + } + outcome => { + let learned = matches!(outcome, RequirementsOutcome::Learned); + if learned + && ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + && let Some(doc) = document + { + sequence::note_signing(ctx.config, &vtc_did, &doc); + } + let shown = learned && show_vetting(ctx.state, ctx.config, &vtc_did); + if !shown { + self.continue_join(ctx, vtc_did).await; + } + } + }, + Purpose::ContinueJoin => self.continue_with_invitations(ctx, vtc_did).await, + Purpose::InvitationChoice(persona) => { + open_invitation_choice(ctx.state, ctx.config, persona); + } + } + JoinStep::Continue + } + + /// Decide which page answers a community just read. + /// + /// Peer identity vetting: a community that vets says what it requires + /// before anything about the applicant is sent (vetting-process.md §6.1). + /// What the book knows decides at once. Otherwise the community is asked, + /// and the wait happens in the caller's loop — the one that hears the + /// answer — which enters this flow again. + async fn entered( + &mut self, + ctx: &mut JoinCtx<'_>, + vtc_did: String, + knew: Option, + mut manifest: Option>, + document: Option, + ) -> JoinStep { + let mut knowledge = knew; + if knowledge.is_some() { + match manifest.take() { + Some(Ok(fetched)) => { + sequence::learn_fetched(ctx.config, &vtc_did, fetched); + knowledge = knowledge_of(ctx.config, &vtc_did); + } + Some(Err(why)) => { + debug!(community = %vtc_did, reason = %why.reason(), "manifest not re-read"); + ctx.state.main_page.log(format!( + "Could not read {}'s current requirements ({}); showing what it said \ + last time.", + vetting_actions::community_display(ctx.config, &vtc_did), + why.reason() + )); + } + None => {} + } + } + match knowledge { + Some(true) => { + // The re-read above records how it signs; if it could not be + // made, the DID document may still have resolved on its own. + if ctx + .config + .private + .vetting + .post_quantum_key(&vtc_did) + .is_none() + && let Some(doc) = document + { + sequence::note_signing(ctx.config, &vtc_did, &doc); + } + show_vetting(ctx.state, ctx.config, &vtc_did); + } + // Known before, and not after reading it again: read it as a first + // visit would. + None if knew.is_some() => self.enter_community(ctx, vtc_did), + None => match manifest { + // The community's own endpoint first. It needs no mediator, no + // persona and no loop that can hear a reply, so it answers on a + // first join — where the DIDComm question cannot be asked at + // all — and it answers in one round trip everywhere else. + Some(Ok(fetched)) => { + sequence::learn_fetched(ctx.config, &vtc_did, fetched); + if !show_vetting(ctx.state, ctx.config, &vtc_did) { + // It answered, and the answer is that it does not vet. + self.continue_join(ctx, vtc_did).await; + } + } + // Nothing resolved, so there is nobody to ask by any route: a + // DIDComm question needs an endpoint the document would have + // named. Stop here rather than send a request into the dark. + Some(Err(why)) if !why.resolvable() => { + show_unknown( + ctx.state, + ctx.config, + &vtc_did, + why.reason().to_string(), + false, + false, + ); + } + Some(Err(why)) if self.hears_replies() => { + debug!(community = %vtc_did, reason = %why.reason(), "manifest not read over REST"); + if let Ok(awaiting) = + ask_requirements(ctx.state, ctx.config, ctx.tdk, ctx.messaging, &vtc_did) + .await + { + return JoinStep::AwaitRequirements(awaiting); + } + // The page now says why the question could not be sent. + } + Some(Err(why)) => { + // Both ways of asking are shut: its endpoint did not answer, + // and this loop has no inbound arm to hear a message on. Say + // so rather than sending an open request on the quiet — a + // community that vets refers that request to its moderators, + // and the applicant would never learn there was a way in they + // could have taken. + show_unknown( + ctx.state, + ctx.config, + &vtc_did, + why.reason().to_string(), + false, + true, + ); + } + None => {} + }, + Some(false) => self.continue_join(ctx, vtc_did).await, + } + JoinStep::Continue + } + + /// Take one of the community's ways in, as `applicant` when it is a + /// vetting row. + /// + /// Split out of the keypress so the join can also take a route it had to + /// interrupt: a route whose first step was creating a persona resumes here + /// once that persona exists, rather than putting the person back on the + /// list to press the same key again. + /// + /// Which persona presents statements is decided here and nowhere else on + /// this page, and only from the row's own persona: its application, ready + /// or not. The invitation and open-request rows name no persona, so they go + /// to the identity step, and the submit attaches whatever the persona + /// chosen there gathered for this community — its own, matched on its DID, + /// which the review page shows before it is sent. + async fn take_route( + &mut self, + ctx: &mut JoinCtx<'_>, + route: JoinRoute, + applicant: Option, + ) -> JoinStep { + let Some(vtc_did) = ctx.state.join.pending_vtc.clone() else { + return JoinStep::Continue; + }; + ctx.state.join.messages.clear(); + match route { + // The invitation is chosen further on, once the persona it is bound + // to is known: the invitation step lists this community's VICs for + // that persona and is where one is picked. + JoinRoute::Invitation | JoinRoute::OpenRequest => { + self.continue_join(ctx, vtc_did).await; + } + JoinRoute::Vetting => { + let ready = known_vetting(ctx.state).and_then(|k| k.ready_as(applicant)); + match (ready, applicant) { + // This persona's own statements meet the requirements: + // joining is what presents them, and they fix the persona — + // so the identity step has nothing left to ask and the flow + // goes straight to the invitation choice. + (Some(persona), _) => { + if ctx.state.join.invitations_for.as_deref() == Some(vtc_did.as_str()) { + open_invitation_choice(ctx.state, ctx.config, persona); + } else { + self.discover( + ctx, + vtc_did, + Purpose::InvitationChoice(persona), + false, + DocumentWhen::Never, + ); + } + } + // Anything else is taken by working on this persona's own + // application — carrying it on, or starting it. + (None, Some(Applicant::Persona(persona))) => { + match apply_for_vetting( + ctx.state, + ctx.config, + self.io.as_ref(), + ctx.save, + &vtc_did, + persona, + ) + .await + { + Ok(()) => { + ctx.state.active_page = ActivePage::Main; + return JoinStep::Returned(None); + } + Err(why) => ctx.state.join.messages.push(MessageType::Error(why)), + } + } + // A new persona's row starts by making it (its first step is + // taken before this is reached) and comes back on that + // persona's own row. Reaching here without one is a page out + // of step with itself; say so rather than guess. + (None, Some(Applicant::NewPersona) | None) => { + ctx.state.join.messages.push(MessageType::Error( + "Applying as a new persona starts by making it — press Enter on \ + \"Apply for vetting as a new persona\"." + .to_string(), + )); + } + } + } + } + JoinStep::Continue + } + + /// Go on with a join once any vetting has been dealt with: collect the + /// community's invitations, then choose the identity to present, or mint + /// one on a first join. + async fn continue_join(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + ctx.state.join.vetting = None; + // Identity first: collect the invitations available for this community + // so each persona can be badged with its usable-invitation count, then + // let the operator pick the identity to present (the invitation choice, + // if any, follows for the chosen persona). + if ctx.state.join.invitations_for.as_deref() == Some(vtc_did.as_str()) { + self.continue_with_invitations(ctx, vtc_did).await; + } else { + self.discover( + ctx, + vtc_did, + Purpose::ContinueJoin, + false, + DocumentWhen::Never, + ); + } + } + + async fn continue_with_invitations(&mut self, ctx: &mut JoinCtx<'_>, vtc_did: String) { + // Idempotency is per-persona (R-B-9): a community may be joined as more + // than one persona, so the duplicate check happens once the identity is + // chosen (in the launch) rather than at the community level here. + let options = build_persona_options(ctx.config, &ctx.state.join.available_vics); + if options.is_empty() { + // First join — nothing to reuse; mint a fresh identity. With no + // persona to vouch for it, the new one cannot present an invitation + // (`mint_can_present` is false); the loaded one is kept so the launch + // stores it in the vault and says why it went unused. + ctx.state.join.present_invitation = mint_can_present(ctx.config, ctx.state, &vtc_did); + ctx.state.join.pending_vtc = Some(vtc_did.clone()); + if let Some(context_id) = + offer_contexts(ctx.state, ctx.config, IdentityPick::Mint, &vtc_did) + { + self.launch(ctx, IdentityPick::Mint, vtc_did, context_id) + .await; + } + } else { + ctx.state.join.pending_vtc = Some(vtc_did); + ctx.state.join.persona_options = options; + ctx.state.join.identity_selected = 0; + ctx.state.join.reuse_confirm = None; + ctx.state.join.page = JoinPage::IdentityChoice; + } + } + + // ---- the launch ---- + + /// Launch the join for the chosen identity, unless something must be asked + /// first: the community's questions, an unreadable directory statement, + /// the trust-registry consent, a wallet, a hidden-vetting challenge. Every + /// way to submit passes through here, so none can skip them. + async fn launch( + &mut self, + ctx: &mut JoinCtx<'_>, + choice: IdentityPick, + vtc_did: String, + context_id: String, + ) { + // A community that asks the applicant to tell it about themselves is + // answered before anything else happens: which face, and exactly what it + // would send. The launch waits on the Answers page and resumes from + // `JoinAnswersChoose`. + let asked = ctx.config.private.vetting.requested_attributes(&vtc_did); + let answered = ctx + .state + .join + .answers + .as_ref() + .is_some_and(|a| a.approved.is_some()); + if !asked.is_empty() && !answered { + open_answers( + ctx.state, + ctx.admin_vta, + asked, + (choice, vtc_did, context_id), + ) + .await; + return; + } + + // A directory statement the community published but this client could + // not read stops the join: the person would otherwise apply without + // seeing what joining makes visible about them (`manifest/0.4`). + if let Some(DirectoryStatement::Malformed(why)) = + ctx.config.private.vetting.directory(&vtc_did) + { + let name = vetting_actions::community_display(ctx.config, &vtc_did); + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.messages.clear(); + ctx.state.join.fail(format!( + "{name} published a statement of what its member directory would show about you, and it could not be read ({}). Nothing has been sent: joining without seeing it is not offered. Ask the community to fix it, then try again.", + sanitize_display(why, 200) + )); + return; + } + + // Then the trust-registry question, last, so it is the final thing the + // person sees before the request goes out — and on every join, because + // there is no right answer to assume. + let registry_answered = ctx + .state + .join + .registry + .as_ref() + .is_some_and(|r| r.answered_for(&vtc_did)); + if !registry_answered { + let community_name = ctx.config.agent_name_for(&vtc_did).map(str::to_string); + let review = join_review(ctx.config, ctx.state, &vtc_did, &choice); + let directory = match ctx.config.private.vetting.directory(&vtc_did) { + Some(DirectoryStatement::Stated(d)) => { + Some(crate::state_handler::join::DirectoryReview { + sentences: d.describe(|label| sanitize_display(label, 64)), + statement_digest: d.statement_digest.to_string(), + }) + } + _ => None, + }; + open_registry_choice(ctx.state, community_name, (choice, vtc_did, context_id)); + if let Some(registry) = ctx.state.join.registry.as_mut() { + registry.review = Some(review); + registry.directory = directory; + } + return; + } + + // A criterion asking for a credential a wallet presents: offer to + // collect it now, before the request goes out, so the submit can name + // the criterion and the community counts the evidence it already holds. + if let Some(offer) = wallet_offer(ctx.config, ctx.state, &vtc_did, &choice) { + ctx.state.join.wallet = Some(WalletStep { + parked: Some((choice, vtc_did, context_id)), + ..offer + }); + ctx.state.join.page = JoinPage::Wallet; + ctx.state.join.processing = false; + return; + } + + // A community that hides its vetters takes a proof bound to a challenge + // it issues — once, and fresh for each attempt (see `challenge_step`). + // Asked for here, after every question the person answers, so the + // challenge is as young as it can be when the proof is built over it. + // The flow hands the wait to the loop that can hear the answer and comes + // back through `JoinEntry::Challenge`, which lands here again. + let fresh = std::mem::take(&mut ctx.state.join.challenge_fresh); + let step = challenge_step( + presented_application(ctx.config, &vtc_did, &choice), + ctx.state.join.hears_replies, + fresh, + ); + if step == ChallengeStep::Ask + && let IdentityPick::Reuse(persona) = choice + { + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.completed = Completion::NotFinished; + ctx.state.join.messages.clear(); + let _ = ctx.state_tx.publish(ctx.state); + let name = vetting_actions::community_display(ctx.config, &vtc_did); + match ask_challenge(ctx.config, ctx.tdk, ctx.messaging, &vtc_did, persona).await { + Ok(document_id) => { + // Locked while the answer is awaited: the wait is bounded + // (`CHALLENGE_WAIT`), and nothing on this page can usefully + // be done in the meantime. + ctx.state.join.processing = true; + ctx.state.join.waiting_on_challenge = true; + ctx.state.join.info(format!( + "This community hides its vetters. Asking {name} for a fresh challenge \ + to bind your proof to — this gives up after {} seconds. Nothing has \ + been sent yet; Esc stops waiting.", + CHALLENGE_WAIT.as_secs() + )); + ctx.state.join.challenge_wait = Some(AwaitingChallenge { + vtc_did, + document_id, + asked_at: std::time::Instant::now(), + choice, + context_id, + }); + } + Err(reason) => ctx.state.join.fail(challenge_failed_words(&name, &reason)), + } + return; + } + + // Move to the progress page and lock input. + ctx.state.join.page = JoinPage::Progress; + ctx.state.join.processing = true; + ctx.state.join.completed = Completion::NotFinished; + ctx.state.join.messages.clear(); + ctx.state.join.info(format!("Joining {vtc_did}…")); + + // Captured before the mint so a rollback (cancel or failure) can + // restore it — `mint_persona_into` may overwrite `public.friendly_name`. + let prior_friendly_name = ctx.config.public.friendly_name.clone(); + // The person's trust-registry answer, consumed by this one submit. + // Taken, not read: a later join from the same open flow — another + // community, or a retry after a failure — asks again rather than + // inheriting it. + let registry = ctx.state.join.registry.take(); + let registry_consent = registry.as_ref().is_some_and(|r| r.consent_for(&vtc_did)); + // The directory statement the person was shown, named by its digest, + // kept with the join so a later change to the terms can be told apart. + let directory_statement_digest = registry + .filter(|r| r.community == vtc_did) + .and_then(|r| r.directory) + .map(|d| d.statement_digest); + + // The mint + join sequence needs the admin VTA session. + let Some(admin) = ctx.admin_vta.cloned() else { + ctx.state + .join + .fail("VTA session unavailable — cannot join right now."); + return; + }; + let resolver = ctx.tdk.did_resolver().clone(); + if let Some(session) = self.session.as_mut() { + session.launch = Some(Launch { + vtc_did: vtc_did.clone(), + choice, + context_id, + admin, + saga: JoinSaga::default(), + prior_friendly_name, + registry_consent, + directory_statement_digest, + display_name: None, + transports: PeerTransports::default(), + minted_keys: None, + persona: None, + applicant: None, + }); + } + self.start( + ctx, + Then::Stage, + |io, progress| io.reach(ReachReq { resolver, vtc_did }, progress), + JobResult::Reached, + ); + } + + /// Stage 1 came back: the community's name and what it offers. + fn on_reached(&mut self, ctx: &mut JoinCtx<'_>, reached: Reached) { + let Some(launch) = self.launch_mut() else { + return; + }; + // Seeded into the persisted cache so the communities list shows it + // without waiting for the background sweep. + ctx.config + .set_cached_agent_name(&launch.vtc_did, reached.display_name.clone(), Utc::now()); + ctx.state.join.display_name = reached.display_name.clone(); + launch.display_name = reached.display_name; + launch.transports = reached.transports; + // A peer that advertises no messaging service cannot be joined at all, + // and discovering that after the persona mint leaves an orphaned + // identity attached to a request nobody could receive. This is a check + // on what the document *offers*. + let Some(peer_preferred) = launch.transports.preferred() else { + let message = format!( + "This community advertises no messaging transport ({}). Its DID \ + document offers neither a TSP nor a DIDComm service, so a join \ + request cannot reach it. Nothing was created.", + ctx.state + .join + .display_name + .as_deref() + .unwrap_or(&launch.vtc_did) + ); + ctx.state.join.fail(message); + self.end_launch(ctx); + return; + }; + // What the community *offers* — not yet the wire the submit goes out + // on. That needs our own leg too, settled at the submit. + ctx.state + .join + .info(format!("Community reachable over {peer_preferred}.")); + // The community's context exists before anything is minted into it. A + // retried join reuses a context an interrupted one left behind. + let req = ContextReq { + admin_vta: launch.admin.clone(), + top_context_id: ctx.config.account.top_context_id.clone(), + context_id: launch.context_id.clone(), + name: launch + .display_name + .clone() + .unwrap_or_else(|| shorten_did(&launch.vtc_did, 48)), + }; + self.start( + ctx, + Then::Stage, + |io, _| io.context(req), + JobResult::Context, + ); + } + + /// Stage 1 done: the context is there. Choose the persona — reuse one, or + /// start minting. + async fn on_context(&mut self, ctx: &mut JoinCtx<'_>, context: Result) { + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + let context_id = launch.context_id.clone(); + match context { + Ok(true) => ctx + .state + .join + .info(format!("Created context {context_id}.")), + Ok(false) => ctx.state.join.info(format!("Using context {context_id}.")), + Err(e) => { + ctx.state.join.fail(format!( + "Could not prepare context {context_id}: {e}. Nothing was created." + )); + self.end_launch(ctx); + return; + } + } + launch.saga.context_registered(context_id.clone()); + let top_context_id = ctx.config.account.top_context_id.clone(); + // Reuse an existing account persona (R-B-3) or mint a fresh, + // self-contained one (D6). Only a *minted* persona is recorded in the + // saga — a reused persona pre-exists and is never rolled back. + match launch.choice { + IdentityPick::Reuse(persona_id) => { + let Some((did, _)) = io.identity(ctx.config, persona_id) else { + ctx.state + .join + .fail("Selected persona is unavailable — cannot reuse it."); + self.end_launch(ctx); + return; + }; + // Per-persona idempotency (R-B-9): block a second live + // membership as the *same* persona, while still allowing other + // personas. + if is_duplicate_membership(ctx.config, &launch.vtc_did, persona_id) { + ctx.state.join.fail( + "Already a member of (or have a pending request for) this community as this persona.", + ); + self.end_launch(ctx); + return; + } + // A persona's keys live in one context; one minted into a + // sub-context is only ever presented from it. + if let Some(record) = ctx.config.account.personas.get(&persona_id) { + let home = community_context::persona_context(record, &top_context_id); + if community_context::is_sub_context(home, &top_context_id) + && home != context_id + { + let message = format!( + "This persona's keys live in {home}, so it can only join from there." + ); + ctx.state.join.fail(message); + self.end_launch(ctx); + return; + } + } + ctx.state.join.info(format!("Reusing persona {did}…")); + launch.persona = Some((persona_id, did)); + self.connect(ctx).await; + } + IdentityPick::Mint => { + // The persona's mediator is the account's VTA mediator: the DID + // minted via the VTA's webvh server advertises that mediator in + // its DIDComm service, so the persona listener must use the same + // one. A VTA that advertised no mediator refuses the mint here, + // before any key or DID is created at the VTA. + let Some(mediator) = setup_config::vta_mediator(ctx.config) else { + ctx.state.join.fail(setup_config::NO_VTA_MEDIATOR); + self.end_launch(ctx); + return; + }; + ctx.state.setup.custom_mediator = Some(mediator); + ctx.state + .join + .info("Creating persona keys (signing, authentication, encryption)…"); + // How the new persona is presented: the operator's choice on the + // identity page, else the account's preference (VTI-VTA-036). + let req = MintReq { + admin_vta: launch.admin.clone(), + tdk: ctx.tdk.clone(), + context_id, + presentation: openvtc_core::config::account::persona_presentation( + &ctx.config.account, + ctx.state.join.new_persona_did_method, + ), + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.mint(req, progress), + |parts| JobResult::Minted(Box::new(parts)), + ); + } + } + } + + /// Stage 2 came back: what the VTA minted. Persist the persona. + async fn on_minted(&mut self, ctx: &mut JoinCtx<'_>, parts: MintedParts) { + let MintedParts { + keys, + update, + did, + failure, + } = parts; + if let Some(keys) = keys { + ctx.state.setup.did_keys = Some(keys); + } + if let Some((update, next_update)) = update { + ctx.state.setup.vta.update_secret = Some(update); + ctx.state.setup.vta.next_update_secret = Some(next_update); + } + if let Some(vta::MintedDid { + keys, + did, + document, + locator, + }) = did + { + // A persona the VTA minted without `#tsp` can never reach a + // TSP-only community — and this join may be to one. Say so here + // rather than letting it surface later as a request that goes out + // and is never answered. + if let Some(warning) = openvtc_core::config::did::tsp_advertisement_warning(&document) { + ctx.state.join.info(warning); + } + ctx.state.setup.did_keys = Some(keys); + ctx.state.setup.webvh_address.did = did; + ctx.state.setup.webvh_address.document = document; + ctx.state.setup.webvh_address.locator = locator; + } + if let Some(failure) = failure { + ctx.state.join.fail(failure); + self.end_launch(ctx); + return; + } + // A join mints the persona *unlabelled*: the community's name is not + // the persona's. Its community binding is recorded on the membership + // record and shown beside it; the label is for a name the operator + // chose. + ctx.state.setup.username = String::new(); + // `mint_persona_into` writes the persona record + runtime identity + key + // info to disk *immediately*, so from here until the community record is + // persisted the account holds a persona with no community. The saga + // records it so a cancel or later failure rolls it back. + let persona_id = match self + .io + .persist_mint(ctx.config, &ctx.state.setup, ctx.tdk, ctx.profile) + .await + { + Ok(id) => id, + Err(e) => { + ctx.state.join.fail(format!("Failed to save persona: {e}")); + self.end_launch(ctx); + return; + } + }; + let minted_keys = ctx.state.setup.did_keys.clone(); + let Some(launch) = self.launch_mut() else { + return; + }; + launch.saga.minted(persona_id); + launch.minted_keys = minted_keys; + // Where its keys live, which decides every context it can join from. + if let Some(record) = ctx.config.account.personas.get_mut(&persona_id) { + record.origin_context_id = launch.context_id.clone(); + } + let persona_did = ctx.state.setup.webvh_address.did.clone(); + ctx.state + .join + .info(format!("Persona created: {persona_did}")); + launch.persona = Some((persona_id, persona_did)); + self.connect(ctx).await; + } + + /// Stage 3, on the loop: the applicant can send, and its listener is + /// claimed — recorded in the saga *before* it is installed, so a cancel + /// while it connects still removes it. Then off the loop: install it and + /// settle the invitation. + async fn connect(&mut self, ctx: &mut JoinCtx<'_>) { + let io = Arc::clone(&self.io); + let Some(persona_id) = self + .launch_mut() + .and_then(|l| l.persona.as_ref().map(|(id, _)| *id)) + else { + return; + }; + // Submit the join request to the VTC. The persona is the sender (the + // VTC reads the applicant from the envelope). The VTC's submit-receipt + // returns asynchronously to the persona's mediator; until it does, the + // request message id is the correlation handle stored on the Pending + // record. + ctx.state.join.info("Submitting join request…"); + if !io.can_message(ctx.tdk) { + self.fail_and_undo( + ctx, + "Messaging (ATM) unavailable — cannot submit the join request.", + ) + .await; + return; + } + let Some((applicant_did, mediator)) = io.identity(ctx.config, persona_id) else { + self.fail_and_undo( + ctx, + "Persona identity unavailable after mint — cannot submit.", + ) + .await; + return; + }; + let listener = io + .listener_spec( + ctx.messaging, + ctx.config, + ctx.tdk, + persona_id, + &applicant_did, + ) + .await; + let present = ctx.state.join.present_invitation; + let loaded = ctx.state.invitation_credential.take(); + let foreign = if present { + None + } else { + loaded + .as_ref() + .and_then(|vic| foreign_invitation_subject(ctx.config, vic)) + }; + let resolver = ctx.tdk.did_resolver().clone(); + let messaging = ctx.messaging.cloned(); + let Some(launch) = self.launch_mut() else { + return; + }; + if let Some(spec) = &listener { + launch.saga.listener_up(spec.id.clone()); + } + launch.applicant = Some((applicant_did, mediator)); + let req = InviteReq { + messaging, + listener, + admin_vta: launch.admin.clone(), + resolver, + vtc_did: launch.vtc_did.clone(), + present, + loaded, + foreign, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.invite(req, progress), + JobResult::Invited, + ); + } + + /// Stage 3 came back: the listener's fate and the invitation to present. + /// Plan the presenting — the linkage proof's signer, the answers, the + /// hidden-vetting proof — on the loop, and run it off. + async fn on_invited(&mut self, ctx: &mut JoinCtx<'_>, invited: Invited) { + let Some(launch) = self.launch_mut() else { + return; + }; + if invited.listener_up == Some(false) { + launch.saga.listener_failed(); + } + // Setting the presented invitation to the resolved VIC keeps the VP, + // the linkage proof, and the summary all consistent with what is + // actually presented. + let state = &mut *ctx.state; + let config = &*ctx.config; + state.invitation_credential = invited.presentable; + state.join.has_invitation = state.invitation_credential.is_some(); + state.join.presented_invitation = state.invitation_credential.as_ref().map(|vic| { + let subject = openvtc_core::join::invitation_subject(vic).map(str::to_string); + PresentedInvitation { + id: openvtc_core::join::invitation_id(vic) + .unwrap_or_default() + .to_string(), + // Verified-only, from the persisted cache: the subject is one of + // our own personas, so the sweep normally has a name for it. + subject_agent_name: subject + .as_deref() + .and_then(|s| config.agent_name_for(s)) + .map(|n| sanitize_display(n, 256)), + subject, + } + }); + // Present the holder VP. When a matching, unexpired invitation (VIC) is + // resolved it rides in the VP's `verifiableCredential` array; the VTC + // verifies it and auto-admits on a valid, trusted, unconsumed + // invitation. With none the join is an open request the community + // reviews. + if state.invitation_credential.is_some() { + state + .join + .info("Presenting your invitation credential to the community…"); + } else if state.join.present_invitation { + // Wanted to present one, but none resolved to a usable VIC. + state.join.info( + "No valid invitation for this community — \ + submitting as an open request (awaiting approval).", + ); + } + let Some((persona_id, _)) = launch.persona.clone() else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; + return; + }; + let Some((applicant_did, _)) = launch.applicant.clone() else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; + return; + }; + let linkage = plan_linkage(config, state, &applicant_did); + let answers = state.join.answers.as_ref().and_then(|a| { + a.approved.clone().map(|(face_id, approved)| AnswersPlan { + context_id: launch.context_id.clone(), + persona_did: applicant_did.clone(), + community_did: launch.vtc_did.clone(), + asked: a.asked.clone(), + face_id, + approved, + }) + }); + // Peer identity vetting: present the statements gathered for this + // community under this persona. Under a hidden-vetting criterion the + // proof is built off the loop; everything that stops it before the VTA + // is asked is decided here, and said after the answers, as it always + // was. + let hidden = config + .private + .vetting + .application(&launch.vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) + .map(hidden_plan); + let req = PresentReq { + admin_vta: launch.admin.clone(), + linkage, + answers, + hidden, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.present(req, progress), + JobResult::Presented, + ); + } + + /// Stage 4 came back: build the presentation and read the signer, then + /// send it off the loop. + async fn on_presented(&mut self, ctx: &mut JoinCtx<'_>, presented: Presented) { + let Presented { + linkage, + attributes, + proof, + failure, + } = presented; + if let Some(failure) = failure { + self.fail_and_undo(ctx, failure).await; + return; + } + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + let (Some((persona_id, _)), Some((applicant_did, mediator))) = + (launch.persona.clone(), launch.applicant.clone()) + else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; + return; + }; + let vtc_did = launch.vtc_did.clone(); + if let Some(proof) = proof + && let Some(application) = ctx + .config + .private + .vetting + .application_mut(&vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did && a.hidden.is_some()) + { + application.set_hidden_submission(proof); + } + let mut vp = openvtc_core::join::build_join_vp( + &applicant_did, + ctx.state.invitation_credential.as_ref(), + linkage.as_ref(), + ); + let registry_consent = launch.registry_consent; + let protocol = ctx.config.private.vetting.protocol_for(&vtc_did); + let presentation = match ctx.config.private.vetting.application(&vtc_did, persona_id) { + Some(application) if application.join_did == applicant_did => { + let statements = application.presentable_statements(Utc::now()); + if !statements.is_empty() { + ctx.state.join.info(format!( + "Presenting {} vetting statement(s) to the community…", + statements.len() + )); + } + openvtc_core::join::attach_credentials(&mut vp, statements); + openvtc_core::join::JoinPresentation { + vp, + extensions: application.join_extensions(), + attributes, + protocol, + // The vetting criterion this application was made under. + criterion: application.requirements_digest.clone(), + registry_consent, + } + } + // No application: the community decides under the first criterion + // this submission meets — an invitation, a credential it recognises, + // or review — which is the route the person chose. With wallet + // evidence, under the criterion it was collected for: the community + // counts it only there. + _ => openvtc_core::join::JoinPresentation { + attributes, + protocol, + registry_consent, + criterion: match &ctx.state.join.wallet_outcome { + Some(WalletOutcome::Presented(digest)) => Some(digest.clone()), + _ => None, + }, + ..vp.into() + }, + }; + let prepared = io.prepare_submit(ctx.config, ctx.tdk, persona_id).await; + let req = SubmitReq { + messaging: ctx.messaging.cloned(), + vtc_did, + applicant_did, + mediator, + transports: launch.transports.clone(), + prepared, + presentation, + }; + self.start( + ctx, + Then::Stage, + |io, progress| io.submit(req, progress), + JobResult::Submitted, + ); + if let Some(job) = self.session.as_mut().and_then(|s| s.job.as_mut()) { + job.submitting = true; + } + } + + /// The community a join request is being sent to right now, if one is. + /// The runtime holds that community's messages until the request has + /// been recorded, so a fast answer is not processed before the pending + /// record it answers exists. + pub(crate) fn submitting_to(&self) -> Option<&str> { + let session = self.session.as_ref()?; + session + .job + .as_ref() + .filter(|job| job.submitting) + .and(session.launch.as_ref()) + .map(|launch| launch.vtc_did.as_str()) + } + + /// Stage 5 came back: the request went out, or why not. Record the + /// pending membership. + async fn on_submitted(&mut self, ctx: &mut JoinCtx<'_>, submitted: Submitted) { + let Submitted { + transport: submit_transport, + online: applicant_online, + request, + } = submitted; + let request_id = match request { + Ok(id) => id, + // A persona whose key cannot be read cannot apply. Nothing was sent; + // the persona and its listener are kept, as they always were on + // this path. + Err(SubmitError::Signer(e)) => { + ctx.state + .join + .fail(format!("Couldn't sign the application: {e}")); + self.end_launch(ctx); + return; + } + // Nothing was submitted, so nothing will reply — release the socket + // opened for this attempt and roll back a persona minted for it. + Err(e) => { + self.fail_and_undo(ctx, format!("Failed to submit join request: {e}")) + .await; + return; + } + }; + let io = Arc::clone(&self.io); + let Some(launch) = self.launch_mut() else { + return; + }; + launch.saga.submitted(); + let (Some((persona_id, persona_did)), Some((applicant_did, _))) = + (launch.persona.clone(), launch.applicant.clone()) + else { + self.fail_and_undo(ctx, "The join lost track of its persona part-way through and was undone. Please try again.").await; + return; + }; + let vtc_did = launch.vtc_did.clone(); + // The challenge a hidden proof was bound to is spent now: the community + // consumes it the moment it reads the proof, whatever it then decides. + // Kept, it would be bound into the next attempt's proof and refused + // there. Saved with the membership below. + if let Some(application) = ctx + .config + .private + .vetting + .application_mut(&vtc_did, persona_id) + .filter(|a| a.join_did == applicant_did) + { + application.hidden_submission_sent(); + } + // Record the pending membership and persist. + let record = sequence::pending_record( + vtc_did.clone(), + launch.display_name.clone(), + launch.context_id.clone(), + persona_id, + request_id, + Utc::now(), + submit_transport, + ); + let record = CommunityRecord { + directory_statement_digest: launch.directory_statement_digest.clone(), + ..record + }; + ctx.config.account.add_membership(record.clone()); + launch.saga.persisted(); + let registry_consent = launch.registry_consent; + if let Err(e) = io.save(ctx.save, ctx.config).await { + ctx.state + .join + .fail(format!("Failed to save community record: {e}")); + self.end_launch(ctx); + return; + } + + // Sent — refresh the communities panel and surface the relaunch prompt. + // + // "Sent", not "delivered": the send resolves `Ok` when *our own + // mediator* accepts the frame, which says nothing about whether the + // community received it (R1.1). The acknowledgement is asynchronous; + // `pending_unacknowledged` flags the record in the communities panel if + // nothing arrives within `PENDING_ACK_GRACE_SECS`, so the honest thing to + // do here is name what is still outstanding and point at where the + // answer will show up. + ctx.state.main_page.sync_from_config(ctx.config); + // Durable, unlike the ceremony commentary in `state.join`, which is + // transient UI. Whether the applicant was connected is part of that + // record: a join submitted from an unconnected persona takes the slow + // path, and an operator reading this log later cannot otherwise tell + // that from a community that never answered. + ctx.config.private.logs.insert( + LogFamily::Community, + format!( + "Join request sent to ({}) as persona ({}) over {} — Pending, not yet acknowledged.{}", + ctx.state.join.display_name.as_deref().unwrap_or(&vtc_did), + persona_did, + submit_transport, + if applicant_online { + "" + } else { + " The applicant was not connected at submit; a reply that arrives before it \ + connects is collected from the mediator rather than streamed." + } + ), + ); + ctx.state.main_page.log(format!( + "Join request sent over {submit_transport} — Pending in your Communities list, awaiting \ + the community's acknowledgement." + )); + ctx.state.join.created_community = Some(record); + ctx.state.join.created_persona_did = Some(persona_did); + ctx.state.join.registry_consent_sent = Some(registry_consent); + ctx.state.join.completed = Completion::CompletedOK; + ctx.state.join.info(format!( + "Join request sent over {submit_transport}. Waiting for the community to acknowledge it — \ + it's Pending in your Communities list, which will flag it if no response arrives." + )); + self.end_launch(ctx); + } + + /// The launch failed: say why, undo what it did, and end it. + async fn fail_and_undo(&mut self, ctx: &mut JoinCtx<'_>, why: impl Into) { + ctx.state.join.fail(why); + self.compensate(ctx).await; + self.end_launch(ctx); + } + + /// Undo the launch from its saga: remove the listener it installed, and + /// forget the persona it minted unless something already references it. + async fn compensate(&mut self, ctx: &mut JoinCtx<'_>) { + let Some(launch) = self.session.as_ref().and_then(|s| s.launch.as_ref()) else { + return; + }; + let mut forgot = false; + for compensation in launch.saga.compensations() { + match compensation { + Compensation::RemoveListener(id) => { + if let Some(messaging) = ctx.messaging { + self.io.remove_listener(messaging, &id).await; + } + } + Compensation::ForgetPersona(persona_id) => { + if !ctx.config.account.persona_referenced(&persona_id) { + sequence::forget_minted_persona( + ctx.config, + persona_id, + launch.minted_keys.as_ref(), + &launch.prior_friendly_name, + ); + forgot = true; + } + } + } + } + if forgot && let Err(e) = self.io.save(ctx.save, ctx.config).await { + debug!("persona rollback re-save failed after a failed join: {e}"); + } + } + + /// The launch is over, however it ended: input unlocks. + fn end_launch(&mut self, ctx: &mut JoinCtx<'_>) { + if let Some(session) = self.session.as_mut() { + session.launch = None; + } + ctx.state.join.processing = false; + } + + /// The saga of the launch in progress — for tests. + #[cfg(test)] + fn saga(&self) -> Option<&JoinSaga> { + self.session + .as_ref() + .and_then(|s| s.launch.as_ref()) + .map(|l| &l.saga) + } +} + +/// What a job that panicked leaves on the join page. +const JOB_LOST: &str = "Joining a community failed (internal error)."; + +/// What the book knows about whether `vtc_did` vets. +fn knowledge_of(config: &Config, vtc_did: &str) -> Option { + match config.private.vetting.knowledge(vtc_did) { + Knowledge::Vetting(_) => Some(true), + Knowledge::NoVetting => Some(false), + Knowledge::Unknown => None, + } +} + +/// #1b: the subject-linkage proof to build when the presenting DID differs +/// from the presented VIC's subject — the invited persona authorizes the +/// presenter. `None` on the join-as-subject path (presenter == subject), when +/// no invitation is presented, or when the subject's signer cannot be read +/// (it isn't one of our personas: we can't sign for a key we don't hold, and +/// the VTC then refuses the mismatched binding). +fn plan_linkage(config: &Config, state: &State, presenter_did: &str) -> Option { + let vic = state.invitation_credential.as_ref()?; + let subject = openvtc_core::join::invitation_subject(vic)?; + if subject == presenter_did { + return None; // join-as-subject — no linkage needed + } + let vic_id = openvtc_core::join::invitation_id(vic)?; + let signer = match config.subject_signer(subject) { + Ok(signer) => signer, + Err(e) => { + debug!(subject = %subject, error = %e, "subject-linkage proof unavailable"); + return None; + } + }; + let name = config + .agent_name_for(subject) + .map(|n| sanitize_display(n, 256)) + .unwrap_or_else(|| shorten_did(subject, 48)); + Some(LinkagePlan { + signer, + subject: subject.to_string(), + vic_id: vic_id.to_string(), + presenter: presenter_did.to_string(), + said: format!( + "The invitation names {name}, which signs for this persona — the community \ + will see that the two are linked." + ), + }) +} + +/// What a hidden-vetting application's launch does about its proof: refuse +/// with the sentence the progress page fails with, or ask the VTA for it. +fn hidden_plan(application: &Application) -> HiddenPlan { + let challenge = match hidden_proof_precheck(application) { + Ok(challenge) => challenge, + Err(refusal) => return HiddenPlan::Refuse(refusal), + }; + // Hidden parameters but no application at the VTA: no vetter's attestation + // has arrived. + let (Some(params), Some(app)) = (application.hidden.clone(), application.hidden_app.clone()) + else { + return HiddenPlan::Refuse(NO_ATTESTATION.to_string()); + }; + HiddenPlan::Prove { + application_id: app.application_id, + params: Box::new(params), + digest: application.requirements_digest.clone().unwrap_or_default(), + challenge, + } +} diff --git a/openvtc/src/state_handler/join_flow/driver/tests.rs b/openvtc/src/state_handler/join_flow/driver/tests.rs new file mode 100644 index 00000000..6795cf32 --- /dev/null +++ b/openvtc/src/state_handler/join_flow/driver/tests.rs @@ -0,0 +1,847 @@ +//! The join driver, offline (#529). +//! +//! Everything the join does that touches the network, the disk or a live +//! messaging account goes through [`JoinIo`]; [`Fake`] stands in for it, so a +//! whole launch runs on an offline TDK, a listener-less messaging service and a +//! bare config — and fails wherever a test says, which is what shows the saga +//! undoing exactly what it made. + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::Duration; + +use openvtc_core::config::account::PersonaRecord; +use openvtc_core::didcomm::DIDCommEvent; +use tokio::sync::{mpsc, oneshot}; + +use super::*; +use crate::state_handler::dispatch_util::{test_config, test_tdk}; + +const COMMUNITY: &str = "did:web:community.example"; +const MEDIATOR: &str = "did:web:mediator.example"; +const MINTED_DID: &str = "did:webvh:minted.example"; +const REUSED_DID: &str = "did:webvh:reused.example"; + +/// Where a fake launch stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fail { + Nowhere, + /// The community offers no messaging transport. + NoTransport, + Context, + Mint, + /// The answers from the face are refused. + Answers, + HiddenRefusal, + SubmitSend, + SubmitSigner, + /// The invitation stage never finishes — for a cancel to land in. + HangInInvite, + /// The submit waits for the test to release it — the request is on the + /// wire while it waits. + GatedSubmit, +} + +struct Fake { + fail: Fail, + /// Listeners the compensation removed. + removed: Mutex>, + saves: AtomicUsize, + /// A resolve that waits for the test to answer it. + resolve_gate: Mutex>>>, + /// Released by the test to let a `GatedSubmit` submit finish. + submit_gate: Mutex>>, +} + +impl Fake { + fn new(fail: Fail) -> Arc { + Arc::new(Self { + fail, + removed: Mutex::new(Vec::new()), + saves: AtomicUsize::new(0), + resolve_gate: Mutex::new(None), + submit_gate: Mutex::new(None), + }) + } + + fn removed(&self) -> Vec { + self.removed.lock().unwrap().clone() + } +} + +impl JoinIo for Fake { + fn resolve( + &self, + _resolver: DIDCacheClient, + _name: String, + ) -> IoFuture<'static, Result> { + let gate = self.resolve_gate.lock().unwrap().take(); + Box::pin(async move { + match gate { + Some(gate) => gate.await.unwrap_or_else(|_| Err("gate dropped".into())), + None => Ok(COMMUNITY.to_string()), + } + }) + } + + fn discover(&self, req: DiscoverReq) -> IoFuture<'static, Discovered> { + Box::pin(async move { + Discovered { + invitations: req.invitations.map(|_| Vec::new()), + manifest: req.manifest.then(|| { + Err(NotLearned::Unresolvable( + "it could not be resolved (offline)".into(), + )) + }), + document: None, + } + }) + } + + fn reach(&self, _req: ReachReq, _progress: Progress) -> IoFuture<'static, Reached> { + let fail = self.fail; + Box::pin(async move { + Reached { + display_name: None, + transports: PeerTransports { + tsp_mediator: None, + didcomm_mediator: (fail != Fail::NoTransport).then(|| MEDIATOR.to_string()), + }, + } + }) + } + + fn context(&self, _req: ContextReq) -> IoFuture<'static, Result> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::Context { + Err("the VTA refused".into()) + } else { + Ok(true) + } + }) + } + + fn mint(&self, _req: MintReq, _progress: Progress) -> IoFuture<'static, MintedParts> { + let fail = self.fail; + Box::pin(async move { + MintedParts { + failure: (fail == Fail::Mint) + .then(|| "Failed to create persona keys: the VTA refused".to_string()), + ..MintedParts::default() + } + }) + } + + fn invite(&self, req: InviteReq, _progress: Progress) -> IoFuture<'static, Invited> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::HangInInvite { + std::future::pending::<()>().await; + } + Invited { + listener_up: req.listener.as_ref().map(|_| true), + presentable: None, + } + }) + } + + fn present(&self, _req: PresentReq, _progress: Progress) -> IoFuture<'static, Presented> { + let fail = self.fail; + Box::pin(async move { + let failure = match fail { + Fail::Answers => Some("Could not put your face on this persona: refused".into()), + Fail::HiddenRefusal => Some(NO_ATTESTATION.to_string()), + _ => None, + }; + Presented { + linkage: None, + attributes: Vec::new(), + proof: None, + failure, + } + }) + } + + fn submit(&self, req: SubmitReq, _progress: Progress) -> IoFuture<'static, Submitted> { + let fail = self.fail; + let gate = self.submit_gate.lock().unwrap().take(); + Box::pin(async move { + if fail == Fail::GatedSubmit + && let Some(gate) = gate + { + let _ = gate.await; + } + let request = match req.prepared { + Err(e) => Err(e), + Ok(_) if fail == Fail::SubmitSend => Err(SubmitError::Send(OpenVTCError::Config( + "our mediator refused the frame".into(), + ))), + Ok(_) => Ok(uuid::Uuid::new_v4()), + }; + Submitted { + transport: MessagingTransport::DidComm, + online: true, + request, + } + }) + } + + fn persist_mint<'a>( + &'a self, + config: &'a mut Config, + _setup: &'a SetupState, + _tdk: &'a TDK, + _profile: &'a str, + ) -> IoFuture<'a, anyhow::Result> { + Box::pin(async move { Ok(seed_persona(config, MINTED_DID, "")) }) + } + + fn can_message(&self, _tdk: &TDK) -> bool { + true + } + + fn identity(&self, config: &Config, persona: PersonaId) -> Option<(String, String)> { + config + .account + .personas + .get(&persona) + .map(|p| (p.did.clone(), MEDIATOR.to_string())) + } + + fn listener_spec<'a>( + &'a self, + messaging: Option<&'a Messaging>, + _config: &'a Config, + _tdk: &'a TDK, + _persona: PersonaId, + did: &'a str, + ) -> IoFuture<'a, Option> { + Box::pin(async move { + messaging.map(|_| ListenerSpec { + id: openvtc_core::didcomm::persona_listener_id(did), + did: did.to_string(), + mediator_did: MEDIATOR.to_string(), + label: "applicant".into(), + secrets: Vec::new(), + collection: None, + }) + }) + } + + fn prepare_submit<'a>( + &'a self, + _config: &'a Config, + _tdk: &'a TDK, + _persona: PersonaId, + ) -> IoFuture<'a, Result, SubmitError>> { + let fail = self.fail; + Box::pin(async move { + if fail == Fail::SubmitSigner { + Err(SubmitError::Signer(OpenVTCError::Secret( + "the key could not be read".into(), + ))) + } else { + Ok(None) + } + }) + } + + fn remove_listener<'a>(&'a self, _messaging: &'a Messaging, id: &'a str) -> IoFuture<'a, ()> { + self.removed.lock().unwrap().push(id.to_string()); + Box::pin(async {}) + } + + fn save<'a>( + &'a self, + _save: &'a mut SaveScheduler, + _config: &'a Config, + ) -> IoFuture<'a, Result<(), OpenVTCError>> { + self.saves.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } +} + +fn seed_persona(config: &mut Config, did: &str, origin_context_id: &str) -> PersonaId { + let persona_id = PersonaId::new(); + config.account.personas.insert( + persona_id, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id, + did: did.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: origin_context_id.into(), + created_at: Utc::now(), + label: None, + }, + ); + persona_id +} + +/// The loop a join is driven from, played by the test. +struct Harness { + state: State, + config: Config, + tdk: TDK, + admin: VtaClient, + messaging: Messaging, + save: SaveScheduler, + dispatch_tx: UnboundedSender, + dispatch_rx: UnboundedReceiver, + in_flight: InFlight, + state_tx: watch::Sender>, + driver: JoinDriver, + fake: Arc, + _events: mpsc::UnboundedReceiver, +} + +impl Harness { + async fn new(fail: Fail) -> Self { + let fake = Fake::new(fail); + let (event_tx, events) = mpsc::unbounded_channel(); + let messaging = openvtc_core::didcomm::start_empty_service( + event_tx, + tokio_util::sync::CancellationToken::new(), + ); + let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel(); + let (state_tx, _) = watch::channel(Arc::new(State::default())); + let mut config = test_config(); + // A VTA account with a mediator, which a mint requires. + config.key_backend = openvtc_core::config::KeyBackend::Vta { + credential_bundle: secrecy::SecretString::new("bundle".into()), + credential_did: "did:key:admin".into(), + credential_private_key: secrecy::SecretString::new("key".into()), + vta_did: "did:web:vta.example".into(), + vta_url: String::new(), + mediator_did: Some(MEDIATOR.into()), + encryption_seed: secrecy::SecretBox::new(Box::new(vec![0u8; 32])), + }; + Self { + state: State::default(), + config, + tdk: test_tdk().await, + // Never dialled: every VTA call goes through the fake. + admin: VtaClient::new("http://127.0.0.1:1"), + messaging, + // Never flushed either: saves go through the fake too. + save: SaveScheduler::new("join-driver-test"), + dispatch_tx, + dispatch_rx, + in_flight: InFlight::default(), + state_tx, + driver: JoinDriver::with_io(true, fake.clone()), + fake, + _events: events, + } + } + + fn parts(&mut self) -> (&mut JoinDriver, JoinCtx<'_>) { + let Harness { + state, + config, + tdk, + admin, + messaging, + save, + dispatch_tx, + in_flight, + state_tx, + driver, + .. + } = self; + ( + driver, + JoinCtx { + state, + config, + tdk, + admin_vta: Some(admin), + messaging: Some(messaging), + profile: "join-driver-test", + save, + dispatch_tx, + in_flight, + state_tx, + }, + ) + } + + async fn enter(&mut self, entry: JoinEntry) -> JoinStep { + let (driver, mut ctx) = self.parts(); + driver.enter(&mut ctx, entry).await + } + + async fn act(&mut self, action: Action) -> JoinStep { + let (driver, mut ctx) = self.parts(); + driver.on_action(&mut ctx, action).await + } + + async fn cancel(&mut self) { + let (driver, mut ctx) = self.parts(); + driver.cancel(&mut ctx).await; + } + + fn job_in_flight(&self) -> bool { + self.driver + .session + .as_ref() + .is_some_and(|s| s.job.is_some()) + } + + /// Apply outcomes until no join job is in flight. + async fn run_jobs(&mut self) -> JoinStep { + let mut step = JoinStep::Continue; + while self.job_in_flight() { + let outcome = tokio::time::timeout(Duration::from_secs(5), self.dispatch_rx.recv()) + .await + .expect("a join job finishes") + .expect("the dispatch channel is open"); + match outcome { + DispatchOutcome::Join(outcome) => { + let (driver, mut ctx) = self.parts(); + step = driver.on_outcome(&mut ctx, *outcome).await; + } + DispatchOutcome::Progress(ProgressUpdate::Join { generation, line }) => { + self.driver.on_progress(&mut self.state, generation, line); + } + _ => {} + } + } + step + } + + /// Run the launch stages one at a time until `stop` says the launch is + /// parked where the test wants it. + async fn run_until(&mut self, stop: impl Fn(&Harness) -> bool) { + while self.job_in_flight() && !stop(self) { + let outcome = tokio::time::timeout(Duration::from_secs(5), self.dispatch_rx.recv()) + .await + .expect("a join job finishes") + .expect("the dispatch channel is open"); + if let DispatchOutcome::Join(outcome) = outcome { + let (driver, mut ctx) = self.parts(); + driver.on_outcome(&mut ctx, *outcome).await; + } + } + } + + /// Open the join and launch it as `pick`, with the registry question + /// answered — the last page before anything is sent. + async fn launch(&mut self, pick: IdentityPick) { + assert!(matches!( + self.enter(JoinEntry::Fresh).await, + JoinStep::Continue + )); + self.state.join.registry = Some(RegistryChoice { + publish: false, + community: COMMUNITY.to_string(), + community_name: None, + confirmed: false, + parked: Some((pick, COMMUNITY.to_string(), "acct/community".to_string())), + review: None, + directory: None, + }); + assert!(matches!( + self.act(Action::JoinRegistryConfirm).await, + JoinStep::Continue + )); + assert!( + self.job_in_flight(), + "the launch's first stage runs off the loop" + ); + assert!(self.state.join.processing, "input is locked while it runs"); + } + + fn last_error(&self) -> Option<&str> { + self.state.join.messages.iter().rev().find_map(|m| match m { + MessageType::Error(e) => Some(e.as_str()), + MessageType::Info(_) => None, + }) + } + + fn said(&self, needle: &str) -> bool { + self.state.join.messages.iter().any(|m| match m { + MessageType::Info(t) | MessageType::Error(t) => t.contains(needle), + }) + } + + fn persona(&self, did: &str) -> bool { + self.config.account.personas.values().any(|p| p.did == did) + } + + /// The launch is over and the join page is unlocked, with the domain free. + fn assert_settled(&self) { + assert!(!self.job_in_flight()); + assert!(self.driver.saga().is_none(), "the launch has ended"); + assert!(!self.state.join.processing, "input unlocks"); + assert!(!self.in_flight.is_busy(DispatchDomain::Join)); + assert!(self.driver.is_active(), "the join page stays up"); + } +} + +fn listener_of(did: &str) -> String { + openvtc_core::didcomm::persona_listener_id(did) +} + +// ---- The launch saga, failed at each stage ---- + +#[tokio::test] +async fn no_transport_fails_before_anything_is_made() { + let mut h = Harness::new(Fail::NoTransport).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.contains("advertises no messaging transport") + && e.contains("Nothing was created")) + ); + assert!(h.config.account.personas.is_empty()); + assert!(h.fake.removed().is_empty()); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_context_failure_stops_before_the_mint() { + let mut h = Harness::new(Fail::Context).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Could not prepare context acct/community: the VTA refused. Nothing was created.") + ); + assert!(h.said("Community reachable over")); + assert!(h.config.account.personas.is_empty()); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn a_mint_failure_leaves_no_persona() { + let mut h = Harness::new(Fail::Mint).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Failed to create persona keys: the VTA refused") + ); + assert!(h.said("Created context acct/community.")); + assert!(h.config.account.personas.is_empty()); + assert!(h.fake.removed().is_empty()); +} + +#[tokio::test] +async fn refused_answers_undo_the_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::Answers).await; + h.config.public.friendly_name = "before the join".into(); + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!( + h.last_error(), + Some("Could not put your face on this persona: refused") + ); + assert!(!h.persona(MINTED_DID), "the minted persona is rolled back"); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert_eq!(h.config.public.friendly_name, "before the join"); + assert_eq!( + h.fake.saves.load(Ordering::SeqCst), + 1, + "the rollback is saved at once" + ); +} + +#[tokio::test] +async fn a_hidden_refusal_undoes_the_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::HiddenRefusal).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert_eq!(h.last_error(), Some(NO_ATTESTATION)); + assert!(!h.persona(MINTED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); +} + +#[tokio::test] +async fn a_failed_send_undoes_a_minted_persona_and_its_listener() { + let mut h = Harness::new(Fail::SubmitSend).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Failed to submit join request:")) + ); + assert!(!h.persona(MINTED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert!(h.config.account.memberships().next().is_none()); +} + +/// The one deliberate change (#529): a failed send as a *reused* persona used +/// to delete that persona from the account. The saga never records a reused +/// persona as minted, so only the listener this launch opened is undone. +#[tokio::test] +async fn a_failed_send_never_removes_a_reused_persona() { + let mut h = Harness::new(Fail::SubmitSend).await; + let reused = seed_persona(&mut h.config, REUSED_DID, ""); + h.launch(IdentityPick::Reuse(reused)).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Failed to submit join request:")) + ); + assert!(h.said(&format!("Reusing persona {REUSED_DID}…"))); + assert!(h.persona(REUSED_DID), "a reused persona is never forgotten"); + assert_eq!(h.fake.removed(), vec![listener_of(REUSED_DID)]); + assert_eq!(h.fake.saves.load(Ordering::SeqCst), 0, "nothing to save"); +} + +/// An unreadable signing key undoes nothing — the persona and its listener +/// stay, as they always did on this path. +#[tokio::test] +async fn a_signer_failure_keeps_the_persona_and_its_listener() { + let mut h = Harness::new(Fail::SubmitSigner).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!( + h.last_error() + .is_some_and(|e| e.starts_with("Couldn't sign the application:")) + ); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); +} + +#[tokio::test] +async fn a_sent_join_is_recorded_and_nothing_is_undone() { + let mut h = Harness::new(Fail::Nowhere).await; + h.launch(IdentityPick::Mint).await; + h.run_jobs().await; + h.assert_settled(); + assert!(h.last_error().is_none()); + assert!(matches!(h.state.join.completed, Completion::CompletedOK)); + let record = h + .state + .join + .created_community + .clone() + .expect("the pending membership is on the page"); + assert_eq!(record.vtc_did, COMMUNITY); + assert!( + h.config + .account + .memberships() + .any(|m| m.vtc_did == COMMUNITY) + ); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); + assert_eq!( + h.fake.saves.load(Ordering::SeqCst), + 1, + "the membership is saved" + ); + // Enter on the finished page leaves with the joined session. + let step = h.act(Action::JoinCancel).await; + assert!(matches!(step, JoinStep::Returned(Some(_)))); + assert!(!h.driver.is_active()); +} + +/// A cancel lands mid-stage — the listener is claimed and connecting — and +/// undoes the launch from its saga, at once. +#[tokio::test] +async fn a_cancel_mid_stage_undoes_the_launch() { + let mut h = Harness::new(Fail::HangInInvite).await; + h.launch(IdentityPick::Mint).await; + h.run_until(|h| h.driver.saga().is_some_and(|s| s.listener().is_some())) + .await; + let saga = h.driver.saga().cloned().expect("the launch is in flight"); + assert!(saga.minted_persona().is_some()); + assert!(h.job_in_flight(), "parked in the invitation stage"); + + let step = h.act(Action::Exit).await; + assert!(matches!(step, JoinStep::Exit(Interrupted::UserInt))); + assert!(!h.driver.is_active()); + assert!(!h.in_flight.is_busy(DispatchDomain::Join)); + assert!(!h.persona(MINTED_DID), "the minted persona is rolled back"); + assert_eq!(h.fake.removed(), vec![listener_of(MINTED_DID)]); + assert!(h.said( + "Join cancelled. Any partially-minted persona was rolled back; a sub-context may remain at the VTA." + )); + assert!( + h.state + .main_page + .activity_log + .iter() + .any(|e| e.summary.contains("Join cancelled by user.")) + ); +} + +/// The loop stopping (an interrupt) cancels the same way, and a reused +/// persona survives it. +#[tokio::test] +async fn an_interrupt_mid_stage_keeps_a_reused_persona() { + let mut h = Harness::new(Fail::HangInInvite).await; + let reused = seed_persona(&mut h.config, REUSED_DID, ""); + h.launch(IdentityPick::Reuse(reused)).await; + h.run_until(|h| h.driver.saga().is_some_and(|s| s.listener().is_some())) + .await; + h.cancel().await; + assert!(!h.driver.is_active()); + assert!(h.persona(REUSED_DID)); + assert_eq!(h.fake.removed(), vec![listener_of(REUSED_DID)]); +} + +// ---- Resolve: off the loop, cancellable, keys queued behind it ---- + +#[tokio::test] +async fn keys_pressed_while_a_name_resolves_are_queued_and_replayed() { + let mut h = Harness::new(Fail::Nowhere).await; + let (answer, gate) = oneshot::channel(); + *h.fake.resolve_gate.lock().unwrap() = Some(gate); + h.enter(JoinEntry::Fresh).await; + h.act(Action::JoinSubmitVtc("example.com/@acme".into())) + .await; + assert!(h.job_in_flight(), "the name resolves off the loop"); + assert!(h.said("Resolving example.com/@acme…")); + + let before = h.state.join.new_persona_did_method; + h.act(Action::JoinNewPersonaMethodCycle).await; + assert_eq!( + h.state.join.new_persona_did_method, before, + "held back while the job runs" + ); + + answer.send(Ok(COMMUNITY.to_string())).unwrap(); + h.run_jobs().await; + // Resolved, then read: the fake community does not resolve. + assert_eq!(h.state.join.page, JoinPage::Vetting); + assert_eq!(h.state.join.pending_vtc.as_deref(), Some(COMMUNITY)); + + // Due now, ahead of anything new on the channel. + let (_tx, mut rx) = mpsc::unbounded_channel(); + let replay = h + .driver + .next_action(&mut rx) + .await + .expect("the held-back key is replayed"); + h.act(replay).await; + assert_eq!( + h.state.join.new_persona_did_method, + next_did_method(before), + "and acted on" + ); +} + +#[tokio::test] +async fn esc_while_a_name_resolves_aborts_it() { + let mut h = Harness::new(Fail::Nowhere).await; + let (answer, gate) = oneshot::channel::>(); + *h.fake.resolve_gate.lock().unwrap() = Some(gate); + h.enter(JoinEntry::Fresh).await; + h.act(Action::JoinSubmitVtc("example.com/@acme".into())) + .await; + assert!(h.in_flight.is_busy(DispatchDomain::Join)); + + let step = h.act(Action::JoinCancel).await; + assert!(matches!(step, JoinStep::Returned(None))); + assert!(matches!(h.state.active_page, ActivePage::Main)); + assert!(!h.driver.is_active()); + assert!(!h.in_flight.is_busy(DispatchDomain::Join)); + // The resolve was dropped where it waited. + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(answer.is_closed(), "the aborted resolve no longer listens"); + assert!(h.dispatch_rx.try_recv().is_err(), "and reports nothing"); +} + +#[tokio::test] +async fn a_stale_outcome_is_ignored() { + let mut h = Harness::new(Fail::Nowhere).await; + h.enter(JoinEntry::Fresh).await; + let (driver, mut ctx) = h.parts(); + let step = driver + .on_outcome( + &mut ctx, + JoinOutcome { + generation: 99, + result: JobResult::Resolved(Ok(COMMUNITY.into())), + }, + ) + .await; + assert!(matches!(step, JoinStep::Continue)); + assert_eq!(h.state.join.page, JoinPage::EnterDid); + assert!(!h.job_in_flight()); +} + +// ---- The submit: past the point of no return ---- + +/// The job in flight is the submit. +fn submitting(h: &Harness) -> bool { + h.driver.submitting_to().is_some() +} + +/// F10 while the request is on the wire waits for it: the request is +/// recorded and the persona kept, then the join exits. +#[tokio::test] +async fn an_exit_during_the_submit_waits_for_it_and_records_the_join() { + let mut h = Harness::new(Fail::GatedSubmit).await; + let (release, gate) = oneshot::channel(); + *h.fake.submit_gate.lock().unwrap() = Some(gate); + h.launch(IdentityPick::Mint).await; + h.run_until(submitting).await; + assert!(submitting(&h), "parked in the submit"); + assert_eq!(h.driver.submitting_to(), Some(COMMUNITY)); + + let step = h.act(Action::Exit).await; + assert!(matches!(step, JoinStep::Continue), "the exit waits"); + assert!(h.said("stopping once it has been sent")); + assert!(h.persona(MINTED_DID), "nothing is undone while it sends"); + + release.send(()).unwrap(); + h.run_jobs().await; + assert!( + h.config + .account + .memberships() + .any(|m| m.vtc_did == COMMUNITY), + "the sent request is recorded" + ); + assert!( + h.persona(MINTED_DID), + "the persona the community holds is kept" + ); + assert!(h.fake.removed().is_empty(), "its listener stays"); + // The loop takes the queued exit next (`next_action`), as it does any key + // held back behind a job. + let queued = h.driver.leftover.pop_front(); + assert!(matches!(queued, Some(Action::Exit)), "the exit was kept"); + let step = h.act(Action::Exit).await; + assert!( + matches!(step, JoinStep::Exit(Interrupted::UserInt)), + "then the exit is taken" + ); + assert!(h.persona(MINTED_DID), "and nothing is undone by it"); +} + +/// The loop stopping mid-submit cannot wait, so it keeps the persona rather +/// than forget a DID the community may now hold. +#[tokio::test] +async fn an_interrupt_during_the_submit_keeps_the_persona() { + let mut h = Harness::new(Fail::GatedSubmit).await; + let (_release, gate) = oneshot::channel::<()>(); + *h.fake.submit_gate.lock().unwrap() = Some(gate); + h.launch(IdentityPick::Mint).await; + h.run_until(submitting).await; + h.cancel().await; + assert!(!h.driver.is_active()); + assert!(h.persona(MINTED_DID)); + assert!(h.fake.removed().is_empty()); + assert!(h.said("It may have been sent; the persona was kept.")); +} diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 621ab248..a39a5dd4 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -54,7 +54,8 @@ impl RawCredential { /// Whether the credential carries a post-quantum proof (ML-DSA or /// SLH-DSA, alone or in a proof set). Serializes a VRC, so call it when a - /// list is built, not per frame. + /// list is built, not per frame — and the sync asks through a memo keyed by + /// credential id, so a VRC is serialised for it once, not on every sync. #[must_use] pub fn signed_post_quantum(&self) -> bool { match self { @@ -4123,7 +4124,22 @@ pub struct SettingsState { /// git commit signing. Surfaced on the Help/Status panel so the operator /// can copy the SSH public key into their git host's signing-key /// settings. + /// + /// Detecting it reads files, so it is not done in `sync_from_config`: it is + /// refreshed off the loop (`settings_actions::refresh_did_git_sign`) and kept + /// here between refreshes. pub did_git_sign: Option, + /// The persona DID [`did_git_sign`](Self::did_git_sign) was detected for; + /// `None` before the first detection. When it differs from + /// [`persona_did`](Self::persona_did) — a persona switch — the sync hides the + /// old persona's info and a refresh is due. + pub did_git_sign_for: Option, + /// A refresh was asked for — an install changed, or a refresh was wanted + /// while one was already running — and has not started yet. + pub did_git_sign_wanted: bool, + /// Whether Help / Status or Settings was on screen at the last refresh + /// check, so opening either refreshes once rather than on every check. + pub did_git_sign_viewing: bool, } /// Snapshot of the local did-git-sign install for this persona. diff --git a/openvtc/src/state_handler/main_page/elections.rs b/openvtc/src/state_handler/main_page/elections.rs index 62b4ac57..d49f26e1 100644 --- a/openvtc/src/state_handler/main_page/elections.rs +++ b/openvtc/src/state_handler/main_page/elections.rs @@ -75,15 +75,9 @@ pub enum Purpose { Issue { election: String }, } -/// A request to the community awaiting its answer. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Pending { - pub thid: String, - pub sent_at: Instant, - pub purpose: Purpose, - /// The community it went to: the panel's own, or the one holding a notified election. - pub to: String, -} +/// A request to the community awaiting its answer: to the panel's own community, or the +/// one holding a notified election. +pub type Pending = crate::state_handler::community_request::Pending; /// An election read presenting a membership is offered, waiting for the member's choice. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index a1f70fee..dba325e2 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -96,6 +96,68 @@ pub struct MainPageState { /// trust-anchor override (see `crate::env_overrides`). Rendered at the front /// of the bottom bar, so the run cannot be mistaken for a normal one. pub dev_override: Option, + + /// Whether each VRC row is post-quantum signed, remembered across syncs so a + /// credential is serialised for the check once rather than on every sync. + post_quantum: PostQuantumMemo, +} + +/// The post-quantum flag of every VRC row, by credential id, kept from one +/// [`MainPageState::sync_from_config`] to the next. +/// +/// Answering it for a VRC means serialising the credential, and the sync runs on +/// every config mutation — once per inbound message in a redelivery burst. A VRC's +/// id is its proof value, so the id fixes the proofs the flag is read from: an id +/// seen before has the same answer. Bounded by the credentials on display: each +/// sync keeps only the ids it showed ([`Self::finish_sync`]). +/// +/// JSON-held credentials (membership, role, vetting) are not remembered: their +/// check is a walk of a value already in memory, with nothing to serialise. +#[derive(Debug, Default)] +struct PostQuantumMemo { + /// What the previous sync answered. + previous: std::collections::HashMap, + /// What this sync has answered so far. + current: std::collections::HashMap, +} + +/// A cache, so a clone starts empty: `MainPageState` is cloned to the UI on every +/// change, and the UI's copy never syncs. +impl Clone for PostQuantumMemo { + fn clone(&self) -> Self { + Self::default() + } +} + +impl PostQuantumMemo { + /// Whether the credential `id` carries a post-quantum proof. + fn post_quantum(&mut self, id: &str, raw: &content::RawCredential) -> bool { + self.post_quantum_with(id, raw, content::RawCredential::signed_post_quantum) + } + + /// [`Self::post_quantum`], answering a miss with `check`. + fn post_quantum_with( + &mut self, + id: &str, + raw: &content::RawCredential, + check: impl FnOnce(&content::RawCredential) -> bool, + ) -> bool { + if matches!(raw, content::RawCredential::Value(_)) { + return check(raw); + } + if let Some(&known) = self.current.get(id) { + return known; + } + let answer = self.previous.remove(id).unwrap_or_else(|| check(raw)); + self.current.insert(id.to_string(), answer); + answer + } + + /// End a sync: what it answered is what the next one may reuse, and ids it + /// did not show are forgotten. + fn finish_sync(&mut self) { + self.previous = std::mem::take(&mut self.current); + } } impl MainPageState { @@ -478,8 +540,11 @@ impl MainPageState { .collect(); // Sync credentials (scoped to the working community's persona) + let memo = &mut self.post_quantum; + let persona_labels = persona_labels_by_did(config); self.content_panel.credentials.received = finish_credential_rows( - config, + &persona_labels, + memo, collect_vrcs( &config.private.vrcs_received, config, @@ -489,7 +554,8 @@ impl MainPageState { ) .into(); self.content_panel.credentials.issued = finish_credential_rows( - config, + &persona_labels, + memo, collect_vrcs( &config.private.vrcs_issued, config, @@ -499,9 +565,11 @@ impl MainPageState { ) .into(); self.content_panel.credentials.membership = - finish_credential_rows(config, collect_membership_creds(config)).into(); + finish_credential_rows(&persona_labels, memo, collect_membership_creds(config)).into(); self.content_panel.credentials.vetting = - finish_credential_rows(config, collect_vetting_statements(config)).into(); + finish_credential_rows(&persona_labels, memo, collect_vetting_statements(config)) + .into(); + memo.finish_sync(); self.content_panel.credentials.retired_vrcs = config.private.vrcs_received.retired() + config.private.vrcs_issued.retired() + config @@ -517,7 +585,11 @@ impl MainPageState { self.content_panel.settings.persona_agent_name = config .agent_name_for(config.persona_did()) .map(str::to_owned); - self.content_panel.settings.did_git_sign = detect_did_git_sign_info(config.persona_did()); + // did-git-sign is detected off the loop (it reads files); the sync only + // makes sure info detected for another persona is never shown for this one. + if self.content_panel.settings.did_git_sign_for.as_deref() != Some(config.persona_did()) { + self.content_panel.settings.did_git_sign = None; + } self.content_panel.settings.context_id = config.account.top_context_id.clone(); // Sync VTA info self.content_panel.vta.persona_did = config.persona_did().to_string(); @@ -634,6 +706,12 @@ impl MainPageState { // DID is the holder's identity, not a property of the agent that // happens to host its keys, and the pane that manages it is the pane // that should list it. + // Memberships per persona, counted in one pass over the memberships. + let mut bound_communities: std::collections::HashMap = + std::collections::HashMap::new(); + for c in config.account.memberships() { + *bound_communities.entry(c.persona_ref).or_default() += 1; + } let mut personas: Vec = config .account .personas @@ -645,11 +723,7 @@ impl MainPageState { did: p.did.clone(), did_locator: p.did_locator.clone(), label: p.label.clone().unwrap_or_default(), - bound_communities: config - .account - .memberships() - .filter(|c| c.persona_ref == p.persona_id) - .count(), + bound_communities: bound_communities.get(&p.persona_id).copied().unwrap_or(0), is_active: p.did.as_str() == persona_did, }) .collect(); @@ -1066,22 +1140,38 @@ fn collect_vrcs( /// What every credential row needs that its collector does not compute: the /// persona it was issued to, by name, and whether it is post-quantum signed. /// One pass here rather than in each collector, so no list can miss it. -fn finish_credential_rows(config: &Config, mut rows: Vec) -> Vec { +fn finish_credential_rows( + persona_labels: &std::collections::HashMap<&str, Option<&str>>, + post_quantum: &mut PostQuantumMemo, + mut rows: Vec, +) -> Vec { for row in &mut rows { - row.post_quantum = row.raw_json.signed_post_quantum(); + row.post_quantum = post_quantum.post_quantum(&row.vrc_id, &row.raw_json); if row.subject_is_self { - row.subject_label = config - .account - .personas - .values() - .find(|p| p.did == row.subject) - .and_then(|p| p.label.as_deref()) + row.subject_label = persona_labels + .get(row.subject.as_str()) + .copied() + .flatten() .map(|l| sanitize_display(l, 64)); } } rows } +/// Each persona DID's own label (`None` when it has none), built once per sync +/// so a credential row finds its subject's label by lookup rather than a scan +/// of the personas. Where two personas share a DID the first in iteration order +/// wins, as the scan's `find` did. +fn persona_labels_by_did(config: &Config) -> std::collections::HashMap<&str, Option<&str>> { + let mut labels = std::collections::HashMap::with_capacity(config.account.personas.len()); + for p in config.account.personas.values() { + labels + .entry(p.did.as_str()) + .or_insert_with(|| p.label.as_deref()); + } + labels +} + fn collect_membership_creds(config: &Config) -> Vec { let mut result = Vec::new(); for c in config.account.memberships() { @@ -1625,7 +1715,8 @@ pub fn sanitize_display(input: &str, max_len: usize) -> String { /// `None` if did-git-sign is not configured for this persona, or if the /// state on disk is malformed. /// -/// Reads files synchronously and is cheap (single small file open + read). +/// Reads files synchronously, so it runs on a blocking thread +/// (`settings_actions::refresh_did_git_sign`), never in `sync_from_config`. /// Sourced from disk rather than re-derived from runtime key material so /// the help screen reflects what `did-git-sign` itself would actually use /// — i.e. if the config was hand-edited, the help view stays consistent @@ -3221,6 +3312,108 @@ mod tests { assert_eq!(lazy, eager, "lazy VRC JSON must match the old eager output"); } + /// The per-sync lookup answers what the per-row scan did: a persona's label + /// by its DID, `None` for a persona without one or a DID that is not ours. + #[test] + fn persona_labels_by_did_matches_the_scan() { + use openvtc_core::config::account::PersonaRecord; + let mut config = crate::state_handler::dispatch_util::test_config(); + for (did, label) in [ + ("did:webvh:scid:example.com:a", Some("Alice")), + ("did:webvh:scid:example.com:b", None), + ] { + let id = PersonaId::new(); + config.account.personas.insert( + id, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id: id, + did: did.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: "ctx".into(), + created_at: chrono::Utc::now(), + label: label.map(Into::into), + }, + ); + } + let labels = persona_labels_by_did(&config); + for did in [ + "did:webvh:scid:example.com:a", + "did:webvh:scid:example.com:b", + "did:webvh:scid:example.com:other", + ] { + let scan = config + .account + .personas + .values() + .find(|p| p.did == did) + .and_then(|p| p.label.as_deref()); + assert_eq!(labels.get(did).copied().flatten(), scan, "{did}"); + } + assert_eq!( + labels + .get("did:webvh:scid:example.com:a") + .copied() + .flatten(), + Some("Alice") + ); + } + + /// A VRC is checked for post-quantum proofs once, then answered from the + /// memo on later syncs; an id a sync no longer shows is forgotten; JSON-held + /// credentials are always checked (there is nothing to serialise). + #[test] + fn post_quantum_memo_checks_a_vrc_once_across_syncs() { + use chrono::{TimeZone, Utc}; + use dtg_credentials::DTGCredential; + use std::cell::Cell; + + let vrc = content::RawCredential::Vrc(Arc::new(DTGCredential::new_vrc( + "did:test:issuer".to_string(), + dtg_credentials::IssuerScope::Pairwise, + "did:test:subject".to_string(), + Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(), + None, + ))); + let value = content::RawCredential::Value(Arc::new(serde_json::json!({}))); + let checks = Cell::new(0u32); + let check = |answer: bool| { + let checks = &checks; + move |_: &content::RawCredential| { + checks.set(checks.get() + 1); + answer + } + }; + + let mut memo = PostQuantumMemo::default(); + // First sync: the check runs once, even when the row appears twice. + assert!(memo.post_quantum_with("vrc-1", &vrc, check(true))); + assert!(memo.post_quantum_with("vrc-1", &vrc, check(false))); + memo.finish_sync(); + assert_eq!(checks.get(), 1); + + // Second sync: answered from the memo, not re-checked. + assert!(memo.post_quantum_with("vrc-1", &vrc, check(false))); + memo.finish_sync(); + assert_eq!(checks.get(), 1); + + // A sync without it forgets it, so the memo stays bounded by what is shown. + memo.finish_sync(); + assert!(!memo.post_quantum_with("vrc-1", &vrc, check(false))); + assert_eq!(checks.get(), 2); + + // JSON-held credentials are always asked. + memo.post_quantum_with("vc-1", &value, check(true)); + memo.post_quantum_with("vc-1", &value, check(true)); + assert_eq!(checks.get(), 4); + + // The UI's clone of the state does not carry the cache. + assert!(memo.clone().previous.is_empty() && memo.clone().current.is_empty()); + } + /// The lazy `RawCredential::Value` path (membership/role credentials) must /// pretty-print byte-identically to the previous eager /// `serde_json::to_string_pretty(vc)`. diff --git a/openvtc/src/state_handler/main_page/rooms.rs b/openvtc/src/state_handler/main_page/rooms.rs index 7f9ff05e..35d1c890 100644 --- a/openvtc/src/state_handler/main_page/rooms.rs +++ b/openvtc/src/state_handler/main_page/rooms.rs @@ -6,8 +6,6 @@ //! surfaces too — the member portal, `pnm` — and a record kept here would be the one place //! they disagree ([`openvtc_core::community_rooms`]). -use std::time::Instant; - use openvtc_core::community_rooms::{Offer, Renewal, RoomMembership, Standing}; use openvtc_core::config::account::PersonaId; @@ -36,12 +34,7 @@ pub enum Purpose { } /// A request to the community awaiting its answer. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Pending { - pub thid: String, - pub sent_at: Instant, - pub purpose: Purpose, -} +pub type Pending = crate::state_handler::community_request::Pending; /// One room, as every surface shows it. #[derive(Clone, Debug)] diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 30da77a8..5171f5fb 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1,4 +1,6 @@ use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::{ Interrupted, Terminator, state_handler::{ @@ -270,6 +272,7 @@ mod background_dispatch; mod capability_actions; mod community_actions; mod community_context_actions; +pub(crate) mod community_request; mod create_persona; mod credential_actions; mod elections_actions; @@ -278,6 +281,7 @@ mod persona_binding_refresh; pub(crate) mod repos_actions; pub(crate) mod repos_workspace; mod rooms_actions; +mod runtime; mod signing_health; /// The DIDComm transport module, which now lives in `openvtc-core`. /// @@ -340,7 +344,7 @@ pub enum StartingMode { } pub struct StateHandler { - state_tx: tokio::sync::watch::Sender, + state_tx: tokio::sync::watch::Sender>, profile: String, starting_mode: StartingMode, /// Invitation credential (VIC) supplied at launch via `--invitation`, seeded @@ -460,8 +464,9 @@ impl StateHandler { pub fn new( profile: &str, starting_mode: StartingMode, - ) -> (Self, tokio::sync::watch::Receiver) { - let (state_tx, state_rx) = tokio::sync::watch::channel(State::default()); + ) -> (Self, tokio::sync::watch::Receiver>) { + let (state_tx, state_rx) = + tokio::sync::watch::channel(std::sync::Arc::new(State::default())); ( StateHandler { @@ -532,7 +537,7 @@ impl StateHandler { state.main_page.sync_from_config(&config); state.connection.status = state::MediatorStatus::Initializing("Loading credentials...".into()); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // The setup wizard saved the config but the TDK secrets // resolver is empty. Load persona key secrets so the @@ -583,7 +588,7 @@ impl StateHandler { community: String::new(), }; state.connection.status = state::MediatorStatus::Initializing("Starting...".into()); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // Spawn TDK init + config load as a background task with // hierarchical progress reporting: each event is (major, sub). @@ -692,13 +697,13 @@ impl StateHandler { state::MediatorStatus::Initializing( format!("{major} — {sub}"), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); } // Token-touch notifications arrive through the dedicated channel // so that state is mutated only here, inside the StateHandler loop. Some(pending) = token_touch_rx.recv() => { state.token_touch_pending = pending; - self.state_tx.send(state.clone())?; + self.state_tx.publish(&state)?; } result = &mut load_handle => { match result { @@ -714,7 +719,7 @@ impl StateHandler { state.connection.status = state::MediatorStatus::Failed( state::ConnectionFailure::of(&e), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // No config loaded here — join is unavailable. return self .run_degraded_loop_terminal( @@ -740,7 +745,7 @@ impl StateHandler { openvtc_core::errors::ErrorClass::Local, ), ); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); return self .run_degraded_loop_terminal( &mut action_rx, @@ -857,7 +862,7 @@ impl StateHandler { // single frame. Holds the session through a watch rather than a clone, // so the heartbeat loop outlives any one borrow of the admin session — // and any one session, when a stalled one is rebuilt. - let (presence_tx, mut presence_rx) = + let (presence_tx, presence_rx) = mpsc::unbounded_channel::(); // When the loop rebuilds a stalled session it publishes the new client // here, and the presence task's next call uses it. @@ -1009,7 +1014,7 @@ impl StateHandler { // overflow drop destroys a membership credential or join verdict rather // than deferring it (#221, fixed in #224). Full rationale on // `didcomm::DIDCOMM_EVENT_CHANNEL_CAPACITY`. - let (didcomm_event_tx, mut didcomm_event_rx) = mpsc::unbounded_channel(); + let (didcomm_event_tx, didcomm_event_rx) = mpsc::unbounded_channel(); let shutdown_token = tokio_util::sync::CancellationToken::new(); let didcomm_service = didcomm::start_empty_service(didcomm_event_tx.clone(), shutdown_token.clone()); @@ -1047,9 +1052,9 @@ impl StateHandler { // context back as `DegradedOutcome::Joined` instead of looping, so we // fall through to the messaging setup below — no process restart needed // to receive the approval credential. - let (tdk, mut config, mut admin_vta) = if config.active_identity().is_none() { + let (tdk, config, admin_vta) = if config.active_identity().is_none() { state.connection.status = state::MediatorStatus::NoActiveCommunity; - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(&state); // No persona yet (State A). Hand the always-on admin session to the // degraded loop so the Communities `j` → join flow (R-A-5 Stage 4) // can reuse it; the loop closes it on exit (or hands it back on a @@ -1087,1946 +1092,28 @@ impl StateHandler { (tdk, config, admin_vta) }; - // Point the runtime active identity at the default working community and - // refilter the community-scoped panels before the first paint (D10 / - // R-C-6) — otherwise a multi-persona account renders empty panels until - // the first event re-syncs (the per-path syncs above run with no - // selection set yet). - state.reconcile_selected_community_among( - &config.account, - Some(&config.identities.keys().copied().collect()), - ); - let initial_active = state - .selected_community - .as_ref() - .map(|(_, persona)| *persona); - config.set_active_persona(initial_active); - state.main_page.sync_from_config(&config); - - // Send initial state immediately so the UI renders without blocking. - // Connecting only when there is a community session to connect: with no - // live membership there is none, and "Connecting..." would sit on the - // screen until the seed below corrected it — or read as a hang. - state.connection.status = if config.account.memberships().any(|m| m.is_live()) { - state::MediatorStatus::Connecting - } else { - state::MediatorStatus::NoActiveCommunity - }; - let _ = self.state_tx.send(state.clone()); - - // Install this account's listeners on the runtime started above. Skips - // any that already exist, so a State-A join's own persona listener is - // bound to rather than duplicated (one websocket per DID). - // - // The specs are built here because that needs `Config`, which is not - // `Clone` and cannot cross into a task. Bringing the listeners *up* is - // the expensive half — a mediator auth handshake and a websocket connect - // each, in series — and that half runs off this thread. This is the only - // thread that services UI actions, and the loading screen has already - // been told to offer "Press Enter to continue": awaiting the connects - // here meant the keypress sat unread in the action channel until the last - // socket was up, and the main page then arrived in one late jump. - // Outbound TSP sends (`tsp::send_trust_task`) run on `tdk.atm`; inbound - // frames are unsealed by listener ATMs. Both must record relationships in - // the one durable store, or the invite we send is `Pending` where no - // listener looks and the peer's accept and replies are dropped (VTI-61). - let tdk = match didcomm_service.tsp_store().adopt_into(tdk.clone()).await { - Ok(adopted) => adopted, - Err(e) => { - warn!(error = %e, "could not share the TSP relationship store with the sending ATM"); - tdk - } - }; - - let listener_specs = didcomm::build_listener_configs(&config, &tdk).await; - let _listener_install = - didcomm::spawn_install_listeners(didcomm_service.clone(), listener_specs); - - // Process-lifetime LRU of inbound message IDs. Backstop for replay - // and mediator-pickup duplicates beyond what the TDK already filters. - let mut seen_messages = openvtc_core::messaging::SeenMessages::new(); - - // Forward lifecycle events (connect/disconnect/restart) to the activity log - let (lifecycle_log_tx, mut lifecycle_log_rx) = - mpsc::unbounded_channel::(); - let _lifecycle_handle = didcomm::spawn_lifecycle_logger(&didcomm_service, lifecycle_log_tx); - - // Phase 2 (community/persona DIDComm connection) runs ASYNCHRONOUSLY: we - // do NOT block the UI waiting for the listener. The main page is already - // showing (Connecting); the persona connection proceeds in the - // background and the runtime loop below flips the status to Connected the - // moment a `ListenerEvent::Connected` arrives — and back to Connecting on - // a disconnect. Subscribe to typed lifecycle events for that. - let mut listener_events = didcomm_service.subscribe(); - - // Supervised multi-session manager (D11/D15): one persona-session per - // active community (a reused persona shares one), tracking per-session - // connection status over the listeners `start_service` already launched. - // Messaging runs through it from here on; at N=1 it holds one session and - // behaves identically to the previous single global flag. - let mut session_manager = session_manager::SessionManager::default(); - { - use session_manager::RegisterOutcome; - // NOTE: a mid-session leave/reject/expire does NOT yet deregister its - // session (that is T6/T7), so until then `any_connected()` can read - // live for a persona whose communities have all gone inactive. Startup - // registers exactly the communities that require a live session at load - // time (`IdentityRegistry::sessions`). - let registry = openvtc_core::identity::IdentityRegistry::new(&config.account); - let mut at_capacity = 0usize; - for (persona_id, vtcs) in registry.sessions() { - let Some(ctx) = config.identities.get(&persona_id) else { - // A live community whose persona didn't resolve to an identity - // has no listener either — surfaced here for parity, not silent. - debug!(%persona_id, "live-community persona missing from resolved identities — not tracked"); - continue; - }; - let lid = didcomm::persona_listener_id(&ctx.did); - for vtc in vtcs { - if matches!( - session_manager.register(persona_id, &lid, vtc.clone()), - RegisterOutcome::AtCapacity - ) { - at_capacity += 1; - warn!(%persona_id, vtc = %vtc, "session manager at capacity — community not tracked"); - } - } - } - // No silent caps (D15): tell the user if any community exceeded the bound. - if at_capacity > 0 { - state.main_page.log(format!( - "Warning: {at_capacity} communit{} exceeded the session limit ({}) and are not actively connected.", - if at_capacity == 1 { "y" } else { "ies" }, - session_manager.max_sessions(), - )); - } - } - - // Seed the indicator from the messaging layer's *live* state rather than - // assuming nothing is up yet. `ListenerStatus` is edge-triggered, and a - // State-A join has already brought its persona listener up and connected - // by the time this loop subscribes — the install step above binds to that - // socket instead of reinstalling it, so no second `Connected` edge is - // coming. Without this seed the session sits `Connecting` for the life of - // the process over a perfectly live socket, which is the "Inbox: - // Connecting…" that only a restart appeared to fix. - // - // Applied unconditionally rather than only when the reconcile changed - // something: an account with no community session (an applicant whose - // join is still being vetted) has nothing to change, and must still - // land on `NoActiveCommunity` rather than the `Connecting` shown while - // the page came up — no session will ever send the edge that moves it. - reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); - apply_session_aggregate(&session_manager, &mut state); - match state.connection.status { - state::MediatorStatus::Connected => state.main_page.log("Connected to the mediator."), - state::MediatorStatus::NoActiveCommunity => state - .main_page - .log("No community membership yet — no community session to connect."), - _ => state.main_page.log("Connecting to the mediator…"), - } - let _ = self.state_tx.send(state.clone()); - - // Track when a manual trust-ping was sent (for activity log latency display). - let mut ping_sent_at: Option = None; - - // Background-dispatch plumbing (R13). Network-bound actions whose await - // would otherwise park the whole select loop are spawned as background - // tasks; they do I/O only and send their result back as a - // `DispatchOutcome` on this channel. The select arm below applies the - // outcome on the loop thread (the single mutator), keeping the loop live - // during the wait. `in_flight` rejects a second action on a busy domain. - let (dispatch_tx, mut dispatch_rx) = - mpsc::unbounded_channel::(); - let mut in_flight = background_dispatch::InFlight::default(); - // Hands a witness's answers from the inbound arm to the witnessing waiting on them - // (`witness_actions`); the job verifies each one before reading it. - let witness_router = openvtc_core::witness::ReplyRouter::default(); - // Membership contexts already checked at the VTA this run. A failure is - // logged and retried on the next launch rather than every tick. - let mut contexts_checked: std::collections::HashSet = - std::collections::HashSet::new(); - // Whether this run has read the membership contexts' device grants for - // the communities panel. Once a run; the device-access view re-reads on - // demand. - let mut grants_swept = false; - - // Coalesced + offloaded config persistence (R11). Mutation sites mark the - // config dirty on the loop thread instead of saving inline; the - // `deadline` arm below debounces a burst into a single `spawn_blocking` - // save, with at most one in flight at a time. Durability-critical points - // (Exit, passphrase/protection change, export) force-flush synchronously. - let mut save = save_coalesce::SaveScheduler::new(self.profile.clone()); - // Channel carrying a completed background save's success flag, so the loop - // can clear the in-flight flag and re-arm if the config was dirtied again - // while the save ran. Save failures are surfaced as a status/log here, - // matching the pre-R11 inline `save_config` failure handling. - let (save_done_tx, mut save_done_rx) = mpsc::unbounded_channel::>(); - - // Account-state sync with the VTA (`openvtc_core::vta_sync`). The load - // already ran one; report it, and save whatever it changed. From here - // a cycle runs after each save and on a timer, off the loop: the plan - // is taken here, the network half is spawned, and its result is merged - // back here — the loop stays the only thing that mutates `config`. - if let Some(report) = config.private.vta_sync.load_report.take() { - for line in report.log_lines() { - state.main_page.log(line); - } - if report.touched() { - save.mark_dirty(); - } - } - let mut vta_sync = vta_sync_driver::SyncDriver::new(tokio::time::Instant::now()); - let mut vta_sync_tick = tokio::time::interval(vta_sync_driver::TICK); - vta_sync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let (vta_sync_tx, mut vta_sync_rx) = - mpsc::unbounded_channel::(); - - // 7-day Pending timeout sweep (R-B-7 / D16). `interval`'s first tick fires - // immediately, so a Pending that aged out while the app was closed expires - // on launch; thereafter it sweeps hourly. - let mut pending_expiry_tick = tokio::time::interval(std::time::Duration::from_secs(3600)); - // Reply-window sweep for the capabilities view (cheap no-op when the - // view is closed or idle). - let mut capabilities_sweep = tokio::time::interval(std::time::Duration::from_secs(5)); - // Backstop for the edge-triggered listener-status stream. A transition - // that reaches nobody — the broadcast receiver lagging, a listener - // adopted after its connect — would otherwise latch a session's status - // (and the indicator) wrong until the next real edge, which for a healthy - // socket may never come. Cheap: a read of the transports' live state, no - // I/O and no allocation beyond the listener ids. - let mut session_reconcile_tick = tokio::time::interval(std::time::Duration::from_secs(15)); - // Agent-name refresh sweep. The first tick fires immediately so names - // resolve shortly after launch; thereafter it picks up newly-added DIDs - // (a fresh relationship/contact) and re-verifies stale entries every few - // minutes. Cheap when there is nothing to do — it only spawns a job when - // `agent_name_refresh_targets` finds uncached/stale DIDs, and the resolver - // caches keep repeat sweeps quiet. - let mut agent_name_tick = tokio::time::interval(std::time::Duration::from_secs(300)); - // Reconcile Pending joins with their communities. The first tick fires - // immediately, which is the point: a join still Pending at launch is the - // one whose answer may have been lost, and asking is how we find out - // rather than waiting to be told again. Per-record backoff lives in the - // pacer, so a minute-by-minute tick does not mean a minute-by-minute - // poll; ticks over an account with no Pending join do no work at all. - let mut join_status_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut join_status_pacer = openvtc_core::join::status_poll::PollPacer::default(); - - // Ask each Active community, once per session, for its declared - // `relationshipIdentifierDefault` (issue #241). The pacer asks each - // community once, so this tick over communities already asked does no - // work; a bare account (no Active membership) does none at all. - let mut community_profile_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut community_profile_pacer = community_profile_poll::ProfilePacer::default(); - - // The vetter side of hidden vetting: re-read each community we vet for, - // enrol, and draw the token drip — on the communities' tick windows and - // at least hourly, never on demand (`hidden_vetting_poll`). The interval - // only checks the pacer; the first check is at launch, which is when a - // client that was off catches up the ticks it missed. - let mut hidden_vetting_tick = tokio::time::interval(std::time::Duration::from_secs(30)); - hidden_vetting_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let mut hidden_vetting_pacer = hidden_vetting_poll::HiddenVettingPacer::default(); - - // Network-bound checks on inbound messages (a DID resolve, a status - // list fetch) run off this loop, one at a time, so a slow community - // never freezes the UI. A message waiting on its check is not applied - // at all; it comes back from `verifier` with the result and is - // dispatched from the top. The queue is bounded, with a lane for the - // communities we belong to and a cap per sender (`verify_queue`). - let mut verifier = verify_queue::Verifier::new(tdk.clone()); - // Messages from communities that were still queued at the last exit. - // The mediator deleted them on delivery, so their kept copies are the - // only ones. They are queued again as room allows; the rest wait (kept) - // until earlier checks finish, and none is dropped for want of room. - let mut restore_backlog: std::collections::VecDeque<_> = - config.private.deferred_inbound.iter().cloned().collect(); - if restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) - .await - { - save.mark_dirty(); - } - - // The join flow to enter at the end of this iteration, and a join - // waiting on a community's requirements (see `join_flow::AwaitingRequirements`). - let mut join_entry: Option = None; - let mut awaiting_requirements: Option = None; - // A join parked on a hidden-vetting challenge (see - // `join_flow::AwaitingChallenge`) — the same hand-off, for the question - // a launch asks rather than the one the vetting page does. - let mut awaiting_challenge: Option = None; - // A join parked on a wallet session (see `join_flow::AwaitingWallet`). - let mut awaiting_wallet: Option = None; - let result = loop { - tokio::select! { - Some(action) = action_rx.recv() => match action { - Action::Exit => { - if let Err(e) = terminator.terminate(Interrupted::UserInt) { - debug!("Failed to send terminate signal: {e}"); - } - - break Interrupted::UserInt; - }, - - Action::UXError(interrupted) => { - // An error has occurred on the UX side - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - - break interrupted; - }, - - Action::StartJoin => { - // State-B join from the live runtime: reuse the always-on - // admin VTA session. The DIDComm service keeps running in - // the background; the join flow owns the screen until the - // user returns. Entered at the loop's tail, which is also - // where a join waiting on a community's requirements is - // entered again. - join_entry = Some(join_flow::JoinEntry::Fresh { hears_replies: true }); - }, - // The same join, for a community already chosen: `j` on a - // vetting application resumes the join that application was - // made for, without going back for its DID. - Action::StartJoinFor(vtc_did) => { - join_entry = Some(join_flow::JoinEntry::ForCommunity { - vtc_did, - hears_replies: true, - }); - }, - // A join waiting on a community's requirements keeps its screen - // up while this loop hears the answer, so the two keys that - // page offers land here rather than in the join flow. - Action::JoinCancel if awaiting_requirements.is_some() => { - if let Some(awaiting) = awaiting_requirements.take() { - config.private.vetting.forget_query(&awaiting.document_id); - } - state.active_page = state::ActivePage::Main; - }, - Action::JoinVettingJoin if awaiting_requirements.is_some() => { - if let Some(awaiting) = awaiting_requirements.take() { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Resume { - vtc_did: awaiting.vtc_did, - outcome: join_flow::RequirementsOutcome::Skipped, - }); - } - }, - // Leaving a join parked on its challenge. Nothing was sent - // but the question, and the challenge it brings stands - // unused until the community lets it lapse. - Action::JoinCancel if awaiting_challenge.is_some() => { - if let Some(awaiting) = awaiting_challenge.take() { - config.private.vetting.forget_query(&awaiting.document_id); - } - state.join.processing = false; - state.join.waiting_on_challenge = false; - state.main_page.log( - "Stopped waiting for the community's challenge. Nothing was sent — \ - join again when you are ready.", - ); - state.active_page = state::ActivePage::Main; - }, - // Leaving a wallet session. The session lapses on its own; - // the page offers to try again or go on without it. - Action::JoinCancel if awaiting_wallet.is_some() => { - if let Some(query) = - awaiting_wallet.take().and_then(|a| a.asking) - { - config.private.vetting.forget_query(&query); - } - join_entry = Some(join_flow::JoinEntry::Wallet { - end: join_flow::WalletEnd::Unfinished( - "You stopped waiting for the wallet. Nothing has been sent to \ - the community but the session request." - .to_string(), - ), - }); - }, - // Everything else acts on state and the resources below, - // and lives in `runtime_actions` where a test can call it. - // Only the three arms above need what a loop has and a - // handler cannot be given: the terminator, the action - // receiver, and the ability to `break` with this loop's own - // outcome type. - action => { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - if matches!( - runtime_actions::handle_action(&mut ctx, action).await, - runtime_actions::Handled::ExitUserInt - ) { - // A confirmed profile wipe. The handler cannot break - // this loop or reach the terminator, so it says so. - if let Err(e) = terminator.terminate(Interrupted::UserInt) { - debug!("Failed to send terminate signal: {e}"); - } - break Interrupted::UserInt; - } - }, - }, - // DIDComm inbound message events — as they arrive, or handed - // back with the result of their off-loop check. - Some((event, pre)) = next_inbound(&mut didcomm_event_rx, &mut verifier) => { - match event { - didcomm::DIDCommEvent::InboundMessage { message, transport, authenticated, .. } => { - // Capture message info before processing for detailed logging - let msg_type = message.typ.clone(); - let msg_from = message.from.clone().unwrap_or_else(|| "unknown".into()); - let msg_to = message.to.as_ref().and_then(|v| v.first()).cloned().unwrap_or_default(); - let msg_thid = message.thid.clone().unwrap_or_else(|| "none".into()); - - // A credential that has been checked no longer - // shows its membership as verifying. - let returning_credential = matches!( - pre, - Some(openvtc_core::inbound::PreVerified::Credential(_)) - ); - let returning = pre.is_some(); - let mut effects = openvtc_core::inbound::InboundEffects::default(); - // Only the sender the transport authenticated can be - // waited on: a claimed `from` alone neither holds a - // community's messages back nor jumps its queue. - let waiting = |claimed: &str| { - authenticated.as_deref() == Some(claimed) - && verifier.is_waiting_on(claimed) - }; - let arrival = match pre { - Some(pre) => openvtc_core::inbound::Arrival::Returning(pre), - None => openvtc_core::inbound::Arrival::New { waiting: &waiting }, - }; - let dispatched = openvtc_core::inbound::process_inbound_message( - &mut config, - &tdk, - &didcomm_service, - &mut seen_messages, - &message, - authenticated.as_deref(), - &mut effects, - arrival, - ) - .await; - // Applied (or refused): its kept copy goes. - if returning { - let before = config.private.deferred_inbound.len(); - config.private.deferred_inbound.retain(|kept| { - Some(kept.sender.as_str()) != authenticated.as_deref() - || kept.message.id != message.id - }); - if config.private.deferred_inbound.len() != before { - save.mark_dirty(); - } - } - let openvtc_core::inbound::InboundEffects { - inactivated, - capability_replies, - git_ns_replies, - rooms_replies, - elections_replies, - personhood_challenges, - vetting_answers, - vetting_grant_checks, - renewals, - witness_replies, - notifications, - deferred, - } = effects; - // Notifications for our personas, to the member's VTA as received, - // and the sender answered (VTI-NTF-054). - for mut received in notifications { - received.over_tsp = transport == openvtc_core::didcomm::MessagingTransport::Tsp; - notification_actions::hand_over( - &config, - &tdk, - admin_vta.as_ref(), - received, - ) - .await; - } - // A witness's answers, to the witnessing waiting on their thread. - for reply in witness_replies { - let thread = reply - .document - .get("threadId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - if witness_router.deliver(&reply.sender, reply.document) { - debug!(%thread, "a witness's answer, handed to its witnessing"); - } - } - if returning_credential { - state - .main_page - .content_panel - .communities - .verifying - .remove(&msg_from); - state.main_page.sync_from_config(&config); - } - // Set aside for its check: the membership shows - // "verifying…" until the result comes back. - if let Some(deferred) = deferred { - let _ = queue_for_check( - &mut verifier, - &mut config, - &mut state, - deferred, - transport, - authenticated.as_deref(), - ); - save.mark_dirty(); - } - // A check finished, so there may be room for a - // message kept from before the restart. - if returning - && !restore_backlog.is_empty() - && restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) - .await - { - save.mark_dirty(); - } - - // A live challenge is display state, not account - // state: single-use, ten-minute life, and worthless - // after a restart. It is folded in here rather than - // persisted, and unconditionally — a reply that - // arrived is a reply the member should see whether - // or not the message also changed the config. - for reply in personhood_challenges { - // The challenge belongs to the persona it was - // addressed to. Without one we cannot sign for - // it, so there is nothing to offer the member — - // and silently showing an unanswerable code - // would be worse than saying nothing. - let Some(persona) = config.account.persona_id_for_did(&msg_to) - else { - warn!( - vtc = %msg_from, - to = %msg_to, - "personhood challenge addressed to a DID this account \ - holds no persona for — ignoring", - ); - continue; - }; - state.main_page.content_panel.communities.personhood_challenge = - Some(crate::state_handler::main_page::content::PersonhoodChallengeView { - vtc_did: msg_from.clone(), - persona, - challenge_id: reply.challenge_id, - match_code: reply.match_code.clone(), - expires_at: reply.expires_at, - }); - state.main_page.content_panel.communities.status_message = Some(Status::info( - format!( - "Personhood challenge received — confirm the code {} with \ - whoever is vetting you, then assert.", - reply.match_code - ), - )); - } - - // How a renewal the member asked for ended: on the - // Communities status line, where they asked, and in - // the activity log with the community named. - for (vtc, outcome) in renewals { - let line = outcome.describe(); - match &outcome { - openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { - state.main_page.log(&line) - } - _ => state.main_page.log_error( - "Membership renewal", - &format!("{vtc}: {line}"), - ), - } - let severity = match &outcome { - openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { - crate::state_handler::main_page::status::Severity::Success - } - _ => crate::state_handler::main_page::status::Severity::Error, - }; - state.main_page.content_panel.communities.status_message = - Some(Status::new(severity, line)); - } - - match dispatched - { - Ok(true) => { - // R11: a config-mutating inbound message used - // to save inline here — the per-message cost - // that turned a mediator redelivery burst into - // N sequential keyring+file+card writes. Now we - // mark dirty (coalesced + offloaded); the UI - // sync stays immediate. - save.mark_dirty(); - // R-S-3: a community resolved to an inactive - // status (e.g. a rejection) — tear down its - // live session so a dead community stops - // holding a mediator connection. - for (vtc, persona) in &inactivated { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - vtc, - *persona, - ) - .await; - } - state.main_page.sync_from_config(&config); - let short_type = openvtc_core::display::task_label(&msg_type); - state.main_page.log_detailed( - format!("Inbound {transport}: {short_type}"), - format!( - "Inbound {transport} Message\n\ - ───────────────────────\n\ - Type: {msg_type}\n\ - From: {msg_from}\n\ - To: {msg_to}\n\ - thid: {msg_thid}", - ), - ); - } - Ok(false) => {} - Err(e) => { - state.main_page.log_detailed( - format!("Message error: {e}"), - format!( - "Failed Inbound Message\n\ - ──────────────────────\n\ - Type: {msg_type}\n\ - From: {msg_from}\n\ - To: {msg_to}\n\ - thid: {msg_thid}\n\ - Error: {e}", - ), - ); - debug!("message dispatch error: {e}"); - } - } - apply_capability_replies(&mut state, capability_replies); - // git-ns answers for the Repos panel; a change that - // landed is followed by a fresh read of the view. - if !git_ns_replies.is_empty() - && repos_actions::apply_replies(&mut state, &config, git_ns_replies) - { - repos_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - } - .refresh(true) - .await; - } - // The answer names the repositories to look for - // on this machine. - repos_workspace::probe_if_due(&mut state, &dispatch_tx, &mut in_flight); - // Data-room answers for the Rooms panel; each may call for the - // next step of a refresh or an acceptance. - if !rooms_replies.is_empty() - && rooms_actions::apply_replies(&mut state, rooms_replies) - { - rooms_actions::advance(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - } - // Election answers for the Elections panel; each may call for - // the next step of a ticket or a vote. - if !elections_replies.is_empty() - && elections_actions::apply_replies(&mut state, elections_replies) - { - elections_actions::advance(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - } - // A vetter's grant is checked for revocation off the - // loop: the check fetches the community's status list. - for check in vetting_grant_checks { - vetting_actions::spawn_grant_check(&dispatch_tx, &tdk, check); - } - // The manifest or challenge a waiting join asked for - // resumes it; every other answer goes to the Vetting page. - let mut answers = Vec::with_capacity(vetting_answers.len()); - for answer in vetting_answers { - if let Some(entry) = awaiting_requirements - .as_ref() - .and_then(|a| join_flow::resume_for(a, &answer)) - { - awaiting_requirements = None; - join_entry = Some(entry); - } else if let Some(entry) = awaiting_challenge - .as_ref() - .and_then(|a| join_flow::challenge_resume_for(a, &answer)) - { - awaiting_challenge = None; - join_entry = Some(entry); - } else if let Some(step) = awaiting_wallet - .as_mut() - .and_then(|a| join_flow::wallet_answer(a, &answer, &mut state)) - { - if let Some(entry) = step { - awaiting_wallet = None; - join_entry = Some(entry); - } - } else { - answers.push(answer); - } - } - vetting_actions::apply_answers(&mut state, &config, answers); - // A manifest in is a fresh reading of how its community vets: say a - // switch, and decide a ticket that was waiting on it. - vetting_actions::settle_pending_tickets( - &mut state, - &mut config, - &mut save, - chrono::Utc::now(), - ); - } - didcomm::DIDCommEvent::TrustPingReceived { from, listener_id, message_id } => { - // `from` is the sender the transport authenticated - // (never the plaintext header); with none there is - // nobody to answer. - let sender = from.as_deref().unwrap_or("unknown"); - let sender_arc = std::sync::Arc::new(sender.to_string()); - - // Only respond to pings from the mediator or established relationships - let is_mediator = - from.is_some() && sender == config.mediator_did(); - let has_relationship = from.is_some() - && config - .private - .relationships - .find_by_remote_did(&sender_arc) - .map(|r| { - r.state == openvtc_core::relationships::RelationshipState::Established - }) - .unwrap_or(false); - - if is_mediator || has_relationship { - // Send pong to the authenticated sender, setting `from` to our - // listener's DID so the recipient can identify us. - let our_listener_did = didcomm_service - .listener_did(&listener_id) - .await - .unwrap_or_else(|| config.persona_did().to_string()); - if let Ok(pong_msg) = - build_trust_pong(&our_listener_did, sender, &message_id) - && let Err(e) = didcomm::send_message_via( - &didcomm_service, - &pong_msg, - &listener_id, - sender, - ) - .await - { - state.main_page.log_error("Failed to send pong", &e); - } - let ping_display = resolve_did_to_display(&config, sender); - state.main_page.log_detailed( - format!("Ping from {ping_display} — pong sent"), - format!( - "Trust-Ping Received\n\ - ───────────────────\n\ - From (display): {ping_display}\n\ - From (DID): {sender}\n\ - Listener: {listener_id}\n\ - Response: pong sent", - ), - ); - } else { - state.main_page.log_detailed( - format!( - "Ping from {} — ignored", - resolve_did_to_display(&config, sender) - ), - format!( - "Trust-Ping Rejected\n\ - ───────────────────\n\ - From (DID): {sender}\n\ - Reason: {}", - if from.is_some() { - "no established relationship" - } else { - "the transport authenticated no sender" - }, - ), - ); - } - } - didcomm::DIDCommEvent::TrustPongReceived { from } => { - debug!(from = ?from, "TrustPongReceived event"); - let sender_did = from.as_deref().unwrap_or(""); - // `from` is the sender the transport authenticated. - // A pong with none is logged as such rather than - // credited to whoever we pinged last: it closes no - // ping task either (see the dispatcher). - let sender_display = if sender_did.is_empty() { - "an unauthenticated sender".to_string() - } else { - resolve_did_to_display(&config, sender_did) - }; - let ms = ping_sent_at - .take() - .map(|sent_at| sent_at.elapsed().as_millis()); - let latency_str = ms - .map(|v| format!(" ({v}ms)")) - .unwrap_or_default(); - // The answer belongs where the ping was sent, not only - // in the log (#584). - if !sender_did.is_empty() { - state.main_page.content_panel.relationships.status_message = - Some(Status::success(format!( - "Pong from {sender_display}{latency_str} — they are reachable." - ))); - } - state.main_page.log_detailed( - format!("Pong from {sender_display}{latency_str}"), - format!( - "Trust-Pong Received\n\ - ───────────────────\n\ - From (display): {sender_display}\n\ - From (DID): {sender_did}\n\ - Latency: {}", - ms.map(|v| format!("{v}ms")).unwrap_or_else(|| "n/a".into()), - ), - ); - } - } - }, - // Background-dispatch completions (R13). A network-bound action - // that was spawned off the loop (e.g. the mediator reconnect) - // delivers its result here; the outcome is applied on this thread - // (the single mutator) and the domain's busy-flag is cleared. - // Because this is just another select arm, nav actions, `q`/Exit, - // and inbound DIDComm events are all serviced *while* the spawned - // I/O is still pending. - Some(outcome) = dispatch_rx.recv() => { - let after = background_dispatch::apply_outcome( - &mut state, - &mut config, - &mut save, - &mut in_flight, - outcome, - ); - // A VTA answer or a failed send may call for the Rooms panel's next - // step, and frees the lane it waits on. - rooms_actions::advance(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The same for the Elections panel. - elections_actions::advance(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - let settled = settle_after_apply( - after, &mut state, &mut config, &tdk, self.profile.as_str(), - ) - .await; - match settled { - // A community we have just left still holds a messaging - // session. The session manager lives here, not in the - // shared apply path, so the outcome reports the teardown - // and this arm performs it. Local work — removing a - // listener — not a network call. - AfterSettle::Deregister(vtc, persona) => { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - &vtc, - persona, - ) - .await; - state.main_page.sync_from_config(&config); - } - // A persona minted while running has no listener yet - // (startup builds one per identity; this path does not). - // Bring it up so it can vet/apply/join without a restart. - // It has no community, so — like a community-less identity - // at startup — it gets a listener but no session-manager - // entry (that is per (persona, community)). - AfterSettle::PersonaMinted(persona_id) => { - install_persona_listener(&didcomm_service, &tdk, &config, persona_id, &mut state) - .await; - } - AfterSettle::HiddenVetting(outcome) => { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::apply_pcs_outcome(&mut ctx, *outcome).await; - } - AfterSettle::Nothing => {} - } - // A refresh asked for while the vault was busy is re-issued - // here, now that the domain is free — see `vic_refresh_queued`. - if state.main_page.content_panel.vta.vic_refresh_queued { - state.main_page.content_panel.vta.vic_refresh_queued = false; - spawn_vic_refresh(&dispatch_tx, &mut in_flight, &mut state, admin_vta.as_ref()); - } - // Same rule for the identity pane: a write that just landed - // asked for the listing it invalidated, and could not start - // it while its own outcome held the domain. - if state.main_page.content_panel.identity.refresh_queued { - state.main_page.content_panel.identity.refresh_queued = false; - spawn_persona_effect( - &dispatch_tx, - &mut in_flight, - &mut state, - admin_vta.as_ref(), - persona_actions::PersonaEffect::Read, - ); - } - }, - // D13 presence: another install using this account, or a - // report that we could not tell. The task never touches - // `State` — everything lands here, in the single mutator. - Some(report) = presence_rx.recv() => { - match report { - device_presence::PresenceReport::Registered { device_id } => { - debug!(%device_id, "this install is registered with the VTA"); - state.self_device_id = Some(device_id); - } - device_presence::PresenceReport::NewSiblings(siblings) => { - if let Some(warning) = - openvtc_core::devices::sibling_warning(&siblings) - { - state.main_page.log_detailed( - format!("WARNING: {warning}"), - siblings - .iter() - .map(|d| { - format!( - "{} — last seen {}", - d.label(), - d.last_seen_at.as_deref().unwrap_or("unknown") - ) - }) - .collect::>() - .join("\n"), - ); - } - state.live_siblings = siblings; - let _ = self.state_tx.send(state.clone()); - } - device_presence::PresenceReport::Failing { - what, - reason, - replies_not_arriving, - } => { - // Shown in the panel (the task has already logged it - // at WARN), so an absence of sibling warnings is not - // mistaken for an absence of siblings. - // Logged once per failing spell, not every interval. - let problem = format!("{} failed: {reason}", what.label()); - let receive = &mut state.main_page.content_panel.vta.receive; - let first = receive.presence_problem.is_none(); - receive.presence_problem = Some(problem.clone()); - if first { - state.main_page.log(format!("WARNING: {problem}")); - } - // The session's replies have stopped: act on it now, - // not at the next health tick. - if replies_not_arriving { - vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); - } - } - device_presence::PresenceReport::Recovered => { - state.main_page.content_panel.vta.receive.presence_problem = None; - } - } - }, - // The admin session's receive leg: read its health (no I/O) for - // the panel, and start a rebuild when one is due. - _ = vta_supervisor.tick.tick() => { - vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); - }, - // A rebuild of the stalled admin session landed. Swap the new - // client in everywhere the session is held: `admin_vta` (which - // every later operation borrows or clones from) and the - // presence task's watch. - Some(outcome) = vta_supervisor.done_rx.recv() => { - if let Some(client) = vta_supervisor.land(outcome, &mut state.main_page) { - // Collection claims are made on the live session too. - didcomm_service.set_collection_vta(Some(client.clone())); - admin_vta = Some(client); - } - }, - // Lifecycle log messages from the Messaging - Some(event) = lifecycle_log_rx.recv() => { - // Formatted here, not in the logger task: naming a listener - // needs `config`, which only this thread holds. - let line = format_lifecycle_log(&config, &event); - match line.detail { - Some(detail) => state.main_page.log_detailed(line.summary, detail), - None => state.main_page.log(line.summary), - } - }, - // Typed listener lifecycle events → drive the connection status - // asynchronously (phase 2). The persona listener connecting flips - // the status to Connected; a disconnect drops back to Connecting - // while the service auto-reconnects. - ev = listener_events.recv() => { - if let Ok(ev) = ev { - // Route persona-listener lifecycle through the session - // manager (D11/D15), which holds per-session status; a - // disconnect carrying an error is recorded as that one - // session failing (isolated — others are untouched). The - // SDK keeps retrying per its restart policy, so a restart - // or clean drop is "not connected" until the next connect. - // Events for R-DID listeners (not persona sessions) match - // nothing and are ignored. - // Every listener's state, session or not, for the - // indicator's no-community reading. - let online = &mut state.connection.online_listeners; - let listener_changed = match &ev { - didcomm::ListenerStatus::Connected { listener_id } => { - online.insert(listener_id.clone()) - } - didcomm::ListenerStatus::Disconnected { listener_id, .. } => { - online.remove(listener_id) - } - }; - // A listener that has just come up can carry what could not be sent - // while it was down: re-ask the communities' modes now. - if matches!(ev, didcomm::ListenerStatus::Connected { .. }) { - config.private.vetting.listener_connected(); - } - let changed = listener_changed | match ev { - didcomm::ListenerStatus::Connected { listener_id } => { - session_manager.mark_connected(&listener_id) - } - didcomm::ListenerStatus::Disconnected { listener_id, error } => { - match error { - Some(e) => session_manager.mark_failed(&listener_id, e), - None => session_manager.mark_disconnected(&listener_id), - } - } - }; - // Derive the global connection indicator from the - // aggregate of all persona-sessions (a per-community - // status panel is future UI work). - if changed { - apply_session_aggregate(&session_manager, &mut state); - } - } - }, - _ = session_reconcile_tick.tick() => { - // The loop tail broadcasts `state`, so a change here reaches - // the UI without an explicit send. - reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); - } - // Coalesced-save debounce (R11). Fires when the debounce window - // since the first dirty mark of a burst elapses. Builds an owned - // snapshot on this (the single mutator) thread and runs the heavy - // serialize+encrypt+keyring+card I/O on a blocking thread so the - // loop stays responsive. At most one save is in flight; a mark - // that lands while a save runs is re-scheduled on completion. - // When nothing is scheduled the arm parks forever (no busy-wait). - _ = capabilities_sweep.tick() => { - // The Repos panel: reply windows, and polling a forge-account - // link no faster than the spec's five seconds. - repos_actions::tick(&mut repos_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The Rooms panel: reply windows, and a step left on a busy lane. - rooms_actions::tick(&mut rooms_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // The Elections panel: the same. - elections_actions::tick(&mut elections_actions::Loop { - state: &mut state, - config: &config, - tdk: &tdk, - vta: admin_vta.as_ref(), - dispatch_tx: &dispatch_tx, - in_flight: &mut in_flight, - }) - .await; - // Questions to communities have a reply window too (R1.2). - vetting_actions::expire_queries(&mut state, &mut config, chrono::Utc::now()); - // A ticket whose question about its community's mode went unanswered, or - // could not be sent, ends here, saying which. - vetting_actions::settle_pending_tickets( - &mut state, - &mut config, - &mut save, - chrono::Utc::now(), - ); - // Hidden-vetting work for the VTA — answers a community or a vetter sent, - // an application that needs its engine — goes as soon as it arises, - // backed off while the VTA cannot be reached (R1.4). - if vetting_actions::pcs_work_due(&config, chrono::Utc::now()) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::start_pcs_work(&mut ctx, Vec::new()); - } - // A request reached the desk: re-read what its community - // requires now, before a session opens on a stale answer. - if std::mem::take(&mut config.private.vetting.vetter_refresh_due) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::dispatch( - &mut ctx, - actions::VettingAction::RefreshVetterSide, - ) - .await; - } else if vetting_actions::desk_open(&state) { - // While the desk is open, how each community vets is kept no older - // than its TTL — asked at most once per re-ask interval (R1.4). - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::refresh_stale_modes(&mut ctx).await; - } - if awaiting_requirements - .as_ref() - .is_some_and(|a| a.asked_at.elapsed() >= join_flow::REQUIREMENTS_WAIT) - && let Some(awaiting) = awaiting_requirements.take() - { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Resume { - vtc_did: awaiting.vtc_did, - outcome: join_flow::RequirementsOutcome::Unanswered(format!( - "no answer within {} seconds — its service may be offline", - join_flow::REQUIREMENTS_WAIT.as_secs() - )), - }); - } - // R1.2 / R6.4: a challenge that never comes is the community - // not answering — our side sent the question, so it is not - // our mediator, and nothing refused it. - if awaiting_challenge - .as_ref() - .is_some_and(|a| a.asked_at.elapsed() >= join_flow::CHALLENGE_WAIT) - && let Some(awaiting) = awaiting_challenge.take() - { - config.private.vetting.forget_query(&awaiting.document_id); - join_entry = Some(join_flow::JoinEntry::Challenge { - awaiting, - outcome: join_flow::ChallengeOutcome::Unanswered(format!( - "the request was sent, but no answer came within {} seconds — \ - its service may be offline or unreachable", - join_flow::CHALLENGE_WAIT.as_secs() - )), - }); - } - // A wallet session is polled on this tick, and given up on - // when the community stops answering (R1.2). - if let Some(awaiting) = awaiting_wallet.as_mut() - && let Some(entry) = join_flow::wallet_tick( - awaiting, - &mut config, - &tdk, - Some(&didcomm_service), - ) - .await - { - awaiting_wallet = None; - join_entry = Some(entry); - } - // A ping has a reply window too (#584): past it, say so in - // the panel that sent it and expire the outbound task, so - // the Inbox does not keep a ping nobody will answer. - if ping_sent_at.is_some_and(|t| t.elapsed() > PING_REPLY_WINDOW) { - ping_sent_at = None; - let cutoff = chrono::Utc::now() - - chrono::Duration::from_std(PING_REPLY_WINDOW).unwrap_or_default(); - let before = config.private.tasks.tasks.len(); - config.private.tasks.tasks.retain(|_, t| { - !(matches!(t.type_, openvtc_core::tasks::TaskType::TrustPing { .. }) - && t.created < cutoff) - }); - if config.private.tasks.tasks.len() != before { - save.mark_dirty(); - state.main_page.sync_from_config(&config); - } - let line = format!( - "No reply to the ping after {} s — they may be offline, or not \ - listening on that DID.", - PING_REPLY_WINDOW.as_secs() - ); - state.main_page.content_panel.relationships.status_message = - Some(Status::warning(line.clone())); - state.main_page.log_warning(line); - } - // R4.3: a capability query has a defined reply window. The - // send was fire-and-forget; if no correlated reply arrived, - // fail the view closed with a distinct, actionable message. - if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() - && view.pending_thid.is_some() - && view.sent_at.is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(30)) - { - view.pending_thid = None; - view.sent_at = None; - if matches!(view.phase, crate::state_handler::main_page::content::CapabilitiesPhase::Loading) { - view.phase = crate::state_handler::main_page::content::CapabilitiesPhase::Failed( - "no reply within 30s — the community's governance host may be offline".to_string(), - ); - } else { - view.status_message = Some(Status::error( - "no reply to the change within 30s — refresh (r) to re-check".to_string(), - )); - } - } - } - _ = pending_expiry_tick.tick() => { - // The vetting book's own clock: forget cards past their - // retention, move finished desk requests to the record of - // who we vetted, close sessions nobody answered. Fires at - // start-up too, so a card from a previous run does not wait - // an hour to go. - let mut vetting_changed = config.private.vetting.prune(chrono::Utc::now()); - // Applications whose persona this account no longer holds: - // left by a deletion before deletions took them along, and - // unusable — their DID is gone. Judged by the persona - // record, not the loaded identity, which can be missing for - // a run without the persona being gone. - let personas = &config.account.personas; - let applications = &mut config.private.vetting.applications; - let before = applications.len(); - applications.retain(|a| personas.contains_key(&a.persona)); - let orphaned = before - applications.len(); - if orphaned > 0 { - vetting_changed = true; - state.main_page.log(format!( - "Dropped {orphaned} vetting application{} made as an identity that no \ - longer exists.", - if orphaned == 1 { "" } else { "s" } - )); - } - // Applications whose join is done and that hold nothing a - // rejoin could present again (see `retire_joined`). - let members: std::collections::HashSet<(String, openvtc_core::config::account::PersonaId)> = - config - .account - .memberships() - .filter(|m| m.status.is_active()) - .map(|m| (m.vtc_did.clone(), m.persona_ref)) - .collect(); - let retired = config.private.vetting.retire_joined( - |a| members.contains(&(a.community.clone(), a.persona)), - chrono::Utc::now(), - ); - if retired > 0 { - vetting_changed = true; - state.main_page.log(format!( - "Cleared {retired} finished vetting application{} — joined, and its \ - statements have expired.", - if retired == 1 { "" } else { "s" } - )); - } - if vetting_changed { - save.mark_dirty(); - state.main_page.sync_from_config(&config); - } - // The vetter side runs on its own schedule — the - // hidden-vetting tick below, at least hourly. - // R-B-7: expire Pending joins unanswered for 7 days — or for - // the `decisionSla` a vetting community publishes — raising - // actions-required, and tear down each one's now-dead session - // (R-S-3). Records are retained read-only (R-S-1). - let vetting = &config.private.vetting; - let default_timeout = chrono::TimeDelta::days( - openvtc_core::config::account::PENDING_TIMEOUT_DAYS, - ); - let timeouts: std::collections::HashMap<_, _> = config - .account - .memberships() - .filter_map(|c| { - vetting - .decision_sla(&c.vtc_did, c.persona_ref) - .map(|sla| ((c.vtc_did.clone(), c.persona_ref), sla)) - }) - .collect(); - let expired = config.account.expire_stale_pending_with(chrono::Utc::now(), |c| { - timeouts - .get(&(c.vtc_did.clone(), c.persona_ref)) - .copied() - .unwrap_or(default_timeout) - }); - if !expired.is_empty() { - save.mark_dirty(); - for (vtc, persona) in &expired { - deregister_inactive_community( - &mut session_manager, - &didcomm_service, - &config, - &mut state, - vtc, - *persona, - ) - .await; - } - state.main_page.sync_from_config(&config); - state.main_page.log(format!( - "{} pending join{} expired (no decision within the time allowed).", - expired.len(), - if expired.len() == 1 { "" } else { "s" }, - )); - let _ = self.state_tx.send(state.clone()); - } - - // The other thing that lapses by the clock: our own `vetter` - // role credential. Nothing arrives to announce it — the first - // sign is an applicant's request refused at their end — so - // this sweep is where it gets noticed. Said once per grant - // per state; see `take_grant_warnings`. - let warnings = config.private.vetting.take_grant_warnings(chrono::Utc::now()); - if !warnings.is_empty() { - for warning in &warnings { - let community = crate::state_handler::community_label( - &config, - &warning.community, - None, - 64, - ); - state.main_page.log(if warning.expired { - format!( - "Your vetter credential from {community} expired on {}. \ - Requests to you there are refused until it is reissued — \ - ask for it again from your vetting desk (g).", - warning.valid_until.format("%Y-%m-%d"), - ) - } else { - format!( - "Your vetter credential from {community} expires on {}. \ - Ask for it again from your vetting desk (g).", - warning.valid_until.format("%Y-%m-%d"), - ) - }); - let id = warning.id(); - config.private.tasks.tasks.insert( - id.clone().into(), - openvtc_core::tasks::Task { - id: std::sync::Arc::new(id), - type_: openvtc_core::tasks::TaskType::VetterGrantExpiring { - community: std::sync::Arc::new(community), - expired: warning.expired, - valid_until: warning - .valid_until - .format("%Y-%m-%d") - .to_string(), - }, - created: chrono::Utc::now(), - our_persona: Some(warning.persona), - }, - ); - } - save.mark_dirty(); - state.main_page.sync_from_config(&config); - let _ = self.state_tx.send(state.clone()); - } - }, - _ = join_status_tick.tick() => { - // Kept messages still waiting for room (the request rate, - // say) are tried again even if no check is running. - if !restore_backlog.is_empty() - && restore_kept( - &mut verifier, - &mut restore_backlog, - &mut config, - &mut state, - &tdk, - &didcomm_service, - &mut seen_messages, - ) - .await - { - save.mark_dirty(); - } - // Ask each community about a join it has not resolved — - // every Pending join, including one that never got any - // reply, which is asked id-less; see `openvtc_core::join::status_poll`. - // - // Spawned detached rather than through `background_dispatch`: - // there is no outcome to apply on the loop thread (the reply - // arrives on the persona's listener and goes through inbound - // dispatch like any other), and the pacer already prevents - // pile-up by marking a record polled before the send. - let candidates = config.account.pollable_pending(chrono::Utc::now()); - if !candidates.is_empty() - && let Some(atm) = tdk.atm.clone() - { - let due = join_status_pacer.due(candidates, std::time::Instant::now()); - let polls = openvtc_core::join::status_poll::build(&config, &tdk, due).await; - if !polls.is_empty() { - tokio::spawn(openvtc_core::join::status_poll::send_all(atm, polls)); - } - } - }, - _ = hidden_vetting_tick.tick() => { - let now = chrono::Utc::now(); - // A question left unanswered since the last pass is retried on its - // backoff, not at the next scheduled pass (R1.4). - hidden_vetting_pacer.pull_forward(&config.private.vetting, now); - if hidden_vetting_pacer.due(now) { - let mut ctx = runtime_actions::ActionCtx { - state: &mut state, - config: &mut config, - save: &mut save, - in_flight: &mut in_flight, - dispatch_tx: &dispatch_tx, - tdk: &tdk, - admin_vta: admin_vta.as_ref(), - didcomm_service: &didcomm_service, - session_manager: &mut session_manager, - ping_sent_at: &mut ping_sent_at, - state_tx: &self.state_tx, - profile: self.profile.as_str(), - witness_router: &witness_router, - }; - vetting_actions::dispatch( - &mut ctx, - actions::VettingAction::RefreshVetterSide, - ) - .await; - let next = hidden_vetting_pacer.schedule( - &config.private.vetting, - chrono::Utc::now(), - &mut rand::thread_rng(), - ); - debug!(%next, "next hidden-vetting pass"); - } - }, - _ = community_profile_tick.tick() => { - // Ask each Active community, once, for its profile so the - // new-relationship form can default to the community's - // declared identifier form (issue #241). Spawned detached, - // like the status poll: the reply arrives on the persona's - // listener and folds in through inbound dispatch, and the - // pacer marks each community asked before the send so a tick - // never re-asks one. - if let Some(atm) = tdk.atm.clone() { - let mut asks = Vec::new(); - for ask in community_profile_pacer.due(&config) { - // Local key lookup; an ask whose key cannot be - // read is dropped rather than sent unsigned. - if let Ok(keys) = config.get_persona_keys_for(ask.persona, &tdk).await { - asks.push(ask.with_signer(keys.authentication.secret.clone())); - } - } - if !asks.is_empty() { - tokio::spawn(community_profile_poll::send_all(atm, asks)); - } - } - }, - _ = agent_name_tick.tick() => { - // Collect DIDs whose agent name is uncached or stale and, if - // any, resolve them in one background job (read-only I/O). - // Results are folded into the persisted cache on the loop - // thread by `apply_outcome`. The busy-guard drops the tick if - // a prior sweep is still running, so ticks never pile up. - let targets = config.agent_name_refresh_targets(chrono::Utc::now()); - if !targets.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::AgentName) - { - let resolver = tdk.did_resolver().clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::AgentName, - async move { - let results = - agent_name_refresh::resolve_batch(resolver, targets).await; - background_dispatch::DispatchOutcome::AgentName(results) - }, - ); - } - - // Ask what each persona presents. Shares this tick for the - // same reason as the transport probe — the loop gains no - // extra timer — and re-asks every sweep rather than on a - // TTL, because the answer is the holder's own decision and - // they may have changed it from `pnm` a moment ago. The - // busy-guard keeps a slow agent from stacking sweeps. - dispatch_binding_refresh(&config, admin_vta.as_ref(), &mut in_flight, &dispatch_tx); - - // Register the contexts memberships name. Joins before - // per-community contexts recorded an id without creating it; - // faces worn there worked only because nothing checked. - let top_context_id = config.account.top_context_id.clone(); - let unchecked: Vec = config - .account - .memberships() - .map(|c| c.sub_context_id.clone()) - .filter(|id| { - openvtc_core::config::community_context::is_sub_context( - id, - &top_context_id, - ) && !contexts_checked.contains(id) - }) - .collect::>() - .into_iter() - .collect(); - if !unchecked.is_empty() - && let Some(client) = admin_vta.as_ref() - && in_flight - .try_begin(background_dispatch::DispatchDomain::CommunityContexts) - { - contexts_checked.extend(unchecked.iter().cloned()); - let client = client.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::CommunityContexts, - async move { - let mut results = Vec::with_capacity(unchecked.len()); - for context_id in unchecked { - let name = openvtc_core::config::context_path::parse_sub_context_id( - &context_id, - ) - .map_or(context_id.clone(), |(_, slug)| slug.to_string()); - let result = - openvtc_core::config::community_context::ensure_context( - &client, - &top_context_id, - &context_id, - &name, - ) - .await - .map_err(|e| e.to_string()); - results.push((context_id, result)); - } - background_dispatch::DispatchOutcome::CommunityContexts(results) - }, - ); - } - - // Read each membership context's device grants, once a run, - // after registration has made sure the contexts exist — a - // grant listing for a context not yet created would read as - // a failure. - if !grants_swept - && !in_flight.is_busy(background_dispatch::DispatchDomain::CommunityContexts) - && let Some(client) = admin_vta.as_ref() - { - let targets = community_context_actions::sweep_targets(&config); - if !targets.is_empty() - && in_flight - .try_begin(background_dispatch::DispatchDomain::DeviceGrantSweep) - { - grants_swept = true; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::DeviceGrantSweep, - community_context_actions::sweep(client.clone(), targets), - ); - } - } - - // Probe what transports the VTA advertises, for the VTA - // panel. Runs on this tick rather than its own so the loop - // gains no extra timer. Re-probed only while the answer is - // still missing or failed — once a VTA has answered, its - // advertised services are stable for the session, so a - // healthy setup probes exactly once per launch (R1.4). - let needs_probe = state - .main_page - .content_panel - .vta - .transports - .advertised - .as_ref() - .is_none_or(|a| a.error.is_some()); - if needs_probe - && let openvtc_core::config::KeyBackend::Vta { vta_did, .. } = - &config.key_backend - && !vta_did.is_empty() - && in_flight.try_begin(background_dispatch::DispatchDomain::VtaTransports) - { - let vta_did = vta_did.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::VtaTransports, - async move { - background_dispatch::DispatchOutcome::VtaTransports( - vta_transports::probe(vta_did).await, - ) - }, - ); - } - }, - // A background TSP relationship write (an invite recorded by the - // inbound dispatcher, a relationship formed by a send) changed the - // durable store. Snapshot it into the config and mark it dirty; the - // deadline arm below persists it on the same coalesced schedule as - // every other mutation. `Notify` holds one permit, so a write that - // lands between iterations is not missed, and a burst collapses to - // one snapshot — which is exactly the coalescing we want. - _ = tsp_dirty.notified() => { - config.private.tsp_relationships = - didcomm_service.tsp_store().snapshot().await; - save.mark_dirty(); - // A relationship changed, so the pane's view of it is stale. - // Recompute here — the same event that persists it also - // refreshes what the user sees. - let rows = - compute_tsp_relationship_rows(&config, &didcomm_service.tsp_store()).await; - state.main_page.content_panel.tsp_relationships.rows = rows.into(); - }, - _ = save.wait_deadline() => { - match save.take_for_save(|| config.clone_for_save()) { - Ok(Ok(pending)) => { - let done_tx = save_done_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = pending.run().map_err(|e| format!("{e}")); - let _ = done_tx.send(result); - }); - } - // Snapshot failed: surface like an inline save failure. - // The scheduler kept the config dirty + re-armed, so it - // retries on the next deadline. - Ok(Err(e)) => { - state.main_page.log_error("Failed to save config", &e); - } - // NotDirty / InFlight — nothing to start right now. - Err(_) => {} - } - }, - // A backgrounded coalesced save finished (R11). Clear the - // in-flight flag and re-arm if the config was dirtied again. A - // failed save is surfaced exactly as the old inline `save_config` - // failure did (status + log) and left dirty for retry. - Some(result) = save_done_rx.recv() => { - match &result { - // What was just saved may be news for the VTA. - Ok(()) => vta_sync.request(), - Err(reason) => { - state - .main_page - .log_error("Failed to save config", &anyhow::anyhow!("{reason}")); - } - } - save.finish(result.is_ok()); - }, - _ = vta_sync_tick.tick() => { - if let Some(client) = admin_vta.as_ref() - && !config.account.top_context_id.is_empty() - && vta_sync.should_start(tokio::time::Instant::now()) - { - let context_id = config.account.top_context_id.clone(); - config.private.vta_sync.bind_context(&context_id); - let view = openvtc_core::vta_sync::SyncView { - account: &config.account, - private: &config.private, - key_info: &config.key_info, - }; - let out = openvtc_core::vta_sync::outgoing(&view, &config.private.vta_sync); - let since = config.private.vta_sync.watermark; - let client = client.clone(); - let tx = vta_sync_tx.clone(); - tokio::spawn(async move { - let ex = - openvtc_core::vta_sync::exchange(&client, &context_id, since, out) - .await; - let _ = tx.send(ex); - }); - } - }, - Some(ex) = vta_sync_rx.recv() => { - let mut report = openvtc_core::vta_sync::SyncReport::default(); - let mut sync_state = std::mem::take(&mut config.private.vta_sync); - let lost_race = { - let mut parts = openvtc_core::vta_sync::SyncParts { - account: &mut config.account, - private: &mut config.private, - key_info: &mut config.key_info, - }; - openvtc_core::vta_sync::absorb( - &mut parts, - &mut sync_state, - ex, - openvtc_core::vta_sync::ApplyMode::Live, - &mut report, - ) - }; - config.private.vta_sync = sync_state; - let error = report.error.take(); - for line in report.log_lines() { - state.main_page.log(line); - } - if vta_sync.finished(tokio::time::Instant::now(), error.as_ref()) - && let Some(e) = &error - { - state.main_page.log(e.explain()); - } - if lost_race { - // The merged record goes up on the next cycle. - vta_sync.request(); - } - if !report.changed.is_empty() { - state.main_page.sync_from_config(&config); - } - if report.touched() { - save.mark_dirty(); - } - }, - // (keepalive removed — WebSocket-level pings handle connectivity) - // Catch and handle interrupt signal to gracefully shutdown - Ok(interrupted) = interrupt_rx.recv() => { - break interrupted; - } - } - // Enter the join flow: from the Communities panel, or back from waiting - // on a community's requirements. Here rather than in an arm so every - // way in is handled the same. - if let Some(entry) = join_entry.take() { - awaiting_requirements = None; - awaiting_challenge = None; - awaiting_wallet = None; - match self - .join_flow( - &mut action_rx, - &mut interrupt_rx, - &mut state, - &tdk, - &mut config, - admin_vta.as_ref(), - self.profile.as_str(), - Some(&didcomm_service), - entry, - ) - .await - { - Ok(join_flow::JoinExit::Returned(joined)) => { - state.active_page = state::ActivePage::Main; - // R-B-5 / D11: bring the new community's session up live - // so the VTC's async receipt is received now, not only - // after a restart. - if let Some(joined) = joined { - register_joined_session( - &mut session_manager, - &didcomm_service, - &tdk, - &config, - joined, - &mut state, - ) - .await; - // What the new community's persona wears, now rather than at the - // next sweep — until then its row could only say it had not - // been read. - dispatch_binding_refresh( - &config, - admin_vta.as_ref(), - &mut in_flight, - &dispatch_tx, - ); - } - } - Ok(join_flow::JoinExit::AwaitRequirements(awaiting)) => { - awaiting_requirements = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitChallenge(awaiting)) => { - awaiting_challenge = Some(awaiting); - } - Ok(join_flow::JoinExit::AwaitWallet(awaiting)) => { - awaiting_wallet = Some(awaiting); - } - // F10 on a join page lands here. Like every other exit it - // must tell the UI to stop: the UI leaves its loop only on - // an interrupt, so breaking without one stopped this loop - // and left the screen up reading keys nobody acted on — - // the "join page freezes, even F10 and Esc" report. - Ok(join_flow::JoinExit::Exit(interrupted)) => { - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - break interrupted; - } - Err(e) => { - state.main_page.log_error("Join flow failed", &e); - state.active_page = state::ActivePage::Main; - } - } - } - // Keep the working-context selection valid against any account change - // this iteration applied (join/leave/status transition) before the UI - // re-renders from the broadcast (D10 / R-C-6), then point the runtime - // active identity at the selected community's persona so all - // identity-derived reads scope to the working community. When the - // working persona actually changes (e.g. the active community left and - // the default shifted), refilter the community-scoped panels so the UI - // reflects the new context this frame. - state.reconcile_selected_community_among( - &config.account, - Some(&config.identities.keys().copied().collect()), - ); - let active_persona = state - .selected_community - .as_ref() - .map(|(_, persona)| *persona); - if active_persona != config.active_persona { - config.set_active_persona(active_persona); - state.main_page.sync_from_config(&config); - } - let _ = self.state_tx.send(state.clone()); - }; - - // R11: if a backgrounded coalesced save was still running when the loop - // broke, wait for it to complete before the force-flush below. A - // `spawn_blocking` task is NOT cancelled when its `JoinHandle` is dropped, - // so that save is still live; running the shutdown save concurrently would - // mean two `Config::save`s racing the same (non-atomic) file + keyring - // writes. Draining the completion channel serialises shutdown after it. - // After `finish`, `needs_flush()` is only still true if the config was - // dirtied *after* the in-flight save's snapshot — exactly what the - // force-flush must persist. - if save.in_flight() - && let Some(result) = save_done_rx.recv().await - { - save.finish(result.is_ok()); - } - - // R11 force-flush: persist the latest state before tearing down, so - // coalescing never loses the final mutation on Exit/interrupt. Runs a - // direct blocking save (the loop has broken; there is no runtime arm left - // to schedule against). `needs_flush` is true when the config is dirty or - // a background save was still in flight when the loop broke. - if save.needs_flush() { - match save.snapshot_now(&config) { - Ok(pending) => { - if let Err(e) = pending.run() { - state - .main_page - .log_error("Failed to save config on exit", &e); - } - } - Err(e) => { - state - .main_page - .log_error("Failed to snapshot config on exit", &e); - } - } - } - - // Shut down the DIDComm service gracefully - shutdown_token.cancel(); - didcomm_service.shutdown().await; - - // Close the always-on admin VTA session. - if let Some(c) = admin_vta { - c.shutdown().await; - } - - Ok(result) + // Everything from here on is the runtime loop's (#528): its fields are + // what used to be this function's locals, and each `select!` arm is a + // method on it. + runtime::Runtime::assemble(runtime::Startup { + handler: self, + terminator, + action_rx, + interrupt_rx, + state, + tdk, + config, + admin_vta, + presence_rx, + vta_supervisor, + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + }) + .await + .run() + .await } /// Minimal event loop for when there is no active community / messaging @@ -3050,19 +1137,24 @@ impl StateHandler { /// without a restart (hot-start). All other exits return /// [`DegradedOutcome::Exit`] after closing the admin session. /// - /// # Invariant: this loop must never own a live listener across an iteration + /// The join is driven from here exactly as the runtime loop drives it — a + /// [`join_flow::JoinDriver`] whose network work runs off the loop — with + /// the one capability this loop lacks switched off: it has no inbound arm, + /// so the driver is told it cannot hear a community's answer and never + /// waits for one. + /// + /// # Invariant: this loop must not stay put holding a listener /// - /// Its `select!` has exactly two arms — `action_rx` and `interrupt_rx`. There - /// is **no inbound arm**: nothing here drains the DIDComm event channel that - /// `dispatch_inbound` feeds. A listener opened here is therefore a socket - /// whose mail the SDK acknowledges (and the mediator then deletes) while no - /// consumer runs — inbound survives only as far as the channel's 256-slot - /// buffer, and only until the runtime loop drains it. + /// There is **no inbound arm**: nothing here drains the DIDComm event + /// channel that `dispatch_inbound` feeds. A listener a join opens here (the + /// applicant's, brought up before the submit; or a persona minted from the + /// join's overlay) queues its mail on that unbounded channel for as long as + /// the join is open, and only the runtime loop drains it. /// - /// So every path that opens a listener must exit via - /// [`DegradedOutcome::Joined`] in the same iteration. The `StartJoin` arm - /// enforces this for itself *and* re-checks `list_listeners()` as a backstop, - /// because the cost of getting it wrong is silent, permanent message loss + /// So when a join ends, a loop holding any listener exits via + /// [`DegradedOutcome::Joined`] at once. [`Self::degraded_join_step`] enforces this + /// for every way a join ends *and* re-checks `list_listeners()` as a + /// backstop, because the cost of getting it wrong is a mailbox nobody reads /// that looks like a community that never answered. /// /// `vta_supervisor` watches the admin session held in `join_ctx` and @@ -3081,9 +1173,10 @@ impl StateHandler { messaging: Option<&didcomm::Messaging>, vta_supervisor: &mut vta_recovery::AdminVtaSupervisor, ) -> Result { - // R11: the degraded loop persists only via `remove_community` (State-A - // community withdrawal); `join_flow` saves itself synchronously. Coalesce - // here too and force-flush on every exit path so a withdrawal isn't lost. + // R11: the degraded loop persists via `remove_community` (State-A + // community withdrawal) and the join's own durable points, which flush + // this scheduler. Coalesce here too and force-flush on every exit path + // so a withdrawal isn't lost. let mut save = save_coalesce::SaveScheduler::new(self.profile.clone()); // State A reaches the VTA panel too (an account with no community still // manages personas and VICs there), so it needs the same off-loop path @@ -3094,9 +1187,41 @@ impl StateHandler { let (dispatch_tx, mut dispatch_rx) = tokio::sync::mpsc::unbounded_channel::(); let mut in_flight = background_dispatch::InFlight::default(); + // The join, driven from here. This loop has no inbound arm, so the join + // cannot wait on a community's answer. + let mut join = join_flow::JoinDriver::new(false); let result = loop { tokio::select! { - Some(action) = action_rx.recv() => match action { + Some(action) = join.next_action(action_rx) => match action { + // An open join owns the screen, and with it every action. + action if join.is_active() => { + let Some(ctx) = join_ctx.as_mut() else { + continue; + }; + let step = join + .on_action( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + action, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; + } + } // Shared nav reducer first — degraded mode now routes the exact // same pure-state nav set as the runtime loop (previously these // arms were duplicated here and the VTA DID-manager nav arms were @@ -3136,119 +1261,47 @@ impl StateHandler { // page it opens on differs, so they share this arm and its // listener invariant below. Action::StartJoin | Action::StartJoinFor(_) => { - let entry = match &action { - Action::StartJoinFor(vtc_did) => join_flow::JoinEntry::ForCommunity { - vtc_did: vtc_did.clone(), - // This loop has no inbound arm, so the flow - // cannot wait here for a community's answer. - hears_replies: false, - }, - _ => join_flow::JoinEntry::Fresh { - hears_replies: false, - }, - }; - // Set when a join succeeded: the loop then breaks `Joined` - // so `run()` can start messaging. - let mut joined_a_community = false; - if let Some(ctx) = join_ctx.as_mut() { - match self - .join_flow( - action_rx, - interrupt_rx, - state, - &ctx.tdk, - &mut ctx.config, - ctx.admin_vta.as_ref(), - ctx.profile.as_str(), - // The live runtime, so the applicant persona - // is connected before the submit goes out and - // an auto-admitted join's reply is streamed - // rather than stored unread. `None` only for - // the early load-failure callers, which - // cannot reach a join anyway. - messaging, - entry, - ) - .await - { - // The joined session is ignored here: a join from - // State A flips `joined_a_community`, breaking - // `Joined` so `run()` restarts into the full pipeline, - // whose startup registration (`IdentityRegistry`) - // brings the new session up (R-B-5). Live in-loop - // registration is only needed in the runtime loop. - Ok(join_flow::JoinExit::Returned(joined)) => { - // Back on the main page; resume the degraded loop. - state.active_page = state::ActivePage::Main; - // Gated on the *join*, never on whether it minted - // the account's first identity. This used to read - // `!had_identity && …`, on the assumption that the - // only way to already hold an identity here was the - // messaging-startup-failure path. It is not: the - // `CreatePersonaSubmit` arm below mints one in this - // very loop, and `active_identity()` falls back to - // the first persona in the map — so the ordinary - // first-run order (create persona, *then* join) - // left `had_identity` true, skipped the hand-off, - // and stranded the account in a loop that has no - // inbound arm. The community's reply was ACKed by - // the SDK, deleted at the mediator, and dropped. - joined_a_community = - joined.is_some() && ctx.config.active_identity().is_some(); - } - // Neither is returned without `hears_replies`. - Ok( - join_flow::JoinExit::AwaitRequirements(_) - | join_flow::JoinExit::AwaitChallenge(_) - | join_flow::JoinExit::AwaitWallet(_), - ) => { - state.active_page = state::ActivePage::Main; - } - Ok(join_flow::JoinExit::Exit(interrupted)) => { - if let Err(e) = terminator.terminate(interrupted.clone()) { - debug!("Failed to send terminate signal: {e}"); - } - break DegradedOutcome::Exit(interrupted); - } - Err(e) => { - state - .main_page - .log_error("Join flow failed", &e); - state.active_page = state::ActivePage::Main; - } + let entry = match action { + Action::StartJoinFor(vtc_did) => { + join_flow::JoinEntry::ForCommunity { vtc_did } } - } else { + _ => join_flow::JoinEntry::Fresh, + }; + let Some(ctx) = join_ctx.as_mut() else { state .main_page .log("Cannot join: no active VTA session."); - } - // Hot-start: the borrow on `join_ctx` has ended, so take - // the context and hand it back to `run()`, which brings up - // the new persona's DIDComm listener without a restart. - // - // `holds_listener` is the backstop for the same class of - // bug the condition above fixes: this loop has no inbound - // arm, so *any* live listener it owns is a mailbox nobody - // reads. Whatever opened one — this join, or some future - // arm — the only safe move is to hand off to the runtime - // loop that drains the event channel. See the invariant on - // `run_degraded_loop`. - let holds_listener = match messaging { - Some(m) => !m.list_listeners().await.is_empty(), - None => false, + let _ = self.state_tx.publish(state); + continue; }; - if must_hand_off(joined_a_community, holds_listener, join_ctx.is_some()) { - state.main_page.log(if joined_a_community { - "Joined — starting secure messaging…" - } else { - "Starting secure messaging…" - }); - let _ = self.state_tx.send(state.clone()); - break DegradedOutcome::Joined(Box::new( - join_ctx - .take() - .expect("join_ctx present, just checked is_some"), - )); + let step = join + .enter( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + // The live runtime, so the applicant persona + // is connected before the submit goes out + // and an auto-admitted join's reply is + // streamed rather than stored unread. `None` + // only for the early load-failure callers, + // which cannot reach a join anyway. + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + entry, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; } } Action::CreatePersonaSubmit => { @@ -3517,8 +1570,10 @@ impl StateHandler { Action::StartAddVic | Action::AddVicInput(..) | Action::AddVicPaste(..) | Action::AddVicClose => {} - // Owned by the join-flow and setup-wizard sub-loops, which - // run their own action loops and never delegate to this one. + // Join actions reach the join driver (the guard arm above) + // while a join is open; here there is none, so they are + // stale keys. The rest are the setup wizard's, which runs + // its own action loop and never delegates to this one. Action::ActivateMainMenu | Action::JoinSubmitVtc(..) | Action::JoinIdentitySelect(..) | Action::JoinIdentityChoose | Action::JoinNewPersonaMethodCycle | @@ -3593,6 +1648,47 @@ impl StateHandler { } }, Some(outcome) = dispatch_rx.recv() => { + // The join's own jobs, before the shared applier. + let outcome = match outcome { + background_dispatch::DispatchOutcome::Join(outcome) => { + if let Some(ctx) = join_ctx.as_mut() { + let step = join + .on_outcome( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + *outcome, + ) + .await; + if let Some(outcome) = self + .degraded_join_step(step, state, &mut join_ctx, messaging, terminator) + .await + { + break outcome; + } + let _ = self.state_tx.publish(state); + continue; + } + background_dispatch::DispatchOutcome::Join(outcome) + } + background_dispatch::DispatchOutcome::Progress( + background_dispatch::ProgressUpdate::Join { generation, line }, + ) => { + join.on_progress(state, generation, line); + let _ = self.state_tx.publish(state); + continue; + } + outcome => outcome, + }; // Applying needs the config the outcome is annotated against. // It lives in `join_ctx`, which is `take`n on hand-off to the // runtime loop — a listing that lands in that window has @@ -3612,6 +1708,8 @@ impl StateHandler { &mut ctx.config, &mut save, &mut in_flight, + // Nothing here waits on a reply by thread. + &mut openvtc_core::pending_replies::PendingReplies::default(), outcome, ); // State A holds no messaging sessions, so a pending @@ -3622,8 +1720,8 @@ impl StateHandler { after, state, &mut ctx.config, + &mut save, &ctx.tdk, - self.profile.as_str(), ) .await; // The first persona was just minted. This loop has no @@ -3633,7 +1731,33 @@ impl StateHandler { // Reuses the tested hot-start path rather than telling // the operator to restart. The borrow on `join_ctx` // ends with this match, so the break below can take it. - minted_persona = matches!(settled, AfterSettle::PersonaMinted(_)); + // + // Unless the persona was minted from an open join's + // own overlay: the join offers it as a way in and + // brings it online itself, and the hand-off waits + // for the join to end (`degraded_join_step`). + if let AfterSettle::PersonaMinted(persona_id) = settled { + if join.is_active() { + join.on_persona_minted( + &mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }, + persona_id, + ) + .await; + } else { + minted_persona = true; + } + } } None => in_flight.finish(outcome.domain()), } @@ -3641,7 +1765,7 @@ impl StateHandler { state .main_page .log("Persona created — starting secure messaging…"); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); break DegradedOutcome::Joined(Box::new( join_ctx .take() @@ -3666,10 +1790,29 @@ impl StateHandler { } } Ok(interrupted) = interrupt_rx.recv() => { + // An open join is undone first: a launch in flight rolls + // back what it made, as a cancel does. + if join.is_active() + && let Some(ctx) = join_ctx.as_mut() + { + join.cancel(&mut join_flow::JoinCtx { + state, + config: &mut ctx.config, + tdk: &ctx.tdk, + admin_vta: ctx.admin_vta.as_ref(), + messaging, + profile: ctx.profile.as_str(), + save: &mut save, + dispatch_tx: &dispatch_tx, + in_flight: &mut in_flight, + state_tx: &self.state_tx, + }) + .await; + } break DegradedOutcome::Exit(interrupted); } } - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); }; // Close the always-on admin VTA session owned by the join context, if @@ -3685,6 +1828,89 @@ impl StateHandler { Ok(result) } + /// What the State-A loop does with a join step. `Some` is the outcome the + /// loop breaks with. + /// + /// Nothing while the join goes on. When it ends, back to the main page — + /// and, when the join succeeded or a listener is held, hand the runtime + /// context back to `run()`, which brings up the new persona's DIDComm + /// listener without a restart (hot-start). See the invariant on + /// [`Self::run_degraded_loop`]. + async fn degraded_join_step( + &self, + step: join_flow::JoinStep, + state: &mut State, + join_ctx: &mut Option, + messaging: Option<&didcomm::Messaging>, + terminator: &mut Terminator, + ) -> Option { + // Set when a join succeeded: the loop then breaks `Joined` so `run()` + // can start messaging. + let joined_a_community = match step { + join_flow::JoinStep::Continue => return None, + // The joined session itself is not registered here: breaking + // `Joined` restarts into the full pipeline, whose startup + // registration (`IdentityRegistry`) brings the new session up + // (R-B-5). Live in-loop registration is only needed in the runtime + // loop. + join_flow::JoinStep::Returned(joined) => { + // Back on the main page; resume the degraded loop. + state.active_page = state::ActivePage::Main; + // Gated on the *join*, never on whether it minted the account's + // first identity. This used to read `!had_identity && …`, on the + // assumption that the only way to already hold an identity here + // was the messaging-startup-failure path. It is not: the + // `CreatePersonaSubmit` arm mints one in this very loop, and + // `active_identity()` falls back to the first persona in the map + // — so the ordinary first-run order (create persona, *then* + // join) left `had_identity` true, skipped the hand-off, and + // stranded the account in a loop that has no inbound arm. The + // community's reply was ACKed by the SDK, deleted at the + // mediator, and dropped. + joined.is_some() + && join_ctx + .as_ref() + .is_some_and(|ctx| ctx.config.active_identity().is_some()) + } + // Never returned by a driver that cannot hear replies. + join_flow::JoinStep::AwaitRequirements(_) + | join_flow::JoinStep::AwaitChallenge(_) + | join_flow::JoinStep::AwaitWallet(_) => { + state.active_page = state::ActivePage::Main; + false + } + join_flow::JoinStep::Exit(interrupted) => { + if let Err(e) = terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + return Some(DegradedOutcome::Exit(interrupted)); + } + }; + // `holds_listener` is the backstop for the same class of bug the + // condition above fixes: this loop has no inbound arm, so *any* live + // listener it owns is a mailbox nobody reads. Whatever opened one — the + // join, or some future arm — the only safe move is to hand off to the + // runtime loop that drains the event channel. + let holds_listener = match messaging { + Some(m) => !m.list_listeners().await.is_empty(), + None => false, + }; + if must_hand_off(joined_a_community, holds_listener, join_ctx.is_some()) { + state.main_page.log(if joined_a_community { + "Joined — starting secure messaging…" + } else { + "Starting secure messaging…" + }); + let _ = self.state_tx.publish(state); + return Some(DegradedOutcome::Joined(Box::new( + join_ctx + .take() + .expect("join_ctx present, just checked is_some"), + ))); + } + None + } + /// Run the degraded loop for a path that cannot hot-start (no loaded config), /// collapsing the outcome to an [`Interrupted`]. `DegradedOutcome::Joined` is /// unreachable for these callers — only the State-A entry can transition @@ -3724,7 +1950,8 @@ impl StateHandler { } } -/// Runtime context the degraded loop hands to [`StateHandler::join_flow`]. +/// Runtime context the degraded loop lends the join it drives +/// ([`join_flow::JoinDriver`]). /// /// Owns the live `Config` (mutated + persisted by a successful join), the TDK, /// the always-on admin VTA session, and the profile name. The admin session is @@ -3874,8 +2101,8 @@ async fn settle_after_apply( after: background_dispatch::AfterApply, state: &mut State, config: &mut Config, + save: &mut save_coalesce::SaveScheduler, tdk: &TDK, - profile: &str, ) -> AfterSettle { use main_page::content::CreatePersonaPhase; @@ -3889,7 +2116,17 @@ async fn settle_after_apply( AfterSettle::HiddenVetting(outcome) } background_dispatch::AfterApply::PersistPersona(minted) => { - match minted.persist(config, tdk, profile).await { + // Durability-critical: the DID already exists at the VTA, so the + // persona is force-flushed, not left to the debounce. + let persisted = match minted.write_into(config, tdk).await { + Ok(persona_id) => save + .flush(config) + .await + .map(|()| persona_id) + .map_err(anyhow::Error::from), + Err(e) => Err(e), + }; + match persisted { Ok(persona_id) => { let did = minted.shareable_did(); let copied = crate::clipboard::copy_to_clipboard(&did).is_ok(); @@ -3968,16 +2205,11 @@ fn spawn_persona_mint( let Some(job) = advance_persona_overlay(state, config, tdk, admin_vta, dispatch_tx) else { return; }; - let domain = background_dispatch::DispatchDomain::Persona; - if !in_flight.try_begin(domain) { + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { if let Some(o) = state.main_page.create_persona.as_mut() { o.phase = main_page::content::CreatePersonaPhase::Path; - o.messages = vec![background_dispatch::InFlight::busy_message(domain)]; + o.messages = vec![busy]; } - return; - } - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Persona(Box::new(job.run().await)) }); } @@ -4090,15 +2322,8 @@ fn spawn_community_job( state: &mut State, job: community_actions::CommunityJob, ) { - let domain = background_dispatch::DispatchDomain::Community; - if !in_flight.try_begin(domain) { - state.main_page.content_panel.communities.status_message = Some(Status::warning( - background_dispatch::InFlight::busy_message(domain), - )); - return; - } - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Community(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + state.main_page.content_panel.communities.status_message = Some(Status::warning(busy)); }); } @@ -4146,29 +2371,26 @@ fn membership_over_tsp( /// governance documents at a community: the view is armed with exactly one /// pending thread id, and a second send would orphan the first. /// -/// The view is armed here, before the spawn, not when the send reports back: -/// the reply travels a different channel from the send's outcome and can be -/// dispatched first. +/// The view is armed here, in the same loop turn as the spawn, not when the +/// send reports back: the reply travels a different channel from the send's +/// outcome and can be dispatched first — but by this loop, so not before the +/// arm below has run. fn spawn_capability_job( dispatch_tx: &tokio::sync::mpsc::UnboundedSender, in_flight: &mut background_dispatch::InFlight, state: &mut State, job: capability_actions::CapabilityJob, ) { - let domain = background_dispatch::DispatchDomain::Capabilities; - if !in_flight.try_begin(domain) { + let send = job.prepare(); + let arm = send.arming(); + let started = background_dispatch::dispatch(send, in_flight, dispatch_tx, |busy| { if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() { - view.status_message = Some(Status::warning( - background_dispatch::InFlight::busy_message(domain), - )); + view.status_message = Some(Status::warning(busy)); } - return; - } - let send = job.prepare(); - send.arm(state); - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::Capabilities(send.run().await) }); + if started { + arm(state); + } } /// Start a VIC vault mutation off the loop, shared by both loops. @@ -4189,20 +2411,13 @@ fn spawn_vic_mutation( admin_vta: Option<&vta_sdk::client::VtaClient>, verb: vic::VicVerb, ) { - let domain = background_dispatch::DispatchDomain::Vic; let Some(admin_vta) = admin_vta else { return }; - if !in_flight.try_begin(domain) { - state - .main_page - .log(background_dispatch::InFlight::busy_message(domain)); - return; - } let job = vic::VicJob { admin_vta: admin_vta.clone(), verb, }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::VicMutation(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + state.main_page.log(busy); }); } @@ -4286,7 +2501,6 @@ fn spawn_agent_name_job( ) { use main_page::content::AgentNameManagerPhase; - let domain = background_dispatch::DispatchDomain::AgentNameManage; let opening = matches!(verb, agent_name_actions::Verb::Open); let status = verb.status(); @@ -4298,10 +2512,17 @@ fn spawn_agent_name_job( } return; }; - if !in_flight.try_begin(domain) { + let job = agent_name_actions::AgentNameJob { + vta: admin_vta.clone(), + persona_did, + verb, + }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { if let Some(o) = state.main_page.agent_names.as_mut() { - o.message = Some(background_dispatch::InFlight::busy_message(domain)); + o.message = Some(busy); } + }); + if !started { return; } @@ -4316,38 +2537,6 @@ fn spawn_agent_name_job( // running; don't leave it armed over the refreshed list. o.confirm_remove = None; } - - let job = agent_name_actions::AgentNameJob { - vta: admin_vta.clone(), - persona_did, - verb, - }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::AgentNameManage(job.run().await) - }); -} - -/// Record what a refresh request does to the panel, and answer whether the -/// caller should actually start a job. Split out from [`spawn_vic_refresh`] -/// so the guard rules are testable without a live `VtaClient`. -/// -/// The three outcomes, and why each is what it is, are documented on -/// [`spawn_vic_refresh`]. -fn begin_vic_refresh( - in_flight: &mut background_dispatch::InFlight, - state: &mut State, - has_session: bool, -) -> bool { - if !has_session { - state.main_page.content_panel.vta.vic_loading = false; - return false; - } - if !in_flight.try_begin(background_dispatch::DispatchDomain::Vic) { - state.main_page.content_panel.vta.vic_refresh_queued = true; - return false; - } - state.main_page.content_panel.vta.vic_loading = true; - true } /// Start a background VIC-list refresh, shared by both loops. @@ -4378,20 +2567,20 @@ fn spawn_vic_refresh( state: &mut State, admin_vta: Option<&vta_sdk::client::VtaClient>, ) { - if !begin_vic_refresh(in_flight, state, admin_vta.is_some()) { + let Some(admin_vta) = admin_vta else { + state.main_page.content_panel.vta.vic_loading = false; return; + }; + let job = vic::VicRefreshJob { + admin_vta: admin_vta.clone(), + include_inactive: state.main_page.content_panel.vta.vic_show_inactive, + }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { + state.main_page.content_panel.vta.vic_refresh_queued = true; + }); + if started { + state.main_page.content_panel.vta.vic_loading = true; } - let admin_vta = admin_vta.expect("checked by begin_vic_refresh").clone(); - let include_inactive = state.main_page.content_panel.vta.vic_show_inactive; - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::Vic, - async move { - background_dispatch::DispatchOutcome::Vic( - vic::VicRefreshOutcome::run(admin_vta, include_inactive).await, - ) - }, - ); } /// Start the identity pane's network work off the loop, shared by both loops. @@ -4422,7 +2611,6 @@ fn spawn_persona_effect( ) { use persona_actions::PersonaEffect; - let domain = background_dispatch::DispatchDomain::PersonaManage; if matches!(effect, PersonaEffect::None) { return; } @@ -4442,18 +2630,6 @@ fn spawn_persona_effect( } return; }; - if !in_flight.try_begin(domain) { - match effect { - PersonaEffect::Read => { - state.main_page.content_panel.identity.refresh_queued = true; - } - _ => persona_actions::release_form( - state, - background_dispatch::InFlight::busy_message(domain), - ), - } - return; - } match effect { PersonaEffect::None => unreachable!("returned above"), @@ -4464,18 +2640,20 @@ fn spawn_persona_effect( targets: persona_actions::PersonaReadJob::targets(state), needs_claim_types: !state.main_page.content_panel.identity.claim_types_loaded, }; - state.main_page.content_panel.identity.loading = true; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::PersonaManage(job.run().await) + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { + state.main_page.content_panel.identity.refresh_queued = true; }); + if started { + state.main_page.content_panel.identity.loading = true; + } } PersonaEffect::Job(job) => { let job = persona_actions::PersonaJobRun { admin_vta: admin_vta.clone(), job, }; - background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { - background_dispatch::DispatchOutcome::PersonaManage(job.run().await) + background_dispatch::dispatch(job, in_flight, dispatch_tx, |busy| { + persona_actions::release_form(state, busy); }); } } @@ -4943,7 +3121,7 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { /// /// On the common path the listener is **already installed**: the join sequence /// brings the applicant online before it submits, so a reply that arrives during -/// the submit is not stranded at the mediator (`join_flow::start_persona_listener`). +/// the submit is not stranded at the mediator (`join_flow::driver::applicant_listener`). /// This is then the step that binds the community's session to it. The install /// arm still matters — a State-A join, a listener that could not be opened before /// the submit, or a reused persona whose session was dropped all land here. @@ -5090,18 +3268,13 @@ fn dispatch_binding_refresh( .collect(); if !binding_targets.is_empty() && let Some(client) = admin_vta - && in_flight.try_begin(background_dispatch::DispatchDomain::PersonaBinding) { - let client = client.clone(); - background_dispatch::spawn_dispatch( - dispatch_tx.clone(), - background_dispatch::DispatchDomain::PersonaBinding, - async move { - background_dispatch::DispatchOutcome::PersonaBinding( - persona_binding_refresh::resolve_batch(client, binding_targets).await, - ) - }, - ); + let job = persona_binding_refresh::BindingRefreshJob { + client: client.clone(), + targets: binding_targets, + }; + // A sweep already running answers for this one too. + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); } } @@ -5431,6 +3604,7 @@ async fn restore_kept( tdk: &affinidi_tdk::TDK, service: &didcomm::Messaging, seen: &mut openvtc_core::messaging::SeenMessages, + pending_replies: &mut openvtc_core::pending_replies::PendingReplies, ) -> bool { let mut changed = false; while let Some(kept) = backlog.pop_front() { @@ -5444,6 +3618,7 @@ async fn restore_kept( tdk, service, seen, + pending_replies, &kept.message, Some(&kept.sender), &mut effects, @@ -5669,6 +3844,7 @@ mod tests { &tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), ) .await; assert_eq!(backlog.len(), 1, "waits for room"); @@ -5691,6 +3867,7 @@ mod tests { &tdk, &service, &mut seen, + &mut openvtc_core::pending_replies::PendingReplies::default(), ) .await; assert!(backlog.is_empty()); @@ -6528,33 +4705,45 @@ mod tests { /// filter flip — so its answer is already stale, and discarding the new /// request would leave, say, a just-archived VIC rendered as active until /// someone refreshed by hand. - #[test] - fn vic_refresh_guard_defers_rather_than_drops() { + #[tokio::test] + async fn vic_refresh_guard_defers_rather_than_drops() { let mut state = State::default(); let mut in_flight = background_dispatch::InFlight::default(); + let (tx, _rx) = mpsc::unbounded_channel(); + // Nothing listens there: the job a refresh starts fails fast and its + // outcome is never applied, so the domain stays claimed until this + // test frees it by hand. + let vta = vta_sdk::client::VtaClient::new("http://127.0.0.1:1"); // No admin session: nothing to query, and the panel must not be left // claiming a load is under way. state.main_page.content_panel.vta.vic_loading = true; - assert!(!begin_vic_refresh(&mut in_flight, &mut state, false)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, None); assert!(!state.main_page.content_panel.vta.vic_loading); assert!(!in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); // First request with a session: claims the domain and starts loading. - assert!(begin_vic_refresh(&mut in_flight, &mut state, true)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); assert!(state.main_page.content_panel.vta.vic_loading); assert!(in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); // Second while that one is in flight: not started, but remembered. - assert!(!begin_vic_refresh(&mut in_flight, &mut state, true)); + state.main_page.content_panel.vta.vic_loading = false; + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); assert!( state.main_page.content_panel.vta.vic_refresh_queued, "a rejected refresh must be re-issued once the domain frees" ); + assert!( + !state.main_page.content_panel.vta.vic_loading, + "a deferred refresh starts no load" + ); // Once the outcome lands the domain is free and the queued request runs. in_flight.finish(background_dispatch::DispatchDomain::Vic); state.main_page.content_panel.vta.vic_refresh_queued = false; - assert!(begin_vic_refresh(&mut in_flight, &mut state, true)); + spawn_vic_refresh(&tx, &mut in_flight, &mut state, Some(&vta)); + assert!(state.main_page.content_panel.vta.vic_loading); + assert!(in_flight.is_busy(background_dispatch::DispatchDomain::Vic)); } } diff --git a/openvtc/src/state_handler/notification_actions.rs b/openvtc/src/state_handler/notification_actions.rs index 7a8e56bc..2458af24 100644 --- a/openvtc/src/state_handler/notification_actions.rs +++ b/openvtc/src/state_handler/notification_actions.rs @@ -39,7 +39,7 @@ use tokio::sync::Semaphore; use tracing::{debug, info, warn}; use vta_sdk::client::VtaClient; -use crate::state_handler::repos_actions::{Sender, sender}; +use crate::state_handler::community_request::{Sender, sender}; /// How many hand-overs run at once. One beyond that is not handed over and not answered, so /// its sender delivers it again later — a burst never queues unbounded work here. diff --git a/openvtc/src/state_handler/persona_actions/jobs.rs b/openvtc/src/state_handler/persona_actions/jobs.rs index 9c3cf814..7c5f3747 100644 --- a/openvtc/src/state_handler/persona_actions/jobs.rs +++ b/openvtc/src/state_handler/persona_actions/jobs.rs @@ -205,6 +205,16 @@ pub(crate) struct PersonaReadJob { pub(crate) needs_claim_types: bool, } +impl crate::state_handler::background_dispatch::Job for PersonaReadJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaManage(self.run().await) + } +} + impl PersonaReadJob { /// The targets a read should ask about: one per membership. pub(crate) fn targets(state: &State) -> Vec { @@ -272,6 +282,16 @@ pub(crate) struct PersonaJobRun { pub(crate) job: PersonaJob, } +impl crate::state_handler::background_dispatch::Job for PersonaJobRun { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaManage + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaManage(self.run().await) + } +} + impl PersonaJobRun { /// I/O only. pub(crate) async fn run(self) -> PersonaOutcome { diff --git a/openvtc/src/state_handler/persona_binding_refresh.rs b/openvtc/src/state_handler/persona_binding_refresh.rs index 8636f8b2..c6b92890 100644 --- a/openvtc/src/state_handler/persona_binding_refresh.rs +++ b/openvtc/src/state_handler/persona_binding_refresh.rs @@ -27,6 +27,24 @@ use vta_sdk::client::VtaClient; /// A `(sub_context_id, persona_did)` pair to ask about. pub type BindingTarget = (String, String); +/// One sweep: every membership's persona, asked of the agent in one job. +pub(crate) struct BindingRefreshJob { + pub(crate) client: VtaClient, + pub(crate) targets: Vec, +} + +impl crate::state_handler::background_dispatch::Job for BindingRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::PersonaBinding + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::PersonaBinding( + resolve_batch(self.client, self.targets).await, + ) + } +} + /// Resolve every target, in order, returning one entry per target. /// /// Sequential rather than concurrent on purpose: the count is bounded by the diff --git a/openvtc/src/state_handler/repos_actions.rs b/openvtc/src/state_handler/repos_actions.rs index 6c895f36..4e2cab4d 100644 --- a/openvtc/src/state_handler/repos_actions.rs +++ b/openvtc/src/state_handler/repos_actions.rs @@ -21,19 +21,14 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use affinidi_tdk::TDK; -use affinidi_tdk::messaging::ATM; -use affinidi_tdk::messaging::profiles::ATMProfile; -use affinidi_tdk::secrets_resolver::secrets::Secret; use chrono::Utc; use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; use openvtc_core::git_ns::{self, GitRight, Reply, Request}; -use tokio::sync::mpsc::UnboundedSender; use crate::state_handler::actions::ReposAction as Act; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; use crate::state_handler::main_page::repos::{ AddPersonForm, ArmedChange, CreatedRepo, EXPIRY_CHOICES, LinkFlow, LinkPhase, LinkedAccount, NewRepoForm, Pending, Purpose, ReposPhase, ReposScreen, ReposView, Severity, Status, @@ -42,10 +37,13 @@ use crate::state_handler::main_page::sanitize_display; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; -const DOMAIN: DispatchDomain = DispatchDomain::GitNs; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +pub(crate) use crate::state_handler::community_request::REPLY_WINDOW; +use crate::state_handler::community_request::{ + Sender, answered_by, capitalise, sender, sign_and_send, +}; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); +const DOMAIN: DispatchDomain = DispatchDomain::GitNs; /// How long `account/link` waits: the VTC itself waits up to 30 s for its /// bridge to begin the forge's flow, and the answer then has to travel back. @@ -63,19 +61,6 @@ pub(crate) fn reply_window(purpose: &Purpose) -> Duration { /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -/// The DID part of a DID or DID URL. -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - -/// Whether a `git-ns` answer is the community's own: sent by `vtc` and issued -/// by it. A thread id is a correlation key, not a credential. Compared by DID: -/// a did:scid community may name itself with the `?src=` it is resolved through. -pub(crate) fn answered_by(vtc: &str, from: &str, issuer: Option<&str>) -> bool { - let is_vtc = |did: &str| vta_sdk::did_scid::same_reference(did_part(did), vtc); - is_vtc(from) && issuer.is_some_and(is_vtc) -} - /// Longest text kept in a form field. Past every schema limit (a repository /// name is 100, a DID 2048, a reason 1024), so an over-long paste is refused /// by the schema with a reason rather than trimmed into something else. @@ -524,94 +509,10 @@ fn arm_drift(view: &mut ReposView, action: git_ns::DriftAction) { view.confirm = Some(ArmedChange { summary, request }); } -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().chain(c).collect()) - .unwrap_or_default() -} - // **************************************************************************** // Loop-side actions // **************************************************************************** -/// Everything a send needs, resolved on the loop thread. -pub(crate) struct Sender { - pub(crate) atm: ATM, - pub(crate) profile: Arc, - pub(crate) persona_did: String, - pub(crate) mediator: String, - /// The membership was joined over TSP, so its requests go over TSP: the - /// community answers on the transport a request arrived on, and a persona - /// that joined over TSP may not collect a DIDComm answer at all. - pub(crate) over_tsp: bool, - pub(crate) signer: Box, -} - -/// Resolve the persona's messaging identity and signing key. The key comes -/// from the TDK secrets resolver, an in-memory store populated at startup — -/// not I/O — which is what lets the job own a plain `Secret`. -pub(crate) async fn sender( - config: &Config, - tdk: &TDK, - persona: PersonaId, - vtc_did: &str, -) -> Result { - let id = config - .identities - .get(&persona) - .ok_or_else(|| "this persona has no messaging identity".to_string())?; - let atm = tdk - .atm - .as_ref() - .ok_or_else(|| "messaging is unavailable".to_string())? - .clone(); - let keys = config - .get_persona_keys_for(persona, tdk) - .await - .map_err(|e| format!("couldn't read this persona's signing key: {e}"))?; - Ok(Sender { - atm, - profile: id.profile().clone(), - persona_did: id.persona_did().to_string(), - mediator: id.mediator_did.clone().unwrap_or_default(), - over_tsp: config - .account - .membership(vtc_did, persona) - .is_some_and(|c| c.joined_over_tsp()), - signer: Box::new(keys.authentication.secret.clone()), - }) -} - -/// Sign `doc` as the persona and send it to `vtc_did`, over TSP when the -/// membership was joined over it. -pub(crate) async fn sign_and_send( - s: &Sender, - vtc_did: &str, - doc: &mut RequestDocument, -) -> Result<(), String> { - async { - openvtc_core::capabilities::sign_document(doc, &s.signer).await?; - let tsp_mediator = - openvtc_core::community_send::tsp_mediator_for(s.over_tsp, vtc_did).await; - openvtc_core::capabilities::send_capability_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - doc, - ) - .await - } - .await - .map(|_| ()) - .map_err(|e| e.to_string()) -} - /// One `git-ns/*` send, off the loop. pub(crate) struct ReposJob { sender: Sender, @@ -623,6 +524,16 @@ pub(crate) struct ReposJob { purpose: Purpose, } +impl background_dispatch::Job for ReposJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::Repos(self.run().await) + } +} + impl ReposJob { /// Sign and send. I/O only. pub(crate) async fn run(mut self) -> ReposOutcome { @@ -743,117 +654,101 @@ impl ReposOutcome { } } -/// The resources a send needs from the loop, borrowed. Both the action path -/// ([`ActionCtx`]) and the loop's own tick and reply arms build one. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, -} - -impl Loop<'_> { - /// Send `request` for the open view. `quiet` suppresses the busy message, - /// for sends nobody pressed a key for (polls, refresh after a change). - /// Returns whether the send was dispatched; every early return says why - /// in the view, unless `quiet`. - async fn send(&mut self, request: Request, purpose: Purpose, quiet: bool) -> bool { - let Some((vtc_did, persona, waiting)) = self - .state - .main_page - .content_panel - .repos - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona, v.pending.clone())) - else { - return false; - }; - // One request awaits its answer at a time. A second would take the - // pending slot, and the first answer — a grant, say — would then match - // nothing and be dropped unannounced. Polls have their own slot. - if purpose != Purpose::LinkPoll - && let Some(waiting) = waiting - { - if !quiet && let Some(view) = view_mut(self.state) { - view.note( - Severity::Warning, - match waiting.purpose { - Purpose::Change(what) => { - format!("Still waiting for the community's answer ({what}).") - } - _ => "Still waiting for the community's answer.".into(), - }, - ); - } - return false; - } - let sender = match sender(self.config, self.tdk, persona, &vtc_did).await { - Ok(s) => s, - Err(e) => { - if let Some(view) = view_mut(self.state) { - if view.data.is_none() && purpose == Purpose::View { - view.phase = ReposPhase::Failed(e); - } else { - view.note(Severity::Error, e); +/// Send `request` for the open view. `quiet` suppresses the busy message, +/// for sends nobody pressed a key for (polls, refresh after a change). +/// Returns whether the send was dispatched; every early return says why +/// in the view, unless `quiet`. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose, quiet: bool) -> bool { + let Some((vtc_did, persona, waiting)) = lp + .state + .main_page + .content_panel + .repos + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona, v.pending.clone())) + else { + return false; + }; + // One request awaits its answer at a time. A second would take the + // pending slot, and the first answer — a grant, say — would then match + // nothing and be dropped unannounced. Polls have their own slot. + if purpose != Purpose::LinkPoll + && let Some(waiting) = waiting + { + if !quiet && let Some(view) = view_mut(lp.state) { + view.note( + Severity::Warning, + match waiting.purpose { + Purpose::Change(what) => { + format!("Still waiting for the community's answer ({what}).") } + _ => "Still waiting for the community's answer.".into(), + }, + ); + } + return false; + } + let sender = match sender(lp.config, lp.tdk, persona, &vtc_did).await { + Ok(s) => s, + Err(e) => { + if let Some(view) = view_mut(lp.state) { + if view.data.is_none() && purpose == Purpose::View { + view.phase = ReposPhase::Failed(e); + } else { + view.note(Severity::Error, e); } - return false; } - }; - // Built here rather than in the job so its id — the thread the answer - // carries — is known before the send is spawned, and the view can wait - // on it at once (see `arm`). - let doc = match git_ns::build(&request, &sender.persona_did, &vtc_did) { - Ok(doc) => doc, - Err(e) => { - if let Some(view) = view_mut(self.state) { - let e = match &purpose { - Purpose::Change(what) => format!("couldn't send ({what}): {e}"), - _ => format!("couldn't send: {e}"), - }; - if view.data.is_none() && purpose == Purpose::View { - view.phase = ReposPhase::Failed(e); - } else { - view.note(Severity::Error, e); - } + return false; + } + }; + // Built here rather than in the job so its id — the thread the answer + // carries — is known before the send is spawned, and the view can wait + // on it at once (see `arm`). + let doc = match git_ns::build(&request, &sender.persona_did, &vtc_did) { + Ok(doc) => doc, + Err(e) => { + if let Some(view) = view_mut(lp.state) { + let e = match &purpose { + Purpose::Change(what) => format!("couldn't send ({what}): {e}"), + _ => format!("couldn't send: {e}"), + }; + if view.data.is_none() && purpose == Purpose::View { + view.phase = ReposPhase::Failed(e); + } else { + view.note(Severity::Error, e); } - return false; - } - }; - if !self.in_flight.try_begin(DOMAIN) { - if !quiet && let Some(view) = view_mut(self.state) { - view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); } return false; } - if let Some(view) = view_mut(self.state) { - arm( - view, - correlation_thread(&doc).to_string(), - purpose.clone(), - Instant::now(), - ); + }; + let thid = correlation_thread(&doc).to_string(); + let job = ReposJob { + sender, + vtc_did, + persona, + doc, + purpose: purpose.clone(), + }; + let started = background_dispatch::dispatch(job, lp.in_flight, lp.dispatch_tx, |msg| { + if !quiet && let Some(view) = view_mut(lp.state) { + view.note(Severity::Warning, msg); } - let job = ReposJob { - sender, - vtc_did, - persona, - doc, - purpose, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::Repos(job.run().await) - }); - true + }); + if !started { + return false; } - - /// Read the view again. - pub(crate) async fn refresh(&mut self, quiet: bool) { - self.send(Request::View { resource: None }, Purpose::View, quiet) - .await; + // Armed in the same loop turn as the spawn, so the answer — applied by + // this loop — always finds the view waiting on it. + if let Some(view) = view_mut(lp.state) { + arm(view, thid, purpose, Instant::now()); } + true +} + +/// Read the view again. +pub(crate) async fn refresh(lp: &mut Loop<'_>, quiet: bool) { + send(lp, Request::View { resource: None }, Purpose::View, quiet).await; } /// Service an action that reads the config or sends a task. @@ -875,13 +770,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } _ => {} } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) | Act::Refresh => { if let Some(view) = view_mut(lp.state) { @@ -890,7 +779,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } view.status = None; } - lp.refresh(false).await; + refresh(&mut lp, false).await; } Act::NewSubmit => submit_new(&mut lp).await, Act::AddSubmit => submit_add(&mut lp).await, @@ -898,7 +787,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { let armed = view_mut(lp.state).and_then(|v| v.confirm.take()); if let Some(armed) = armed { let what = describe(&armed.request); - lp.send(armed.request, Purpose::Change(what), false).await; + send(&mut lp, armed.request, Purpose::Change(what), false).await; } } Act::LinkStart => start_link(&mut lp).await, @@ -1029,7 +918,7 @@ async fn submit_new(lp: &mut Loop<'_>) { return; } let what = describe(&request); - lp.send(request, Purpose::Change(what), false).await; + send(lp, request, Purpose::Change(what), false).await; } /// Add the owner picker's highlighted candidate, or its pasted DID, to the @@ -1119,7 +1008,7 @@ async fn submit_add(lp: &mut Loop<'_>) { return; } let what = describe(&request); - lp.send(request, Purpose::Change(what), false).await; + send(lp, request, Purpose::Change(what), false).await; } async fn start_link(lp: &mut Loop<'_>) { @@ -1147,15 +1036,15 @@ async fn start_link(lp: &mut Loop<'_>) { // The attempt is shown only once its request is actually on its way: a // send refused here (still waiting, busy, no signing key) says why and // leaves no "starting…" line that nothing will ever answer. - if lp - .send( - Request::Link { - forge: forge.clone(), - }, - Purpose::LinkStart, - false, - ) - .await + if send( + lp, + Request::Link { + forge: forge.clone(), + }, + Purpose::LinkStart, + false, + ) + .await && let Some(view) = view_mut(lp.state) { view.link = Some(LinkFlow::starting(forge)); @@ -1576,10 +1465,13 @@ pub(crate) async fn tick(lp: &mut Loop<'_>) { } } if let Some(link_id) = poll { - lp.send(Request::LinkStatus { link_id }, Purpose::LinkPoll, true) - .await; + send(lp, Request::LinkStatus { link_id }, Purpose::LinkPoll, true).await; } super::repos_workspace::probe_if_due(lp.state, lp.dispatch_tx, lp.in_flight); + // The did-git-sign install as Help / Status and Settings show it: read off + // the loop when it is due (a persona switch, a signing change made here, + // either view opened), never in `sync_from_config`. + super::settings_actions::maybe_refresh_did_git_sign(lp.state, lp.in_flight, lp.dispatch_tx); } #[cfg(test)] @@ -1587,7 +1479,9 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; + use crate::state_handler::background_dispatch::InFlight; use openvtc_core::git_ns::{Refusal, view}; + use openvtc_core::pending_replies::PendingReplies; use serde_json::json; const VTC: &str = "did:webvh:QmVtcScid7:acme-vtc.example"; @@ -2474,14 +2368,18 @@ mod tests { let tdk = crate::state_handler::dispatch_util::test_tdk().await; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut in_flight = InFlight::default(); - Loop { - state: &mut state, - config: &config, - tdk: &tdk, - dispatch_tx: &tx, - in_flight: &mut in_flight, - } - .refresh(false) + refresh( + &mut Loop { + state: &mut state, + config: &config, + tdk: &tdk, + vta: None, + dispatch_tx: &tx, + in_flight: &mut in_flight, + pending_replies: &mut PendingReplies::default(), + }, + false, + ) .await; let v = view(&state); assert_eq!(v.pending.as_ref().unwrap().thid, "grant-1"); @@ -2508,8 +2406,10 @@ mod tests { state: &mut state, config: &config, tdk: &tdk, + vta: None, dispatch_tx: &tx, in_flight: &mut in_flight, + pending_replies: &mut PendingReplies::default(), }) .await; let v = view(&state); @@ -2529,12 +2429,15 @@ mod tests { let tdk = crate::state_handler::dispatch_util::test_tdk().await; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut in_flight = InFlight::default(); + let mut pending_replies = PendingReplies::default(); let mut lp = Loop { state, config: &config, tdk: &tdk, + vta: None, dispatch_tx: &tx, in_flight: &mut in_flight, + pending_replies: &mut pending_replies, }; f(&mut lp).await; } diff --git a/openvtc/src/state_handler/repos_workspace.rs b/openvtc/src/state_handler/repos_workspace.rs index 68b8861c..ab89e32c 100644 --- a/openvtc/src/state_handler/repos_workspace.rs +++ b/openvtc/src/state_handler/repos_workspace.rs @@ -52,6 +52,7 @@ use vta_sdk::client::VtaClient; use crate::state_handler::actions::WorkspaceAction as W; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +use crate::state_handler::community_request::capitalise; use crate::state_handler::main_page::repos::{ AccountForm, CheckoutView, ReposScreen, ReposView, Severity, SignerHealth, WorkspaceChange, WorkspaceForm, @@ -652,6 +653,16 @@ pub(crate) async fn set_up_identity( const INSTALL_BINARY: &str = "did-git-sign is not on PATH, and git runs it to sign every commit. \ Install it (`cargo install did-git-sign`), then press e here."; +impl background_dispatch::Job for WorkspaceJob { + fn domain(&self) -> DispatchDomain { + DOMAIN + } + + async fn execute(self) -> DispatchOutcome { + DispatchOutcome::Workspace(self.run().await) + } +} + impl WorkspaceJob { async fn run(self) -> WorkspaceOutcome { let (vtc_did, persona) = (self.vtc_did.clone(), self.persona); @@ -1409,22 +1420,14 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: W) { settings: view.workspace.settings.clone(), settings_path: WorkspaceSettings::path(ctx.profile).ok(), }; - if !ctx.in_flight.try_begin(DOMAIN) { - view.note(Severity::Warning, InFlight::busy_message(DOMAIN)); + let started = background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |msg| { + view.note(Severity::Warning, msg); + }); + if !started { return; } view.note(Severity::Progress, format!("{}…", capitalise(&busy))); view.workspace.busy = Some(busy); - background_dispatch::spawn_dispatch(ctx.dispatch_tx.clone(), DOMAIN, async move { - DispatchOutcome::Workspace(job.run().await) - }); -} - -fn capitalise(s: &str) -> String { - let mut c = s.chars(); - c.next() - .map(|f| f.to_uppercase().chain(c).collect()) - .unwrap_or_default() } /// How a checkout reads, in a few words, for the list. diff --git a/openvtc/src/state_handler/rooms_actions.rs b/openvtc/src/state_handler/rooms_actions.rs index a0af102c..f2c13b8f 100644 --- a/openvtc/src/state_handler/rooms_actions.rs +++ b/openvtc/src/state_handler/rooms_actions.rs @@ -26,41 +26,30 @@ //! replies and outcomes, takes it. After an acceptance ends, either way, the view is read //! again: the community and the VTA are the record. -use std::time::{Duration, Instant}; - -use affinidi_tdk::TDK; -use openvtc_core::capabilities::{RequestDocument, correlation_thread}; use openvtc_core::community_rooms::{ self, Joined, Offer, Prepared, Renewal, Reply, Request, RoomCondition, Standing, }; -use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; -use tokio::sync::mpsc::UnboundedSender; use vta_sdk::client::VtaClient; use crate::state_handler::actions::RoomsAction as Act; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; +pub(crate) use crate::state_handler::community_request::CommunityLoop as Loop; +use crate::state_handler::community_request::{self, CommunityFeature, Doc, SendOutcome, sender}; use crate::state_handler::main_page::rooms::{ - AcceptFlow, AcceptStep, Next, Pending, Purpose, RoomsPhase, RoomsView, Severity, + AcceptFlow, AcceptStep, Next, Purpose, RoomsPhase, RoomsView, Severity, }; -use crate::state_handler::repos_actions::{Sender, sender}; use crate::state_handler::runtime_actions::ActionCtx; use crate::state_handler::state::State; +use openvtc_core::pending_replies::PendingReplies; const SEND: DispatchDomain = DispatchDomain::Rooms; const KEYS: DispatchDomain = DispatchDomain::RoomsVta; -/// How long a request waits for the community's answer (R1.2). -pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); - /// A `vtc/rooms/*` answer as it arrived, threaded on the request it answers /// ([`openvtc_core::inbound::ThreadedReply`]). pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; -fn did_part(did: &str) -> &str { - did.split('#').next().unwrap_or(did) -} - fn view_mut(state: &mut State) -> Option<&mut RoomsView> { state.main_page.content_panel.rooms.view.as_mut() } @@ -90,139 +79,165 @@ pub(crate) fn reduce(state: &mut State, action: &Act) -> bool { // The loop's side // **************************************************************************** -/// What a send or a VTA call needs from the loop. -pub(crate) struct Loop<'a> { - pub(crate) state: &'a mut State, - pub(crate) config: &'a Config, - pub(crate) tdk: &'a TDK, - pub(crate) vta: Option<&'a VtaClient>, - pub(crate) dispatch_tx: &'a UnboundedSender, - pub(crate) in_flight: &'a mut InFlight, +/// The Rooms panel, on the shared request/reply plumbing +/// ([`community_request`]). +pub(crate) struct Rooms; + +impl CommunityFeature for Rooms { + type View = RoomsView; + type Purpose = Purpose; + type Reply = Reply; + type Next = Next; + const NAME: &'static str = "rooms"; + const SEND: DispatchDomain = SEND; + /// Only a community the persona belongs to answers the Rooms panel. + const RECORDS_THREAD: bool = false; + + fn view_mut(state: &mut State) -> Option<&mut RoomsView> { + view_mut(state) + } + fn pending_mut(view: &mut RoomsView) -> &mut Option> { + &mut view.pending + } + fn next_mut(view: &mut RoomsView) -> &mut Option { + &mut view.next + } + fn community(view: &RoomsView) -> (&str, PersonaId) { + (&view.vtc_did, view.persona) + } + fn note(view: &mut RoomsView, severity: Severity, text: String) { + view.note(severity, text); + } + fn outcome(outcome: SendOutcome) -> DispatchOutcome { + DispatchOutcome::Rooms(outcome) + } + fn is_refusal(reply: &Reply) -> bool { + matches!(reply, Reply::Refused(_)) + } + fn take_reply(view: &mut RoomsView, reply: Reply, purpose: Purpose) { + take_reply(view, reply, purpose); + } + fn timed_out(view: &mut RoomsView, purpose: Purpose, secs: u64) { + timed_out(view, purpose, secs); + } + fn failed(view: &mut RoomsView, purpose: &Purpose, why: String) { + failed(view, purpose, why); + } + fn end_flow(view: &mut RoomsView) { + view.accept = None; + } + async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + step(lp, next).await + } } -impl Loop<'_> { - /// Send `request` to the open view's community, armed on `purpose`. Returns whether - /// it was dispatched; every early return says why in the view. - async fn send(&mut self, request: Request, purpose: Purpose) -> bool { - let Some((vtc_did, persona, waiting)) = self - .state - .main_page - .content_panel - .rooms - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona, v.pending.is_some())) - else { - return false; - }; - if waiting { - if let Some(view) = view_mut(self.state) { - view.note( - Severity::Warning, - "Still waiting for the community's answer.", - ); - } - return false; +/// Send `request` to the open view's community, armed on `purpose`. Returns whether +/// it was dispatched; every early return says why in the view. +async fn send(lp: &mut Loop<'_>, request: Request, purpose: Purpose) -> bool { + let Some((vtc_did, persona, waiting)) = lp + .state + .main_page + .content_panel + .rooms + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona, v.pending.is_some())) + else { + return false; + }; + if waiting { + if let Some(view) = view_mut(lp.state) { + view.note( + Severity::Warning, + "Still waiting for the community's answer.", + ); } - let sender = match sender(self.config, self.tdk, persona, &vtc_did).await { - Ok(s) => s, - Err(e) => { - fail(self.state, &purpose, e); - return false; - } - }; - let doc = match community_rooms::build(&request, &sender.persona_did, &vtc_did) { - Ok(doc) => doc, - Err(e) => { - fail(self.state, &purpose, format!("couldn't send: {e}")); - return false; - } - }; - if !self.in_flight.try_begin(SEND) { + return false; + } + let sender = match sender(lp.config, lp.tdk, persona, &vtc_did).await { + Ok(s) => s, + Err(e) => { + community_request::fail::(lp.state, &purpose, e); return false; } - let thid = correlation_thread(&doc).to_string(); - if let Some(view) = view_mut(self.state) { - view.pending = Some(Pending { - thid, - sent_at: Instant::now(), - purpose, - }); + }; + let doc = match community_rooms::build(&request, &sender.persona_did, &vtc_did) { + Ok(doc) => doc, + Err(e) => { + community_request::fail::(lp.state, &purpose, format!("couldn't send: {e}")); + return false; } - let job = SendJob { - sender, + }; + lp.spawn_send::( + sender, + vtc_did, + persona, + Doc::Unsigned(Box::new(doc)), + purpose, + ) +} + +/// Ask the VTA. Returns whether it was dispatched. +fn ask_vta(lp: &mut Loop<'_>, ask: VtaAsk) -> bool { + let Some(vta) = lp.vta.cloned() else { + return false; + }; + let Some((vtc_did, persona)) = lp + .state + .main_page + .content_panel + .rooms + .view + .as_ref() + .map(|v| (v.vtc_did.clone(), v.persona)) + else { + return false; + }; + if !lp.in_flight.try_begin(KEYS) { + return false; + } + background_dispatch::spawn_dispatch(lp.dispatch_tx.clone(), KEYS, async move { + DispatchOutcome::RoomsVta(RoomsVtaOutcome { vtc_did, persona, - doc, - }; - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), SEND, async move { - DispatchOutcome::Rooms(job.run().await) - }); - true - } + answer: ask.run(&vta).await, + }) + }); + true +} - /// Ask the VTA. Returns whether it was dispatched. - fn ask_vta(&mut self, ask: VtaAsk) -> bool { - let Some(vta) = self.vta.cloned() else { - return false; - }; - let Some((vtc_did, persona)) = self - .state - .main_page - .content_panel - .rooms - .view - .as_ref() - .map(|v| (v.vtc_did.clone(), v.persona)) - else { - return false; - }; - if !self.in_flight.try_begin(KEYS) { - return false; +/// Read the community's record and the VTA's custody again. +pub(crate) async fn refresh(lp: &mut Loop<'_>) { + let me = match view_mut(lp.state) { + Some(view) => { + view.restart_pages(); + view.me.clone() } - background_dispatch::spawn_dispatch(self.dispatch_tx.clone(), KEYS, async move { - DispatchOutcome::RoomsVta(RoomsVtaOutcome { - vtc_did, - persona, - answer: ask.run(&vta).await, - }) - }); - true - } - - /// Read the community's record and the VTA's custody again. - pub(crate) async fn refresh(&mut self) { - let me = match view_mut(self.state) { - Some(view) => { - view.restart_pages(); - view.me.clone() - } - None => return, - }; - if self.vta.is_none() { - if let Some(view) = view_mut(self.state) { - view.held = None; - view.held_error = Some( - "no VTA is connected, so which rooms it holds keys for can't be shown".into(), - ); - } - } else if !self.ask_vta(VtaAsk::Held { member: me }) - && let Some(view) = view_mut(self.state) - { - // Busy with an acceptance step; the record still refreshes. - view.held_error - .get_or_insert_with(|| "your VTA is busy".into()); + None => return, + }; + if lp.vta.is_none() { + if let Some(view) = view_mut(lp.state) { + view.held = None; + view.held_error = + Some("no VTA is connected, so which rooms it holds keys for can't be shown".into()); } - self.send(Request::Memberships { cursor: None }, Purpose::Memberships) - .await; + } else if !ask_vta(lp, VtaAsk::Held { member: me }) + && let Some(view) = view_mut(lp.state) + { + // Busy with an acceptance step; the record still refreshes. + view.held_error + .get_or_insert_with(|| "your VTA is busy".into()); } + send( + lp, + Request::Memberships { cursor: None }, + Purpose::Memberships, + ) + .await; } /// Say a request never left, where the view shows it. -fn fail(state: &mut State, purpose: &Purpose, why: String) { - let Some(view) = view_mut(state) else { - return; - }; +fn failed(view: &mut RoomsView, purpose: &Purpose, why: String) { match purpose { Purpose::Memberships if view.rooms.is_empty() => view.phase = RoomsPhase::Failed(why), Purpose::Memberships => view.note(Severity::Error, why), @@ -240,14 +255,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { if let Act::Open(index) = action { open(ctx, index); } - let mut lp = Loop { - state: &mut *ctx.state, - config: ctx.config, - tdk: ctx.tdk, - vta: ctx.admin_vta, - dispatch_tx: ctx.dispatch_tx, - in_flight: &mut *ctx.in_flight, - }; + let mut lp = Loop::from_ctx(ctx); match action { Act::Open(_) | Act::Refresh => { if let Some(view) = view_mut(lp.state) { @@ -256,7 +264,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: Act) { } view.status = None; } - lp.refresh().await; + refresh(&mut lp).await; } Act::Accept => start_accept(&mut lp).await, Act::Back | Act::Select(_) => {} @@ -373,9 +381,12 @@ async fn start_accept(lp: &mut Loop<'_>) { community_rooms::display_label(&row.room) ), ); - if !lp - .send(Request::Offers { cursor: None }, Purpose::Offers { room }) - .await + if !send( + lp, + Request::Offers { cursor: None }, + Purpose::Offers { room }, + ) + .await && let Some(view) = view_mut(lp.state) && view.pending.is_none() { @@ -386,12 +397,14 @@ async fn start_accept(lp: &mut Loop<'_>) { } /// Take the step the view's last answer called for, if its lane is free. The loop calls -/// this after applying replies and dispatch outcomes, and on its tick. +/// this after applying replies and outcomes, and on its tick. pub(crate) async fn advance(lp: &mut Loop<'_>) { - let Some(next) = view_mut(lp.state).and_then(|v| v.next.take()) else { - return; - }; - let taken = match next.clone() { + community_request::advance::(lp).await; +} + +/// Take one step. Returns whether it was taken (a busy lane keeps it). +async fn step(lp: &mut Loop<'_>, next: Next) -> bool { + match next { Next::Refresh => { if lp.in_flight.is_busy(SEND) || lp @@ -405,12 +418,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { { false } else { - lp.refresh().await; + refresh(lp).await; true } } Next::MembershipsPage { cursor } => { - lp.send( + send( + lp, Request::Memberships { cursor: Some(cursor), }, @@ -419,7 +433,8 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { .await } Next::OffersPage { room, cursor } => { - lp.send( + send( + lp, Request::Offers { cursor: Some(cursor), }, @@ -445,12 +460,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { }) }); match ask { - Some(ask) => lp.ask_vta(ask), + Some(ask) => ask_vta(lp, ask), None => true, } } Next::SendAccept { room, key_package } => { - lp.send( + send( + lp, Request::Accept { room_id: room.clone(), key_package, @@ -469,21 +485,21 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { .and_then(|a| a.offer.as_ref()) .map(|o| o.credentials.invitation.to_string()); match invitation { - Some(invitation) => lp.ask_vta(VtaAsk::Join { - room, - invitation, - welcome, - ratchet_tree, - }), + Some(invitation) => ask_vta( + lp, + VtaAsk::Join { + room, + invitation, + welcome, + ratchet_tree, + }, + ), None => true, } } // Without a VTA there is nowhere to store a renewal, so nothing to read. Next::CheckRenewals if lp.vta.is_none() => true, - Next::CheckRenewals => { - lp.send(Request::Offers { cursor: None }, Purpose::Renewals) - .await - } + Next::CheckRenewals => send(lp, Request::Offers { cursor: None }, Purpose::Renewals).await, Next::Renew => { let ask = view_mut(lp.state).map(|v| VtaAsk::Renew { renewals: std::mem::take(&mut v.renewals), @@ -491,10 +507,13 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { }); match ask { Some(VtaAsk::Renew { renewals, member }) if !renewals.is_empty() => { - let taken = lp.ask_vta(VtaAsk::Renew { - renewals: renewals.clone(), - member, - }); + let taken = ask_vta( + lp, + VtaAsk::Renew { + renewals: renewals.clone(), + member, + }, + ); if !taken && let Some(view) = view_mut(lp.state) { view.renewals = renewals; } @@ -503,196 +522,74 @@ pub(crate) async fn advance(lp: &mut Loop<'_>) { _ => true, } } - }; - // A lane that was busy: keep the step for the next tick. - if !taken && let Some(view) = view_mut(lp.state) { - view.next.get_or_insert(next); } } /// The loop's periodic pass: reply windows, and any step left waiting on a busy lane. pub(crate) async fn tick(lp: &mut Loop<'_>) { - let now = Instant::now(); - if let Some(view) = view_mut(lp.state) - && view - .pending - .as_ref() - .is_some_and(|p| now.duration_since(p.sent_at) > REPLY_WINDOW) - && let Some(p) = view.pending.take() - { - let secs = REPLY_WINDOW.as_secs(); - match p.purpose { - Purpose::Memberships if view.rooms.is_empty() => { - // A community that does not serve the record still answers, with a - // refusal shown in words, so silence means the request or its answer was - // lost (R6.4). - view.phase = RoomsPhase::Failed(format!( - "no reply within {secs}s. A community that does not serve data rooms says \ + community_request::tick::(lp).await; +} + +/// The request made for `purpose` went unanswered for `secs` seconds. +fn timed_out(view: &mut RoomsView, purpose: Purpose, secs: u64) { + match purpose { + Purpose::Memberships if view.rooms.is_empty() => { + // A community that does not serve the record still answers, with a + // refusal shown in words, so silence means the request or its answer was + // lost (R6.4). + view.phase = RoomsPhase::Failed(format!( + "no reply within {secs}s. A community that does not serve data rooms says \ so, so this is not that: the request or its answer was lost — the \ community's VTC is offline or unreachable, or it answered on a route this \ persona does not collect. Press r to try again." - )); - } - Purpose::Memberships => { - view.note( - Severity::Error, - format!("no reply to the refresh within {secs}s"), - ); - } - Purpose::Offers { .. } | Purpose::Accept { .. } => { - view.accept = None; - view.note( - Severity::Error, - format!( - "No reply within {secs}s. Accepting again is safe: it finishes whatever \ + )); + } + Purpose::Memberships => { + view.note( + Severity::Error, + format!("no reply to the refresh within {secs}s"), + ); + } + Purpose::Offers { .. } | Purpose::Accept { .. } => { + view.accept = None; + view.note( + Severity::Error, + format!( + "No reply within {secs}s. Accepting again is safe: it finishes whatever \ was done, here or elsewhere. Refresh (r) first to see where it stands." - ), - ); - } - Purpose::Renewals => { - view.note( - Severity::Warning, - format!( - "no reply within {secs}s to the check for renewed access; refresh (r) to \ + ), + ); + } + Purpose::Renewals => { + view.note( + Severity::Warning, + format!( + "no reply within {secs}s to the check for renewed access; refresh (r) to \ check again" - ), - ); - } + ), + ); } } - advance(lp).await; } /// A job ended without an outcome (it panicked): end what it was part of. pub(crate) fn job_lost(state: &mut State, msg: &str) { - if let Some(view) = view_mut(state) { - view.pending = None; - view.accept = None; - view.next = None; - view.note(Severity::Error, msg.to_string()); - } -} - -// **************************************************************************** -// Sending to the community -// **************************************************************************** - -/// One `vtc/rooms/*` send, off the loop. -pub(crate) struct SendJob { - sender: Sender, - vtc_did: String, - persona: PersonaId, - doc: RequestDocument, -} - -impl SendJob { - async fn run(mut self) -> RoomsOutcome { - let thid = correlation_thread(&self.doc).to_string(); - let s = &self.sender; - let result = async { - openvtc_core::capabilities::sign_document(&mut self.doc, &s.signer).await?; - let tsp_mediator = - openvtc_core::community_send::tsp_mediator_for(s.over_tsp, &self.vtc_did).await; - openvtc_core::capabilities::send_capability_document( - &openvtc_core::community_send::Delivery { - atm: &s.atm, - profile: &s.profile, - member_did: &s.persona_did, - vtc_did: &self.vtc_did, - mediator_did: &s.mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }, - &self.doc, - ) - .await - } - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - RoomsOutcome { - vtc_did: self.vtc_did, - persona: self.persona, - thid, - result, - } - } -} - -/// What a send did. Applied on the loop. -pub(crate) struct RoomsOutcome { - vtc_did: String, - persona: PersonaId, - thid: String, - result: Result<(), String>, -} - -impl RoomsOutcome { - /// Say the send never left — while the view still waits on this request. - pub(crate) fn apply(self, state: &mut State) { - let Err(e) = self.result else { - return; - }; - let Some(view) = view_mut(state) else { - return; - }; - if view.vtc_did != self.vtc_did - || view.persona != self.persona - || view.pending.as_ref().is_none_or(|p| p.thid != self.thid) - { - tracing::debug!(thid = %self.thid, "rooms send failed for a request no longer awaited: {e}"); - return; - } - if let Some(p) = view.pending.take() { - fail( - state, - &p.purpose, - format!("couldn't reach the community: {e}"), - ); - } - } + community_request::job_lost::(state, msg); } /// Apply the community's answers. Returns whether any was taken, so the loop /// [`advance`]s. -pub(crate) fn apply_replies(state: &mut State, replies: Vec) -> bool { - let mut taken = false; - for inbound in replies { - let Some(vtc) = view_mut(state).map(|v| v.vtc_did.clone()) else { - continue; - }; - // A thread id is a correlation key, not a credential: an answer is taken only - // from the community the view is asking — sent by it and issued by it. - // By DID: a did:scid community may name itself with the `?src=` it is - // resolved through (VTI-KEY-163). - let from_vtc = vta_sdk::did_scid::same_reference(did_part(&inbound.from), &vtc); - let issued_by_vtc = inbound - .issuer - .as_deref() - .map(did_part) - .is_some_and(|issuer| vta_sdk::did_scid::same_reference(issuer, &vtc)); - if !(from_vtc && issued_by_vtc) { - tracing::warn!(from = %inbound.from, thid = %inbound.thid, "rooms reply not from the community the view is asking — dropped"); - continue; - } - taken |= apply_reply(state, &inbound.thid, inbound.reply); - } - taken +pub(crate) fn apply_replies( + state: &mut State, + pending_replies: &mut PendingReplies, + replies: Vec, +) -> bool { + community_request::apply_replies::(state, pending_replies, replies) } -fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { - let Some(view) = view_mut(state) else { - return false; - }; - if view.pending.as_ref().is_none_or(|p| p.thid != thid) { - if !matches!(reply, Reply::Refused(_)) { - tracing::warn!(%thid, "rooms reply matches no pending request — dropped"); - } - return false; - } - let Some(pending) = view.pending.take() else { - return false; - }; - match (reply, pending.purpose) { +/// Take the answer to the request made for `purpose`. +fn take_reply(view: &mut RoomsView, reply: Reply, purpose: Purpose) { + match (reply, purpose) { (Reply::Memberships(page), Purpose::Memberships) => { let next = page.next_cursor.as_ref().map(|c| c.as_str().to_string()); view.take_page(page.items, next.is_none()); @@ -813,7 +710,6 @@ fn apply_reply(state: &mut State, thid: &str, reply: Reply) -> bool { ); } } - true } // **************************************************************************** @@ -987,8 +883,10 @@ impl RoomsVtaOutcome { #[cfg(test)] mod tests { use super::*; + use crate::state_handler::main_page::rooms::Pending; use openvtc_core::community_rooms::Reply; use serde_json::json; + use std::time::Instant; fn state_with_view() -> State { let mut state = State::default(); @@ -1006,6 +904,7 @@ mod tests { thid: "t1".into(), sent_at: Instant::now(), purpose, + to: "did:key:zVtc".into(), }); } @@ -1047,6 +946,7 @@ mod tests { arm(&mut state, Purpose::Memberships); assert!(apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR1", "admitted", true)]), Some("c1") @@ -1064,6 +964,7 @@ mod tests { arm(&mut state, Purpose::Memberships); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR2", "offered", false)]), None, @@ -1086,7 +987,11 @@ mod tests { arm(&mut state, Purpose::Memberships); let mut forged = inbound(page(json!([]), None)); forged.from = "did:key:zMallory".into(); - assert!(!apply_replies(&mut state, vec![forged])); + assert!(!apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![forged] + )); assert!(view_mut(&mut state).unwrap().pending.is_some()); } @@ -1133,7 +1038,11 @@ mod tests { message: None, }; assert!(refusal.is_already_accepted()); - apply_replies(&mut state, vec![inbound(Reply::Refused(refusal))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(Reply::Refused(refusal))], + ); let view = view_mut(&mut state).unwrap(); assert!(view.accept.is_none()); assert!(view.status.as_ref().unwrap().text.contains("re-admit")); @@ -1148,6 +1057,7 @@ mod tests { arm(&mut state, Purpose::Memberships); apply_replies( &mut state, + &mut PendingReplies::default(), vec![inbound(page( json!([room("did:key:zR1", "admitted", false)]), None, @@ -1175,7 +1085,11 @@ mod tests { })) .unwrap(), )); - apply_replies(&mut state, vec![inbound(offers)]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(offers)], + ); let view = view_mut(&mut state).unwrap(); assert_eq!(view.next, Some(Next::Renew)); assert_eq!(view.renewals.len(), 1); @@ -1187,7 +1101,11 @@ mod tests { let none: Reply = Reply::Offers(Box::new( serde_json::from_value(json!({ "items": [], "renewals": [] })).unwrap(), )); - apply_replies(&mut state, vec![inbound(none)]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(none)], + ); let view = view_mut(&mut state).unwrap(); assert_eq!(view.next, None); assert!(view.status.is_none()); @@ -1203,7 +1121,11 @@ mod tests { r["suspendedAt"] = json!("2026-11-02T10:00:00Z"); r["roomState"] = json!("archived"); r["roomAccess"] = json!("noAccess"); - apply_replies(&mut state, vec![inbound(page(json!([r]), None))]); + apply_replies( + &mut state, + &mut PendingReplies::default(), + vec![inbound(page(json!([r]), None))], + ); let view = view_mut(&mut state).unwrap(); let row = &view.rows()[0]; assert_eq!(row.standing, Standing::Suspended); diff --git a/openvtc/src/state_handler/runtime/arms.rs b/openvtc/src/state_handler/runtime/arms.rs new file mode 100644 index 00000000..036f6030 --- /dev/null +++ b/openvtc/src/state_handler/runtime/arms.rs @@ -0,0 +1,634 @@ +//! The runtime's event arms other than inbound messages and the periodic +//! sweeps: UI actions, dispatch outcomes, presence and the admin-session +//! supervisor, listener lifecycle, the TSP store, saves and VTA account sync. + +use super::*; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; +use crate::state_handler::*; + +impl Runtime { + /// A UI action. Everything but leaving and the join flow's entry points is + /// `runtime_actions::handle_action`'s — and while a join is open, every + /// action is the join's, as the screen is. + pub(super) async fn on_action(&mut self, action: Action) -> Flow { + if self.join.is_active() { + let (join, mut ctx) = self.join_parts(); + let step = join.on_action(&mut ctx, action).await; + return match self.apply_join_step(step).await { + Some(interrupted) => Flow::Exit(interrupted), + None => Flow::Continue, + }; + } + let Runtime { + state, + config, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + terminator, + .. + } = self; + match action { + Action::Exit => { + if let Err(e) = terminator.terminate(Interrupted::UserInt) { + debug!("Failed to send terminate signal: {e}"); + } + + return Flow::Exit(Interrupted::UserInt); + } + + Action::UXError(interrupted) => { + // An error has occurred on the UX side + if let Err(e) = terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + + return Flow::Exit(interrupted); + } + + Action::StartJoin => { + // State-B join from the live runtime: reuse the always-on + // admin VTA session. The DIDComm service keeps running, and + // so does this loop: the join is driven from here, and owns + // the screen until the user returns. Entered at the loop's + // tail, which is also where a join waiting on a community's + // requirements is entered again. + *join_entry = Some(join_flow::JoinEntry::Fresh); + } + // The same join, for a community already chosen: `j` on a + // vetting application resumes the join that application was + // made for, without going back for its DID. + Action::StartJoinFor(vtc_did) => { + *join_entry = Some(join_flow::JoinEntry::ForCommunity { vtc_did }); + } + // A join waiting on a community's requirements keeps its screen + // up while this loop hears the answer, so the two keys that + // page offers land here rather than in the join flow. + Action::JoinCancel if awaiting_requirements.is_some() => { + if let Some(awaiting) = awaiting_requirements.take() { + config.private.vetting.forget_query(&awaiting.document_id); + } + state.active_page = state::ActivePage::Main; + } + Action::JoinVettingJoin if awaiting_requirements.is_some() => { + if let Some(awaiting) = awaiting_requirements.take() { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Resume { + vtc_did: awaiting.vtc_did, + outcome: join_flow::RequirementsOutcome::Skipped, + }); + } + } + // Leaving a join parked on its challenge. Nothing was sent + // but the question, and the challenge it brings stands + // unused until the community lets it lapse. + Action::JoinCancel if awaiting_challenge.is_some() => { + if let Some(awaiting) = awaiting_challenge.take() { + config.private.vetting.forget_query(&awaiting.document_id); + } + state.join.processing = false; + state.join.waiting_on_challenge = false; + state.main_page.log( + "Stopped waiting for the community's challenge. Nothing was sent — \ + join again when you are ready.", + ); + state.active_page = state::ActivePage::Main; + } + // Leaving a wallet session. The session lapses on its own; + // the page offers to try again or go on without it. + Action::JoinCancel if awaiting_wallet.is_some() => { + if let Some(query) = awaiting_wallet.take().and_then(|a| a.asking) { + config.private.vetting.forget_query(&query); + } + *join_entry = Some(join_flow::JoinEntry::Wallet { + end: join_flow::WalletEnd::Unfinished( + "You stopped waiting for the wallet. Nothing has been sent to \ + the community but the session request." + .to_string(), + ), + }); + } + // Everything else acts on state and the resources below, + // and lives in `runtime_actions` where a test can call it. + // Only the three arms above need what a loop has and a + // handler cannot be given: the terminator, the action + // receiver, and the ability to `break` with this loop's own + // outcome type. + action => { + if matches!( + runtime_actions::handle_action(&mut self.action_ctx(), action).await, + runtime_actions::Handled::ExitUserInt + ) { + // A confirmed profile wipe. The handler cannot break + // this loop or reach the terminator, so it says so. + if let Err(e) = self.terminator.terminate(Interrupted::UserInt) { + debug!("Failed to send terminate signal: {e}"); + } + return Flow::Exit(Interrupted::UserInt); + } + } + } + Flow::Continue + } + + /// Background-dispatch completions (R13). A network-bound action + /// that was spawned off the loop (e.g. the mediator reconnect) + /// delivers its result here; the outcome is applied on this thread + /// (the single mutator) and the domain's busy-flag is cleared. + /// Because this is just another select arm, nav actions, `q`/Exit, + /// and inbound DIDComm events are all serviced *while* the spawned + /// I/O is still pending. + pub(super) async fn on_dispatch_outcome( + &mut self, + outcome: background_dispatch::DispatchOutcome, + ) -> Flow { + // The join's own jobs, before the shared applier: the driver matches + // each to the job it is waiting for. + let outcome = match outcome { + background_dispatch::DispatchOutcome::Join(outcome) => { + let (join, mut ctx) = self.join_parts(); + let step = join.on_outcome(&mut ctx, *outcome).await; + let flow = match self.apply_join_step(step).await { + Some(interrupted) => Flow::Exit(interrupted), + None => Flow::Continue, + }; + self.release_held_inbound().await; + return flow; + } + background_dispatch::DispatchOutcome::Progress( + background_dispatch::ProgressUpdate::Join { generation, line }, + ) => { + self.join.on_progress(&mut self.state, generation, line); + return Flow::Continue; + } + outcome => outcome, + }; + let mut minted = None; + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + ping_sent_at, + handler, + pending_replies, + .. + } = self; + let after = background_dispatch::apply_outcome( + state, + config, + save, + in_flight, + pending_replies, + outcome, + ); + // A VTA answer or a failed send may call for the Rooms panel's next + // step, and frees the lane it waits on. + rooms_actions::advance(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + // The same for the Elections panel. + elections_actions::advance(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + let settled = settle_after_apply(after, state, config, save, tdk).await; + match settled { + // A community we have just left still holds a messaging + // session. The session manager lives here, not in the + // shared apply path, so the outcome reports the teardown + // and this arm performs it. Local work — removing a + // listener — not a network call. + AfterSettle::Deregister(vtc, persona) => { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + &vtc, + persona, + ) + .await; + state.main_page.sync_from_config(config); + } + // A persona minted while running has no listener yet + // (startup builds one per identity; this path does not). + // Bring it up so it can vet/apply/join without a restart. + // It has no community, so — like a community-less identity + // at startup — it gets a listener but no session-manager + // entry (that is per (persona, community)). + AfterSettle::PersonaMinted(persona_id) => minted = Some(persona_id), + AfterSettle::HiddenVetting(outcome) => { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + pending_replies, + }; + vetting_actions::apply_pcs_outcome(&mut ctx, *outcome).await; + } + AfterSettle::Nothing => {} + } + // A refresh asked for while the vault was busy is re-issued + // here, now that the domain is free — see `vic_refresh_queued`. + if state.main_page.content_panel.vta.vic_refresh_queued { + state.main_page.content_panel.vta.vic_refresh_queued = false; + spawn_vic_refresh(dispatch_tx, in_flight, state, admin_vta.as_ref()); + } + // Same rule for the identity pane: a write that just landed + // asked for the listing it invalidated, and could not start + // it while its own outcome held the domain. + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; + spawn_persona_effect( + dispatch_tx, + in_flight, + state, + admin_vta.as_ref(), + persona_actions::PersonaEffect::Read, + ); + } + if let Some(persona_id) = minted { + if self.join.is_active() { + // Minted from the join's own create-persona overlay: the join + // offers it as a way in, and brings it online itself. + let (join, mut ctx) = self.join_parts(); + join.on_persona_minted(&mut ctx, persona_id).await; + } else { + install_persona_listener( + &self.didcomm_service, + &self.tdk, + &self.config, + persona_id, + &mut self.state, + ) + .await; + } + } + Flow::Continue + } + + /// D13 presence: another install using this account, or a + /// report that we could not tell. The task never touches + /// `State` — everything lands here, in the single mutator. + pub(super) async fn on_presence(&mut self, report: device_presence::PresenceReport) { + let Runtime { + state, + admin_vta, + vta_supervisor, + handler, + .. + } = self; + match report { + device_presence::PresenceReport::Registered { device_id } => { + debug!(%device_id, "this install is registered with the VTA"); + state.self_device_id = Some(device_id); + } + device_presence::PresenceReport::NewSiblings(siblings) => { + if let Some(warning) = openvtc_core::devices::sibling_warning(&siblings) { + state.main_page.log_detailed( + format!("WARNING: {warning}"), + siblings + .iter() + .map(|d| { + format!( + "{} — last seen {}", + d.label(), + d.last_seen_at.as_deref().unwrap_or("unknown") + ) + }) + .collect::>() + .join("\n"), + ); + } + state.live_siblings = siblings; + let _ = handler.state_tx.publish(state); + } + device_presence::PresenceReport::Failing { + what, + reason, + replies_not_arriving, + } => { + // Shown in the panel (the task has already logged it + // at WARN), so an absence of sibling warnings is not + // mistaken for an absence of siblings. + // Logged once per failing spell, not every interval. + let problem = format!("{} failed: {reason}", what.label()); + let receive = &mut state.main_page.content_panel.vta.receive; + let first = receive.presence_problem.is_none(); + receive.presence_problem = Some(problem.clone()); + if first { + state.main_page.log(format!("WARNING: {problem}")); + } + // The session's replies have stopped: act on it now, + // not at the next health tick. + if replies_not_arriving { + vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); + } + } + device_presence::PresenceReport::Recovered => { + state.main_page.content_panel.vta.receive.presence_problem = None; + } + } + } + + /// The admin session's receive leg: read its health (no I/O) for + /// the panel, and start a rebuild when one is due. + pub(super) async fn on_vta_health_tick(&mut self) { + let Runtime { + state, + admin_vta, + vta_supervisor, + .. + } = self; + vta_supervisor.check(admin_vta.as_ref(), &mut state.main_page); + } + + /// A rebuild of the stalled admin session landed. Swap the new + /// client in everywhere the session is held: `admin_vta` (which + /// every later operation borrows or clones from) and the + /// presence task's watch. + pub(super) async fn on_vta_rebuilt(&mut self, outcome: vta_recovery::RebuildOutcome) { + let Runtime { + state, + admin_vta, + didcomm_service, + vta_supervisor, + .. + } = self; + if let Some(client) = vta_supervisor.land(outcome, &mut state.main_page) { + // Collection claims are made on the live session too. + didcomm_service.set_collection_vta(Some(client.clone())); + *admin_vta = Some(client); + } + } + + /// Lifecycle log messages from the Messaging + pub(super) async fn on_lifecycle_log(&mut self, event: didcomm::LifecycleLog) { + let Runtime { state, config, .. } = self; + // Formatted here, not in the logger task: naming a listener + // needs `config`, which only this thread holds. + let line = format_lifecycle_log(config, &event); + match line.detail { + Some(detail) => state.main_page.log_detailed(line.summary, detail), + None => state.main_page.log(line.summary), + } + } + + /// Typed listener lifecycle events → drive the connection status + /// asynchronously (phase 2). The persona listener connecting flips + /// the status to Connected; a disconnect drops back to Connecting + /// while the service auto-reconnects. + pub(super) async fn on_listener_event( + &mut self, + ev: Result, + ) { + let Runtime { + state, + config, + session_manager, + .. + } = self; + if let Ok(ev) = ev { + // Route persona-listener lifecycle through the session + // manager (D11/D15), which holds per-session status; a + // disconnect carrying an error is recorded as that one + // session failing (isolated — others are untouched). The + // SDK keeps retrying per its restart policy, so a restart + // or clean drop is "not connected" until the next connect. + // Events for R-DID listeners (not persona sessions) match + // nothing and are ignored. + // Every listener's state, session or not, for the + // indicator's no-community reading. + let online = &mut state.connection.online_listeners; + let listener_changed = match &ev { + didcomm::ListenerStatus::Connected { listener_id } => { + online.insert(listener_id.clone()) + } + didcomm::ListenerStatus::Disconnected { listener_id, .. } => { + online.remove(listener_id) + } + }; + // A listener that has just come up can carry what could not be sent + // while it was down: re-ask the communities' modes now. + if matches!(ev, didcomm::ListenerStatus::Connected { .. }) { + config.private.vetting.listener_connected(); + } + let changed = listener_changed + | match ev { + didcomm::ListenerStatus::Connected { listener_id } => { + session_manager.mark_connected(&listener_id) + } + didcomm::ListenerStatus::Disconnected { listener_id, error } => match error { + Some(e) => session_manager.mark_failed(&listener_id, e), + None => session_manager.mark_disconnected(&listener_id), + }, + }; + // Derive the global connection indicator from the + // aggregate of all persona-sessions (a per-community + // status panel is future UI work). + if changed { + apply_session_aggregate(session_manager, state); + } + } + } + + /// The backstop for the edge-triggered listener-status stream. Returns + /// whether a session's status changed. + pub(super) async fn on_session_reconcile(&mut self) -> bool { + let Runtime { + state, + didcomm_service, + session_manager, + .. + } = self; + // The loop tail broadcasts `state`, so a change here reaches + // the UI without an explicit send. + reconcile_sessions(session_manager, didcomm_service, state) + } + + /// A background TSP relationship write (an invite recorded by the + /// inbound dispatcher, a relationship formed by a send) changed the + /// durable store. Snapshot it into the config and mark it dirty; the + /// deadline arm below persists it on the same coalesced schedule as + /// every other mutation. `Notify` holds one permit, so a write that + /// lands between iterations is not missed, and a burst collapses to + /// one snapshot — which is exactly the coalescing we want. + pub(super) async fn on_tsp_dirty(&mut self) { + let Runtime { + state, + config, + didcomm_service, + save, + .. + } = self; + config.private.tsp_relationships = didcomm_service.tsp_store().snapshot().await; + save.mark_dirty(); + // A relationship changed, so the pane's view of it is stale. + // Recompute here — the same event that persists it also + // refreshes what the user sees. + let rows = compute_tsp_relationship_rows(config, &didcomm_service.tsp_store()).await; + state.main_page.content_panel.tsp_relationships.rows = rows.into(); + } + + pub(super) async fn on_save_deadline(&mut self) { + let Runtime { + state, + config, + save, + save_done_tx, + .. + } = self; + match save.take_for_save(|| config.clone_for_save()) { + Ok(Ok(pending)) => { + let done_tx = save_done_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = pending.run().map_err(|e| format!("{e}")); + let _ = done_tx.send(result); + }); + } + // Snapshot failed: surface like an inline save failure. + // The scheduler kept the config dirty + re-armed, so it + // retries on the next deadline. + Ok(Err(e)) => { + state.main_page.log_error("Failed to save config", &e); + } + // NotDirty / InFlight — nothing to start right now. + Err(_) => {} + } + } + + /// A backgrounded coalesced save finished (R11). Clear the + /// in-flight flag and re-arm if the config was dirtied again. A + /// failed save is surfaced exactly as the old inline `save_config` + /// failure did (status + log) and left dirty for retry. + pub(super) async fn on_save_done(&mut self, result: Result<(), String>) { + let Runtime { + state, + save, + vta_sync, + .. + } = self; + match &result { + // What was just saved may be news for the VTA. + Ok(()) => vta_sync.request(), + Err(reason) => { + state + .main_page + .log_error("Failed to save config", &anyhow::anyhow!("{reason}")); + } + } + save.finish(result.is_ok()); + } + + /// Start a VTA account-sync cycle when one is due. Returns whether one + /// started. + pub(super) async fn on_vta_sync_tick(&mut self) -> bool { + let Runtime { + config, + admin_vta, + vta_sync, + vta_sync_tx, + .. + } = self; + if let Some(client) = admin_vta.as_ref() + && !config.account.top_context_id.is_empty() + && vta_sync.should_start(tokio::time::Instant::now()) + { + let context_id = config.account.top_context_id.clone(); + config.private.vta_sync.bind_context(&context_id); + let view = openvtc_core::vta_sync::SyncView { + account: &config.account, + private: &config.private, + key_info: &config.key_info, + }; + let out = openvtc_core::vta_sync::outgoing(&view, &config.private.vta_sync); + let since = config.private.vta_sync.watermark; + let client = client.clone(); + let tx = vta_sync_tx.clone(); + tokio::spawn(async move { + let ex = openvtc_core::vta_sync::exchange(&client, &context_id, since, out).await; + let _ = tx.send(ex); + }); + return true; + } + false + } + + pub(super) async fn on_vta_sync_exchange(&mut self, ex: openvtc_core::vta_sync::Exchange) { + let Runtime { + state, + config, + save, + vta_sync, + .. + } = self; + let mut report = openvtc_core::vta_sync::SyncReport::default(); + let mut sync_state = std::mem::take(&mut config.private.vta_sync); + let lost_race = { + let mut parts = openvtc_core::vta_sync::SyncParts { + account: &mut config.account, + private: &mut config.private, + key_info: &mut config.key_info, + }; + openvtc_core::vta_sync::absorb( + &mut parts, + &mut sync_state, + ex, + openvtc_core::vta_sync::ApplyMode::Live, + &mut report, + ) + }; + config.private.vta_sync = sync_state; + let error = report.error.take(); + for line in report.log_lines() { + state.main_page.log(line); + } + if vta_sync.finished(tokio::time::Instant::now(), error.as_ref()) + && let Some(e) = &error + { + state.main_page.log(e.explain()); + } + if lost_race { + // The merged record goes up on the next cycle. + vta_sync.request(); + } + if !report.changed.is_empty() { + state.main_page.sync_from_config(config); + } + if report.touched() { + save.mark_dirty(); + } + } +} diff --git a/openvtc/src/state_handler/runtime/inbound_arm.rs b/openvtc/src/state_handler/runtime/inbound_arm.rs new file mode 100644 index 00000000..004843fc --- /dev/null +++ b/openvtc/src/state_handler/runtime/inbound_arm.rs @@ -0,0 +1,520 @@ +//! The runtime's inbound arm: a DIDComm event as it arrives, or handed +//! back with the result of its off-loop check. + +use super::*; +use crate::state_handler::*; + +/// The most messages held for a join being submitted; past this they are +/// processed as they arrive, as before. +const MAX_HELD_INBOUND: usize = 64; + +impl Runtime { + /// Process messages held while a join request was being sent, once it + /// no longer is. + pub(super) async fn release_held_inbound(&mut self) { + if self.join.submitting_to().is_some() || self.held_inbound.is_empty() { + return; + } + for (event, pre) in std::mem::take(&mut self.held_inbound) { + Box::pin(self.on_inbound(event, pre)).await; + } + } + + /// DIDComm inbound message events — as they arrive, or handed + /// back with the result of their off-loop check. + pub(super) async fn on_inbound( + &mut self, + event: didcomm::DIDCommEvent, + pre: Option, + ) { + // While a join request to a community is being sent, its messages + // wait: the pending record its submit-receipt or verdict is matched + // against is written only once the submit comes back, and a fast + // answer processed first would find nothing and be dropped. The old + // nested join loop got this for free by not reading inbound at all. + if let didcomm::DIDCommEvent::InboundMessage { authenticated, .. } = &event + && let Some(vtc) = self.join.submitting_to() + && authenticated.as_deref() == Some(vtc) + && self.held_inbound.len() < MAX_HELD_INBOUND + { + self.held_inbound.push((event, pre)); + return; + } + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + verifier, + restore_backlog, + seen_messages, + pending_replies, + witness_router, + ping_sent_at, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + .. + } = self; + match event { + didcomm::DIDCommEvent::InboundMessage { + message, + transport, + authenticated, + .. + } => { + // Capture message info before processing for detailed logging + let msg_type = message.typ.clone(); + let msg_from = message.from.clone().unwrap_or_else(|| "unknown".into()); + let msg_to = message + .to + .as_ref() + .and_then(|v| v.first()) + .cloned() + .unwrap_or_default(); + let msg_thid = message.thid.clone().unwrap_or_else(|| "none".into()); + + // A credential that has been checked no longer + // shows its membership as verifying. + let returning_credential = + matches!(pre, Some(openvtc_core::inbound::PreVerified::Credential(_))); + let returning = pre.is_some(); + let mut effects = openvtc_core::inbound::InboundEffects::default(); + // Only the sender the transport authenticated can be + // waited on: a claimed `from` alone neither holds a + // community's messages back nor jumps its queue. + let waiting = |claimed: &str| { + authenticated.as_deref() == Some(claimed) && verifier.is_waiting_on(claimed) + }; + let arrival = match pre { + Some(pre) => openvtc_core::inbound::Arrival::Returning(pre), + None => openvtc_core::inbound::Arrival::New { waiting: &waiting }, + }; + let dispatched = openvtc_core::inbound::process_inbound_message( + config, + tdk, + didcomm_service, + seen_messages, + pending_replies, + &message, + authenticated.as_deref(), + &mut effects, + arrival, + ) + .await; + // Applied (or refused): its kept copy goes. + if returning { + let before = config.private.deferred_inbound.len(); + config.private.deferred_inbound.retain(|kept| { + Some(kept.sender.as_str()) != authenticated.as_deref() + || kept.message.id != message.id + }); + if config.private.deferred_inbound.len() != before { + save.mark_dirty(); + } + } + let openvtc_core::inbound::InboundEffects { + inactivated, + capability_replies, + git_ns_replies, + rooms_replies, + elections_replies, + personhood_challenges, + vetting_answers, + vetting_grant_checks, + renewals, + witness_replies, + notifications, + deferred, + } = effects; + // Notifications for our personas, to the member's VTA as received, + // and the sender answered (VTI-NTF-054). + for mut received in notifications { + received.over_tsp = transport == openvtc_core::didcomm::MessagingTransport::Tsp; + notification_actions::hand_over(config, tdk, admin_vta.as_ref(), received) + .await; + } + // A witness's answers, to the witnessing waiting on their thread. + for reply in witness_replies { + let thread = reply + .document + .get("threadId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if witness_router.deliver(&reply.sender, reply.document) { + debug!(%thread, "a witness's answer, handed to its witnessing"); + } + } + if returning_credential { + state + .main_page + .content_panel + .communities + .verifying + .remove(&msg_from); + state.main_page.sync_from_config(config); + } + // Set aside for its check: the membership shows + // "verifying…" until the result comes back. + if let Some(deferred) = deferred { + let _ = queue_for_check( + verifier, + config, + state, + deferred, + transport, + authenticated.as_deref(), + ); + save.mark_dirty(); + } + // A check finished, so there may be room for a + // message kept from before the restart. + if returning + && !restore_backlog.is_empty() + && restore_kept( + verifier, + restore_backlog, + config, + state, + tdk, + didcomm_service, + seen_messages, + pending_replies, + ) + .await + { + save.mark_dirty(); + } + + // A live challenge is display state, not account + // state: single-use, ten-minute life, and worthless + // after a restart. It is folded in here rather than + // persisted, and unconditionally — a reply that + // arrived is a reply the member should see whether + // or not the message also changed the config. + for reply in personhood_challenges { + // The challenge belongs to the persona it was + // addressed to. Without one we cannot sign for + // it, so there is nothing to offer the member — + // and silently showing an unanswerable code + // would be worse than saying nothing. + let Some(persona) = config.account.persona_id_for_did(&msg_to) else { + warn!( + vtc = %msg_from, + to = %msg_to, + "personhood challenge addressed to a DID this account \ + holds no persona for — ignoring", + ); + continue; + }; + state + .main_page + .content_panel + .communities + .personhood_challenge = Some( + crate::state_handler::main_page::content::PersonhoodChallengeView { + vtc_did: msg_from.clone(), + persona, + challenge_id: reply.challenge_id, + match_code: reply.match_code.clone(), + expires_at: reply.expires_at, + }, + ); + state.main_page.content_panel.communities.status_message = + Some(Status::info(format!( + "Personhood challenge received — confirm the code {} with \ + whoever is vetting you, then assert.", + reply.match_code + ))); + } + + // How a renewal the member asked for ended: on the + // Communities status line, where they asked, and in + // the activity log with the community named. + for (vtc, outcome) in renewals { + let line = outcome.describe(); + match &outcome { + openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { + state.main_page.log(&line) + } + _ => state + .main_page + .log_error("Membership renewal", &format!("{vtc}: {line}")), + } + let severity = match &outcome { + openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { + crate::state_handler::main_page::status::Severity::Success + } + _ => crate::state_handler::main_page::status::Severity::Error, + }; + state.main_page.content_panel.communities.status_message = + Some(Status::new(severity, line)); + } + + match dispatched { + Ok(true) => { + // R11: a config-mutating inbound message used + // to save inline here — the per-message cost + // that turned a mediator redelivery burst into + // N sequential keyring+file+card writes. Now we + // mark dirty (coalesced + offloaded); the UI + // sync stays immediate. + save.mark_dirty(); + // R-S-3: a community resolved to an inactive + // status (e.g. a rejection) — tear down its + // live session so a dead community stops + // holding a mediator connection. + for (vtc, persona) in &inactivated { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + vtc, + *persona, + ) + .await; + } + state.main_page.sync_from_config(config); + let short_type = openvtc_core::display::task_label(&msg_type); + state.main_page.log_detailed( + format!("Inbound {transport}: {short_type}"), + format!( + "Inbound {transport} Message\n\ + ───────────────────────\n\ + Type: {msg_type}\n\ + From: {msg_from}\n\ + To: {msg_to}\n\ + thid: {msg_thid}", + ), + ); + } + Ok(false) => {} + Err(e) => { + state.main_page.log_detailed( + format!("Message error: {e}"), + format!( + "Failed Inbound Message\n\ + ──────────────────────\n\ + Type: {msg_type}\n\ + From: {msg_from}\n\ + To: {msg_to}\n\ + thid: {msg_thid}\n\ + Error: {e}", + ), + ); + debug!("message dispatch error: {e}"); + } + } + apply_capability_replies(state, capability_replies); + // git-ns answers for the Repos panel; a change that + // landed is followed by a fresh read of the view. + if !git_ns_replies.is_empty() + && repos_actions::apply_replies(state, config, git_ns_replies) + { + repos_actions::refresh( + &mut repos_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }, + true, + ) + .await; + } + // The answer names the repositories to look for + // on this machine. + repos_workspace::probe_if_due(state, dispatch_tx, in_flight); + // Data-room answers for the Rooms panel; each may call for the + // next step of a refresh or an acceptance. + if !rooms_replies.is_empty() + && rooms_actions::apply_replies(state, pending_replies, rooms_replies) + { + rooms_actions::advance(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + } + // Election answers for the Elections panel; each may call for + // the next step of a ticket or a vote. + if !elections_replies.is_empty() + && elections_actions::apply_replies(state, pending_replies, elections_replies) + { + elections_actions::advance(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + } + // A vetter's grant is checked for revocation off the + // loop: the check fetches the community's status list. + for check in vetting_grant_checks { + vetting_actions::spawn_grant_check(dispatch_tx, tdk, check); + } + // The manifest or challenge a waiting join asked for + // resumes it; every other answer goes to the Vetting page. + let mut answers = Vec::with_capacity(vetting_answers.len()); + for answer in vetting_answers { + if let Some(entry) = awaiting_requirements + .as_ref() + .and_then(|a| join_flow::resume_for(a, &answer)) + { + *awaiting_requirements = None; + *join_entry = Some(entry); + } else if let Some(entry) = awaiting_challenge + .as_ref() + .and_then(|a| join_flow::challenge_resume_for(a, &answer)) + { + *awaiting_challenge = None; + *join_entry = Some(entry); + } else if let Some(step) = awaiting_wallet + .as_mut() + .and_then(|a| join_flow::wallet_answer(a, &answer, state)) + { + if let Some(entry) = step { + *awaiting_wallet = None; + *join_entry = Some(entry); + } + } else { + answers.push(answer); + } + } + vetting_actions::apply_answers(state, config, answers); + // A manifest in is a fresh reading of how its community vets: say a + // switch, and decide a ticket that was waiting on it. + vetting_actions::settle_pending_tickets(state, config, save, chrono::Utc::now()); + } + didcomm::DIDCommEvent::TrustPingReceived { + from, + listener_id, + message_id, + } => { + // `from` is the sender the transport authenticated + // (never the plaintext header); with none there is + // nobody to answer. + let sender = from.as_deref().unwrap_or("unknown"); + let sender_arc = std::sync::Arc::new(sender.to_string()); + + // Only respond to pings from the mediator or established relationships + let is_mediator = from.is_some() && sender == config.mediator_did(); + let has_relationship = from.is_some() + && config + .private + .relationships + .find_by_remote_did(&sender_arc) + .map(|r| { + r.state == openvtc_core::relationships::RelationshipState::Established + }) + .unwrap_or(false); + + if is_mediator || has_relationship { + // Send pong to the authenticated sender, setting `from` to our + // listener's DID so the recipient can identify us. + let our_listener_did = didcomm_service + .listener_did(&listener_id) + .await + .unwrap_or_else(|| config.persona_did().to_string()); + if let Ok(pong_msg) = build_trust_pong(&our_listener_did, sender, &message_id) + && let Err(e) = didcomm::send_message_via( + didcomm_service, + &pong_msg, + &listener_id, + sender, + ) + .await + { + state.main_page.log_error("Failed to send pong", &e); + } + let ping_display = resolve_did_to_display(config, sender); + state.main_page.log_detailed( + format!("Ping from {ping_display} — pong sent"), + format!( + "Trust-Ping Received\n\ + ───────────────────\n\ + From (display): {ping_display}\n\ + From (DID): {sender}\n\ + Listener: {listener_id}\n\ + Response: pong sent", + ), + ); + } else { + state.main_page.log_detailed( + format!( + "Ping from {} — ignored", + resolve_did_to_display(config, sender) + ), + format!( + "Trust-Ping Rejected\n\ + ───────────────────\n\ + From (DID): {sender}\n\ + Reason: {}", + if from.is_some() { + "no established relationship" + } else { + "the transport authenticated no sender" + }, + ), + ); + } + } + didcomm::DIDCommEvent::TrustPongReceived { from } => { + debug!(from = ?from, "TrustPongReceived event"); + let sender_did = from.as_deref().unwrap_or(""); + // `from` is the sender the transport authenticated. + // A pong with none is logged as such rather than + // credited to whoever we pinged last: it closes no + // ping task either (see the dispatcher). + let sender_display = if sender_did.is_empty() { + "an unauthenticated sender".to_string() + } else { + resolve_did_to_display(config, sender_did) + }; + let ms = ping_sent_at + .take() + .map(|sent_at| sent_at.elapsed().as_millis()); + let latency_str = ms.map(|v| format!(" ({v}ms)")).unwrap_or_default(); + // The answer belongs where the ping was sent, not only + // in the log (#584). + if !sender_did.is_empty() { + state.main_page.content_panel.relationships.status_message = + Some(Status::success(format!( + "Pong from {sender_display}{latency_str} — they are reachable." + ))); + } + state.main_page.log_detailed( + format!("Pong from {sender_display}{latency_str}"), + format!( + "Trust-Pong Received\n\ + ───────────────────\n\ + From (display): {sender_display}\n\ + From (DID): {sender_did}\n\ + Latency: {}", + ms.map(|v| format!("{v}ms")).unwrap_or_else(|| "n/a".into()), + ), + ); + } + } + } +} diff --git a/openvtc/src/state_handler/runtime/mod.rs b/openvtc/src/state_handler/runtime/mod.rs new file mode 100644 index 00000000..78a95ea4 --- /dev/null +++ b/openvtc/src/state_handler/runtime/mod.rs @@ -0,0 +1,866 @@ +//! The runtime loop (#528). +//! +//! `StateHandler::main_loop` used to be one ~2,500-line function: setup, then +//! a `select!` whose 21 arms read and wrote some thirty loop-local variables. +//! None of it could be called from a test. Those locals are now the fields of +//! [`Runtime`], each `select!` arm is one method on it, and [`Runtime::run`] is +//! a router: it waits for the next event, hands it to its method, and runs the +//! end-of-iteration work every arm used to fall through to. +//! +//! The arm methods live next to what they handle: +//! +//! | File | Arms | +//! |------|------| +//! | [`arms`] | actions, dispatch outcomes, presence, the admin-session supervisor, lifecycle/listener events, session reconcile, TSP store, saves, VTA sync | +//! | [`inbound_arm`] | inbound DIDComm events | +//! | [`timers`] | the periodic sweeps (capabilities/reply windows, pending expiry, join status, hidden vetting, community profiles, agent names) | +//! +//! Each method takes `&mut self` and destructures the fields it uses, so its +//! body reads as it did when the fields were locals, and the borrow checker +//! still sees disjoint borrows. +//! +//! The single-mutator invariant is unchanged: only these methods touch +//! `state` and `config`, and they all run on the loop's task. + +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; +mod arms; +mod inbound_arm; +#[cfg(test)] +mod tests; +mod timers; + +use std::collections::{HashSet, VecDeque}; + +use affinidi_tdk::TDK; +use anyhow::Result; +use openvtc_core::config::Config; +use tokio::sync::{ + broadcast, + mpsc::{self, UnboundedReceiver, UnboundedSender}, +}; +use tracing::{debug, warn}; + +use crate::{ + Interrupted, Terminator, + state_handler::{ + StateHandler, actions::Action, background_dispatch, community_profile_poll, + device_presence, didcomm, hidden_vetting_poll, join_flow, runtime_actions, save_coalesce, + session_manager, state, state::State, verify_queue, vta_recovery, vta_sync_driver, + }, +}; + +/// What an arm asks the loop to do next. +#[derive(Debug)] +pub(super) enum Flow { + /// Carry on: run the end-of-iteration work and wait for the next event. + Continue, + /// The arm provably changed nothing: skip the end-of-iteration work, + /// including the state broadcast (#556). Only for arms that touched + /// neither `state` nor `config` — there is no dirty tracking, so an arm + /// that might have changed something returns [`Flow::Continue`]. + Quiet, + /// Stop the loop with this outcome. The arm has already told the + /// terminator when that is its job (an `Exit` action does; an interrupt, + /// which came *from* the terminator, does not). + Exit(Interrupted), +} + +/// What `main_loop` brought up before the runtime can be assembled: the loaded +/// account, the admin VTA session and its supervisor, and the (possibly still +/// listener-less) messaging runtime. +/// +/// Built once, before the State-A branch, and carried across it: a State-A +/// join hands these back rather than restarting. +pub(super) struct Startup { + pub(super) handler: StateHandler, + pub(super) terminator: Terminator, + pub(super) action_rx: UnboundedReceiver, + pub(super) interrupt_rx: broadcast::Receiver, + pub(super) state: State, + pub(super) tdk: TDK, + pub(super) config: Box, + pub(super) admin_vta: Option, + pub(super) presence_rx: UnboundedReceiver, + pub(super) vta_supervisor: vta_recovery::AdminVtaSupervisor, + pub(super) didcomm_event_rx: UnboundedReceiver, + pub(super) shutdown_token: tokio_util::sync::CancellationToken, + pub(super) didcomm_service: didcomm::Messaging, + pub(super) tsp_dirty: std::sync::Arc, +} + +/// The runtime loop's world: everything its arms read and write. +/// +/// Field names match the loop locals they replaced, so the arm bodies did not +/// have to change to move. +pub(crate) struct Runtime { + // ── Owner, UI and lifecycle channels ── + pub(super) handler: StateHandler, + pub(super) terminator: Terminator, + pub(super) action_rx: UnboundedReceiver, + pub(super) interrupt_rx: broadcast::Receiver, + + // ── The single-mutator state ── + pub(super) state: State, + pub(super) config: Box, + + // ── Services ── + pub(super) tdk: TDK, + pub(super) admin_vta: Option, + pub(super) vta_supervisor: vta_recovery::AdminVtaSupervisor, + pub(super) presence_rx: UnboundedReceiver, + pub(super) didcomm_service: didcomm::Messaging, + pub(super) shutdown_token: tokio_util::sync::CancellationToken, + pub(super) session_manager: session_manager::SessionManager, + pub(super) didcomm_event_rx: UnboundedReceiver, + pub(super) listener_events: broadcast::Receiver, + pub(super) lifecycle_log_rx: UnboundedReceiver, + pub(super) tsp_dirty: std::sync::Arc, + /// Held so the listener install and the lifecycle logger are not detached + /// anonymously; neither is awaited. + _listener_install: tokio::task::JoinHandle<()>, + _lifecycle_handle: tokio::task::JoinHandle<()>, + + // ── Inbound ── + pub(super) seen_messages: openvtc_core::messaging::SeenMessages, + /// Replies awaited by thread — a renewal's, a community profile's, an + /// election answer from a community we hold no membership with. The + /// inbound gate admits those only on a thread recorded here. Owned by + /// this loop alone: send paths record into it on this thread before + /// spawning, and inbound dispatch consumes from it. + pub(super) pending_replies: openvtc_core::pending_replies::PendingReplies, + pub(super) verifier: verify_queue::Verifier, + pub(super) restore_backlog: VecDeque, + pub(super) witness_router: openvtc_core::witness::ReplyRouter, + pub(super) ping_sent_at: Option, + + // ── Background dispatch (R13) ── + pub(super) dispatch_tx: UnboundedSender, + pub(super) dispatch_rx: UnboundedReceiver, + pub(super) in_flight: background_dispatch::InFlight, + pub(super) contexts_checked: HashSet, + pub(super) grants_swept: bool, + + // ── Coalesced persistence (R11) ── + pub(super) save: save_coalesce::SaveScheduler, + pub(super) save_done_tx: UnboundedSender>, + pub(super) save_done_rx: UnboundedReceiver>, + + // ── VTA account sync ── + pub(super) vta_sync: vta_sync_driver::SyncDriver, + pub(super) vta_sync_tick: tokio::time::Interval, + pub(super) vta_sync_tx: UnboundedSender, + pub(super) vta_sync_rx: UnboundedReceiver, + + // ── Timers and their pacers ── + pub(super) pending_expiry_tick: tokio::time::Interval, + pub(super) capabilities_sweep: tokio::time::Interval, + pub(super) session_reconcile_tick: tokio::time::Interval, + pub(super) agent_name_tick: tokio::time::Interval, + pub(super) join_status_tick: tokio::time::Interval, + pub(super) join_status_pacer: openvtc_core::join::status_poll::PollPacer, + pub(super) community_profile_tick: tokio::time::Interval, + pub(super) community_profile_pacer: community_profile_poll::ProfilePacer, + pub(super) hidden_vetting_tick: tokio::time::Interval, + pub(super) hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer, + + // ── The join flow ── + /// The open join, if any (#529): a state machine this loop drives — its + /// actions, its off-loop jobs' outcomes — rather than a loop of its own + /// that would park this one. + pub(super) join: join_flow::JoinDriver, + /// The join flow to enter at the end of this iteration. + pub(super) join_entry: Option, + /// Messages from a community whose join request is being sent, held + /// until the request is recorded (see [`Runtime::on_inbound`]). + pub(super) held_inbound: Vec<( + didcomm::DIDCommEvent, + Option, + )>, + /// A join waiting on a community's requirements (see `join_flow::AwaitingRequirements`). + pub(super) awaiting_requirements: Option, + /// A join parked on a hidden-vetting challenge (see + /// `join_flow::AwaitingChallenge`) — the same hand-off, for the question + /// a launch asks rather than the one the vetting page does. + pub(super) awaiting_challenge: Option, + /// A join parked on a wallet session (see `join_flow::AwaitingWallet`). + pub(super) awaiting_wallet: Option, +} + +impl Runtime { + /// Bring the runtime up on what `main_loop` started: install this account's + /// listeners, register its sessions, seed the connection indicator, and + /// build the loop's timers, pacers and channels. + pub(super) async fn assemble(startup: Startup) -> Runtime { + let Startup { + handler, + terminator, + action_rx, + interrupt_rx, + mut state, + tdk, + mut config, + admin_vta, + presence_rx, + vta_supervisor, + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + } = startup; + + // Point the runtime active identity at the default working community and + // refilter the community-scoped panels before the first paint (D10 / + // R-C-6) — otherwise a multi-persona account renders empty panels until + // the first event re-syncs (the per-path syncs above run with no + // selection set yet). + state.reconcile_selected_community_among( + &config.account, + Some(&config.identities.keys().copied().collect()), + ); + let initial_active = state + .selected_community + .as_ref() + .map(|(_, persona)| *persona); + config.set_active_persona(initial_active); + state.main_page.sync_from_config(&config); + + // Send initial state immediately so the UI renders without blocking. + // Connecting only when there is a community session to connect: with no + // live membership there is none, and "Connecting..." would sit on the + // screen until the seed below corrected it — or read as a hang. + state.connection.status = if config.account.memberships().any(|m| m.is_live()) { + state::MediatorStatus::Connecting + } else { + state::MediatorStatus::NoActiveCommunity + }; + let _ = handler.state_tx.publish(&state); + + // Install this account's listeners on the runtime started above. Skips + // any that already exist, so a State-A join's own persona listener is + // bound to rather than duplicated (one websocket per DID). + // + // The specs are built here because that needs `Config`, which is not + // `Clone` and cannot cross into a task. Bringing the listeners *up* is + // the expensive half — a mediator auth handshake and a websocket connect + // each, in series — and that half runs off this thread. This is the only + // thread that services UI actions, and the loading screen has already + // been told to offer "Press Enter to continue": awaiting the connects + // here meant the keypress sat unread in the action channel until the last + // socket was up, and the main page then arrived in one late jump. + // Outbound TSP sends (`tsp::send_trust_task`) run on `tdk.atm`; inbound + // frames are unsealed by listener ATMs. Both must record relationships in + // the one durable store, or the invite we send is `Pending` where no + // listener looks and the peer's accept and replies are dropped (VTI-61). + let tdk = match didcomm_service.tsp_store().adopt_into(tdk.clone()).await { + Ok(adopted) => adopted, + Err(e) => { + warn!(error = %e, "could not share the TSP relationship store with the sending ATM"); + tdk + } + }; + + let listener_specs = didcomm::build_listener_configs(&config, &tdk).await; + let listener_install = + didcomm::spawn_install_listeners(didcomm_service.clone(), listener_specs); + + // Process-lifetime LRU of inbound message IDs. Backstop for replay + // and mediator-pickup duplicates beyond what the TDK already filters. + let mut seen_messages = openvtc_core::messaging::SeenMessages::new(); + // Replies awaited by thread (see the `pending_replies` field). + let mut pending_replies = openvtc_core::pending_replies::PendingReplies::default(); + + // Forward lifecycle events (connect/disconnect/restart) to the activity log + let (lifecycle_log_tx, lifecycle_log_rx) = + mpsc::unbounded_channel::(); + let lifecycle_handle = didcomm::spawn_lifecycle_logger(&didcomm_service, lifecycle_log_tx); + + // Phase 2 (community/persona DIDComm connection) runs ASYNCHRONOUSLY: we + // do NOT block the UI waiting for the listener. The main page is already + // showing (Connecting); the persona connection proceeds in the + // background and the runtime loop below flips the status to Connected the + // moment a `ListenerEvent::Connected` arrives — and back to Connecting on + // a disconnect. Subscribe to typed lifecycle events for that. + let listener_events = didcomm_service.subscribe(); + + // Supervised multi-session manager (D11/D15): one persona-session per + // active community (a reused persona shares one), tracking per-session + // connection status over the listeners `start_service` already launched. + // Messaging runs through it from here on; at N=1 it holds one session and + // behaves identically to the previous single global flag. + let mut session_manager = session_manager::SessionManager::default(); + { + use session_manager::RegisterOutcome; + // NOTE: a mid-session leave/reject/expire does NOT yet deregister its + // session (that is T6/T7), so until then `any_connected()` can read + // live for a persona whose communities have all gone inactive. Startup + // registers exactly the communities that require a live session at load + // time (`IdentityRegistry::sessions`). + let registry = openvtc_core::identity::IdentityRegistry::new(&config.account); + let mut at_capacity = 0usize; + for (persona_id, vtcs) in registry.sessions() { + let Some(ctx) = config.identities.get(&persona_id) else { + // A live community whose persona didn't resolve to an identity + // has no listener either — surfaced here for parity, not silent. + debug!(%persona_id, "live-community persona missing from resolved identities — not tracked"); + continue; + }; + let lid = didcomm::persona_listener_id(&ctx.did); + for vtc in vtcs { + if matches!( + session_manager.register(persona_id, &lid, vtc.clone()), + RegisterOutcome::AtCapacity + ) { + at_capacity += 1; + warn!(%persona_id, vtc = %vtc, "session manager at capacity — community not tracked"); + } + } + } + // No silent caps (D15): tell the user if any community exceeded the bound. + if at_capacity > 0 { + state.main_page.log(format!( + "Warning: {at_capacity} communit{} exceeded the session limit ({}) and are not actively connected.", + if at_capacity == 1 { "y" } else { "ies" }, + session_manager.max_sessions(), + )); + } + } + + // Seed the indicator from the messaging layer's *live* state rather than + // assuming nothing is up yet. `ListenerStatus` is edge-triggered, and a + // State-A join has already brought its persona listener up and connected + // by the time this loop subscribes — the install step above binds to that + // socket instead of reinstalling it, so no second `Connected` edge is + // coming. Without this seed the session sits `Connecting` for the life of + // the process over a perfectly live socket, which is the "Inbox: + // Connecting…" that only a restart appeared to fix. + // + // Applied unconditionally rather than only when the reconcile changed + // something: an account with no community session (an applicant whose + // join is still being vetted) has nothing to change, and must still + // land on `NoActiveCommunity` rather than the `Connecting` shown while + // the page came up — no session will ever send the edge that moves it. + super::reconcile_sessions(&mut session_manager, &didcomm_service, &mut state); + super::apply_session_aggregate(&session_manager, &mut state); + match state.connection.status { + state::MediatorStatus::Connected => state.main_page.log("Connected to the mediator."), + state::MediatorStatus::NoActiveCommunity => state + .main_page + .log("No community membership yet — no community session to connect."), + _ => state.main_page.log("Connecting to the mediator…"), + } + let _ = handler.state_tx.publish(&state); + + // Background-dispatch plumbing (R13). Network-bound actions whose await + // would otherwise park the whole select loop are spawned as background + // tasks; they do I/O only and send their result back as a + // `DispatchOutcome` on this channel. The select arm below applies the + // outcome on the loop thread (the single mutator), keeping the loop live + // during the wait. `in_flight` rejects a second action on a busy domain. + let (dispatch_tx, dispatch_rx) = + mpsc::unbounded_channel::(); + + // Coalesced + offloaded config persistence (R11). Mutation sites mark the + // config dirty on the loop thread instead of saving inline; the + // `deadline` arm below debounces a burst into a single `spawn_blocking` + // save, with at most one in flight at a time. Durability-critical points + // (Exit, passphrase/protection change, export) force-flush synchronously. + let mut save = save_coalesce::SaveScheduler::new(handler.profile.clone()); + // Channel carrying a completed background save's success flag, so the loop + // can clear the in-flight flag and re-arm if the config was dirtied again + // while the save ran. Save failures are surfaced as a status/log here, + // matching the pre-R11 inline `save_config` failure handling. + let (save_done_tx, save_done_rx) = mpsc::unbounded_channel::>(); + + // Account-state sync with the VTA (`openvtc_core::vta_sync`). The load + // already ran one; report it, and save whatever it changed. From here + // a cycle runs after each save and on a timer, off the loop: the plan + // is taken here, the network half is spawned, and its result is merged + // back here — the loop stays the only thing that mutates `config`. + if let Some(report) = config.private.vta_sync.load_report.take() { + for line in report.log_lines() { + state.main_page.log(line); + } + if report.touched() { + save.mark_dirty(); + } + } + let vta_sync = vta_sync_driver::SyncDriver::new(tokio::time::Instant::now()); + let mut vta_sync_tick = tokio::time::interval(vta_sync_driver::TICK); + vta_sync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let (vta_sync_tx, vta_sync_rx) = + mpsc::unbounded_channel::(); + + // Network-bound checks on inbound messages (a DID resolve, a status + // list fetch) run off this loop, one at a time, so a slow community + // never freezes the UI. A message waiting on its check is not applied + // at all; it comes back from `verifier` with the result and is + // dispatched from the top. The queue is bounded, with a lane for the + // communities we belong to and a cap per sender (`verify_queue`). + let mut verifier = verify_queue::Verifier::new(tdk.clone()); + // Messages from communities that were still queued at the last exit. + // The mediator deleted them on delivery, so their kept copies are the + // only ones. They are queued again as room allows; the rest wait (kept) + // until earlier checks finish, and none is dropped for want of room. + let mut restore_backlog: VecDeque<_> = + config.private.deferred_inbound.iter().cloned().collect(); + if super::restore_kept( + &mut verifier, + &mut restore_backlog, + &mut config, + &mut state, + &tdk, + &didcomm_service, + &mut seen_messages, + &mut pending_replies, + ) + .await + { + save.mark_dirty(); + } + + let mut hidden_vetting_tick = tokio::time::interval(std::time::Duration::from_secs(30)); + hidden_vetting_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + Runtime { + handler, + terminator, + action_rx, + interrupt_rx, + state, + config, + tdk, + admin_vta, + vta_supervisor, + presence_rx, + didcomm_service, + shutdown_token, + session_manager, + didcomm_event_rx, + listener_events, + lifecycle_log_rx, + tsp_dirty, + _listener_install: listener_install, + _lifecycle_handle: lifecycle_handle, + seen_messages, + pending_replies, + verifier, + restore_backlog, + // Hands a witness's answers from the inbound arm to the witnessing + // waiting on them (`witness_actions`); the job verifies each one + // before reading it. + witness_router: openvtc_core::witness::ReplyRouter::default(), + // Track when a manual trust-ping was sent (for activity log latency + // display). + ping_sent_at: None, + dispatch_tx, + dispatch_rx, + in_flight: background_dispatch::InFlight::default(), + // Membership contexts already checked at the VTA this run. A failure + // is logged and retried on the next launch rather than every tick. + contexts_checked: HashSet::new(), + // Whether this run has read the membership contexts' device grants + // for the communities panel. Once a run; the device-access view + // re-reads on demand. + grants_swept: false, + save, + save_done_tx, + save_done_rx, + vta_sync, + vta_sync_tick, + vta_sync_tx, + vta_sync_rx, + // 7-day Pending timeout sweep (R-B-7 / D16). `interval`'s first tick + // fires immediately, so a Pending that aged out while the app was + // closed expires on launch; thereafter it sweeps hourly. + pending_expiry_tick: tokio::time::interval(std::time::Duration::from_secs(3600)), + // Reply-window sweep for the capabilities view (cheap no-op when the + // view is closed or idle). + capabilities_sweep: tokio::time::interval(std::time::Duration::from_secs(5)), + // Backstop for the edge-triggered listener-status stream. A + // transition that reaches nobody — the broadcast receiver lagging, a + // listener adopted after its connect — would otherwise latch a + // session's status (and the indicator) wrong until the next real + // edge, which for a healthy socket may never come. Cheap: a read of + // the transports' live state, no I/O and no allocation beyond the + // listener ids. + session_reconcile_tick: tokio::time::interval(std::time::Duration::from_secs(15)), + // Agent-name refresh sweep. The first tick fires immediately so + // names resolve shortly after launch; thereafter it picks up + // newly-added DIDs (a fresh relationship/contact) and re-verifies + // stale entries every few minutes. Cheap when there is nothing to do + // — it only spawns a job when `agent_name_refresh_targets` finds + // uncached/stale DIDs, and the resolver caches keep repeat sweeps + // quiet. + agent_name_tick: tokio::time::interval(std::time::Duration::from_secs(300)), + // Reconcile Pending joins with their communities. The first tick + // fires immediately, which is the point: a join still Pending at + // launch is the one whose answer may have been lost, and asking is + // how we find out rather than waiting to be told again. Per-record + // backoff lives in the pacer, so a minute-by-minute tick does not + // mean a minute-by-minute poll; ticks over an account with no + // Pending join do no work at all. + join_status_tick: tokio::time::interval(std::time::Duration::from_secs(60)), + join_status_pacer: openvtc_core::join::status_poll::PollPacer::default(), + // Ask each Active community, once per session, for its declared + // `relationshipIdentifierDefault` (issue #241). The pacer asks each + // community once, so this tick over communities already asked does + // no work; a bare account (no Active membership) does none at all. + community_profile_tick: tokio::time::interval(std::time::Duration::from_secs(60)), + community_profile_pacer: community_profile_poll::ProfilePacer::default(), + // The vetter side of hidden vetting: re-read each community we vet + // for, enrol, and draw the token drip — on the communities' tick + // windows and at least hourly, never on demand + // (`hidden_vetting_poll`). The interval only checks the pacer; the + // first check is at launch, which is when a client that was off + // catches up the ticks it missed. + hidden_vetting_tick, + hidden_vetting_pacer: hidden_vetting_poll::HiddenVettingPacer::default(), + join: join_flow::JoinDriver::new(true), + join_entry: None, + held_inbound: Vec::new(), + awaiting_requirements: None, + awaiting_challenge: None, + awaiting_wallet: None, + } + } + + /// Run the loop until an arm asks it to stop, then shut down. + pub(super) async fn run(mut self) -> Result { + // did-git-sign's install is read off the loop (#555), once now and + // again when a view that shows it opens. + crate::state_handler::settings_actions::refresh_did_git_sign( + &mut self.state, + &mut self.in_flight, + &self.dispatch_tx, + ); + let result = loop { + let flow = tokio::select! { + Some(action) = self.join.next_action(&mut self.action_rx) => { + let flow = self.on_action(action).await; + // A view that shows did-git-sign may just have opened: no + // I/O here, only whether a refresh is due. + crate::state_handler::settings_actions::maybe_refresh_did_git_sign( + &mut self.state, + &mut self.in_flight, + &self.dispatch_tx, + ); + flow + } + // DIDComm inbound message events — as they arrive, or handed + // back with the result of their off-loop check. + Some((event, pre)) = super::next_inbound(&mut self.didcomm_event_rx, &mut self.verifier) => { + self.on_inbound(event, pre).await; + Flow::Continue + } + Some(outcome) = self.dispatch_rx.recv() => { + self.on_dispatch_outcome(outcome).await + } + Some(report) = self.presence_rx.recv() => { + self.on_presence(report).await; + Flow::Continue + } + _ = self.vta_supervisor.tick.tick() => { + self.on_vta_health_tick().await; + Flow::Continue + } + Some(outcome) = self.vta_supervisor.done_rx.recv() => { + self.on_vta_rebuilt(outcome).await; + Flow::Continue + } + Some(event) = self.lifecycle_log_rx.recv() => { + self.on_lifecycle_log(event).await; + Flow::Continue + } + ev = self.listener_events.recv() => { + self.on_listener_event(ev).await; + Flow::Continue + } + _ = self.session_reconcile_tick.tick() => { + if self.on_session_reconcile().await { + Flow::Continue + } else { + Flow::Quiet + } + } + _ = self.capabilities_sweep.tick() => { + self.on_capabilities_sweep().await; + Flow::Continue + } + _ = self.pending_expiry_tick.tick() => { + self.on_pending_expiry().await; + Flow::Continue + } + _ = self.join_status_tick.tick() => { + self.on_join_status_tick().await; + Flow::Continue + } + _ = self.hidden_vetting_tick.tick() => { + self.on_hidden_vetting_tick().await; + Flow::Continue + } + _ = self.community_profile_tick.tick() => { + self.on_community_profile_tick().await; + Flow::Continue + } + _ = self.agent_name_tick.tick() => { + self.on_agent_name_tick().await; + Flow::Continue + } + _ = self.tsp_dirty.notified() => { + self.on_tsp_dirty().await; + Flow::Continue + } + _ = self.save.wait_deadline() => { + self.on_save_deadline().await; + Flow::Continue + } + Some(result) = self.save_done_rx.recv() => { + self.on_save_done(result).await; + Flow::Continue + } + // Every two seconds, and almost always with nothing to start. + _ = self.vta_sync_tick.tick() => { + // Always republished: the UI redraws only on keys or new + // state, and this 2 s tick is what keeps "now"-based + // displays (pending-join liveness, election and receive + // countdowns) moving, as on the old loop. + self.on_vta_sync_tick().await; + Flow::Continue + } + Some(ex) = self.vta_sync_rx.recv() => { + self.on_vta_sync_exchange(ex).await; + Flow::Continue + } + // (keepalive removed — WebSocket-level pings handle connectivity) + // Catch and handle interrupt signal to gracefully shutdown + Ok(interrupted) = self.interrupt_rx.recv() => Flow::Exit(interrupted), + }; + match flow { + Flow::Exit(interrupted) => { + // An open join is undone before the loop stops: a launch + // in flight rolls back what it made, as a cancel does. + self.cancel_join().await; + break interrupted; + } + Flow::Quiet => continue, + Flow::Continue => {} + } + if let Some(interrupted) = self.end_of_iteration().await { + break interrupted; + } + }; + self.shutdown().await; + Ok(result) + } + + /// The work every arm falls through to: enter the join flow when an arm + /// asked for it, keep the working-community selection valid, and broadcast + /// the state. `Some` when the join flow ended the application. + async fn end_of_iteration(&mut self) -> Option { + // Enter the join flow: from the Communities panel, or back from waiting + // on a community's requirements. Here rather than in an arm so every + // way in is handled the same. + if let Some(entry) = self.join_entry.take() { + self.awaiting_requirements = None; + self.awaiting_challenge = None; + self.awaiting_wallet = None; + let (join, mut ctx) = self.join_parts(); + let step = join.enter(&mut ctx, entry).await; + if let Some(interrupted) = self.apply_join_step(step).await { + return Some(interrupted); + } + } + // Keep the working-context selection valid against any account change + // this iteration applied (join/leave/status transition) before the UI + // re-renders from the broadcast (D10 / R-C-6), then point the runtime + // active identity at the selected community's persona so all + // identity-derived reads scope to the working community. When the + // working persona actually changes (e.g. the active community left and + // the default shifted), refilter the community-scoped panels so the UI + // reflects the new context this frame. + self.state.reconcile_selected_community_among( + &self.config.account, + Some(&self.config.identities.keys().copied().collect()), + ); + let active_persona = self + .state + .selected_community + .as_ref() + .map(|(_, persona)| *persona); + if active_persona != self.config.active_persona { + self.config.set_active_persona(active_persona); + self.state.main_page.sync_from_config(&self.config); + } + let _ = self.handler.state_tx.publish(&self.state); + None + } + + /// Do what a join step asks of this loop. `Some` when it ends the + /// application. + pub(super) async fn apply_join_step( + &mut self, + step: join_flow::JoinStep, + ) -> Option { + match step { + join_flow::JoinStep::Continue => {} + join_flow::JoinStep::Returned(joined) => { + self.state.active_page = state::ActivePage::Main; + // R-B-5 / D11: bring the new community's session up live so the + // VTC's async receipt is received now, not only after a restart. + if let Some(joined) = joined { + super::register_joined_session( + &mut self.session_manager, + &self.didcomm_service, + &self.tdk, + &self.config, + joined, + &mut self.state, + ) + .await; + // What the new community's persona wears, now rather than at + // the next sweep — until then its row could only say it had + // not been read. + super::dispatch_binding_refresh( + &self.config, + self.admin_vta.as_ref(), + &mut self.in_flight, + &self.dispatch_tx, + ); + } + } + join_flow::JoinStep::AwaitRequirements(awaiting) => { + self.awaiting_requirements = Some(awaiting); + } + join_flow::JoinStep::AwaitChallenge(awaiting) => { + self.awaiting_challenge = Some(awaiting); + } + join_flow::JoinStep::AwaitWallet(awaiting) => { + self.awaiting_wallet = Some(awaiting); + } + // F10 on a join page lands here. Like every other exit it must tell + // the UI to stop: the UI leaves its loop only on an interrupt, so + // breaking without one stopped this loop and left the screen up + // reading keys nobody acted on — the "join page freezes, even F10 + // and Esc" report. + join_flow::JoinStep::Exit(interrupted) => { + if let Err(e) = self.terminator.terminate(interrupted.clone()) { + debug!("Failed to send terminate signal: {e}"); + } + return Some(interrupted); + } + } + None + } + + /// End an open join, undoing a launch in flight — the loop is stopping. + async fn cancel_join(&mut self) { + if !self.join.is_active() { + return; + } + let (join, mut ctx) = self.join_parts(); + join.cancel(&mut ctx).await; + let _ = self.handler.state_tx.publish(&self.state); + } + + /// The join driver, and everything it acts on, borrowed apart. + pub(super) fn join_parts(&mut self) -> (&mut join_flow::JoinDriver, join_flow::JoinCtx<'_>) { + let Runtime { + join, + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + dispatch_tx, + in_flight, + handler, + .. + } = self; + ( + join, + join_flow::JoinCtx { + state, + config, + tdk, + admin_vta: admin_vta.as_ref(), + messaging: Some(didcomm_service), + profile: handler.profile.as_str(), + save, + dispatch_tx, + in_flight, + state_tx: &handler.state_tx, + }, + ) + } + + /// Everything the loop owns, borrowed for an action handler. + pub(super) fn action_ctx(&mut self) -> runtime_actions::ActionCtx<'_> { + runtime_actions::ActionCtx { + state: &mut self.state, + config: &mut self.config, + save: &mut self.save, + in_flight: &mut self.in_flight, + dispatch_tx: &self.dispatch_tx, + tdk: &self.tdk, + admin_vta: self.admin_vta.as_ref(), + didcomm_service: &self.didcomm_service, + session_manager: &mut self.session_manager, + ping_sent_at: &mut self.ping_sent_at, + state_tx: &self.handler.state_tx, + profile: self.handler.profile.as_str(), + witness_router: &self.witness_router, + pending_replies: &mut self.pending_replies, + } + } + + /// Persist what is left to persist and close the services. + async fn shutdown(mut self) { + // R11: if a backgrounded coalesced save was still running when the loop + // broke, wait for it to complete before the force-flush below. A + // `spawn_blocking` task is NOT cancelled when its `JoinHandle` is dropped, + // so that save is still live; running the shutdown save concurrently would + // mean two `Config::save`s racing the same (non-atomic) file + keyring + // writes. Draining the completion channel serialises shutdown after it. + // After `finish`, `needs_flush()` is only still true if the config was + // dirtied *after* the in-flight save's snapshot — exactly what the + // force-flush must persist. + if self.save.in_flight() + && let Some(result) = self.save_done_rx.recv().await + { + self.save.finish(result.is_ok()); + } + + // R11 force-flush: persist the latest state before tearing down, so + // coalescing never loses the final mutation on Exit/interrupt. Runs a + // direct blocking save (the loop has broken; there is no runtime arm left + // to schedule against). `needs_flush` is true when the config is dirty or + // a background save was still in flight when the loop broke. + if self.save.needs_flush() { + match self.save.snapshot_now(&self.config) { + Ok(pending) => { + if let Err(e) = pending.run() { + self.state + .main_page + .log_error("Failed to save config on exit", &e); + } + } + Err(e) => { + self.state + .main_page + .log_error("Failed to snapshot config on exit", &e); + } + } + } + + // Shut down the DIDComm service gracefully + self.shutdown_token.cancel(); + self.didcomm_service.shutdown().await; + + // Close the always-on admin VTA session. + if let Some(c) = self.admin_vta { + c.shutdown().await; + } + } +} diff --git a/openvtc/src/state_handler/runtime/tests.rs b/openvtc/src/state_handler/runtime/tests.rs new file mode 100644 index 00000000..f339884e --- /dev/null +++ b/openvtc/src/state_handler/runtime/tests.rs @@ -0,0 +1,316 @@ +//! A harness for the runtime loop (#528, #537). +//! +//! The loop used to be one function nothing could call. Every piece of the +//! world it needs builds offline — `TDK` without an environment file, +//! `Messaging` with no listeners, an admin session that is simply absent — so +//! the harness assembles a real [`Runtime`] and drives one arm at a time, or +//! the whole loop until it is told to stop. + +use std::time::{Duration, Instant}; + +use tokio::sync::{broadcast, mpsc, watch}; + +use super::*; +use crate::state_handler::{ + StartingMode, + dispatch_util::{test_config, test_tdk}, + main_page::content::{CapabilitiesPhase, CapabilitiesView}, +}; + +/// The ends of the runtime's channels a test plays the other side of. +struct Handles { + action_tx: mpsc::UnboundedSender, + interrupt_tx: broadcast::Sender, + /// Subscribed before the runtime starts, so it hears the terminator. + interrupt_seen: broadcast::Receiver, + state_rx: watch::Receiver>, + _event_tx: mpsc::UnboundedSender, + _presence_tx: mpsc::UnboundedSender, +} + +async fn runtime() -> (Runtime, Handles) { + let (handler, state_rx) = StateHandler::new("runtime-test", StartingMode::NotSet); + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let (interrupt_tx, interrupt_rx) = broadcast::channel(4); + let interrupt_seen = interrupt_tx.subscribe(); + let (event_tx, didcomm_event_rx) = mpsc::unbounded_channel(); + let (presence_tx, presence_rx) = mpsc::unbounded_channel(); + let shutdown_token = tokio_util::sync::CancellationToken::new(); + let didcomm_service = didcomm::start_empty_service(event_tx.clone(), shutdown_token.clone()); + let tsp_dirty = didcomm_service.tsp_store().dirty(); + let runtime = Runtime::assemble(Startup { + handler, + terminator: Terminator::new(interrupt_tx.clone()), + action_rx, + interrupt_rx, + state: State::default(), + tdk: test_tdk().await, + config: Box::new(test_config()), + admin_vta: None, + presence_rx, + vta_supervisor: vta_recovery::AdminVtaSupervisor::new( + None, + None, + openvtc_core::vta_receive_leg::ReceiveLegTracker::new(), + ), + didcomm_event_rx, + shutdown_token, + didcomm_service, + tsp_dirty, + }) + .await; + ( + runtime, + Handles { + action_tx, + interrupt_tx, + interrupt_seen, + state_rx, + _event_tx: event_tx, + _presence_tx: presence_tx, + }, + ) +} + +fn logged(state: &State, needle: &str) -> bool { + state + .main_page + .activity_log + .iter() + .any(|e| e.summary.contains(needle)) +} + +fn ago(secs: u64) -> Instant { + Instant::now() + .checked_sub(Duration::from_secs(secs)) + .expect("the clock is past the test's offset") +} + +#[tokio::test] +async fn an_account_with_no_community_assembles_to_no_active_community() { + let (rt, h) = runtime().await; + assert!(matches!( + rt.state.connection.status, + state::MediatorStatus::NoActiveCommunity + )); + assert!(logged(&rt.state, "No community membership yet")); + // Assembling published what it set up, before any event arrived. + assert!(matches!( + h.state_rx.borrow().connection.status, + state::MediatorStatus::NoActiveCommunity + )); +} + +#[tokio::test] +async fn exit_stops_the_loop_and_tells_the_terminator() { + let (rt, mut h) = runtime().await; + h.action_tx.send(Action::Exit).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on Exit") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::UserInt)); + assert!( + matches!(h.interrupt_seen.try_recv(), Ok(Interrupted::UserInt)), + "the UI is told to stop too, or it keeps reading keys nobody acts on" + ); +} + +#[tokio::test] +async fn an_interrupt_stops_the_loop_with_that_interrupt() { + let (rt, h) = runtime().await; + h.interrupt_tx + .send(Interrupted::OsSignal("SIGINT")) + .unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on an interrupt") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::OsSignal("SIGINT"))); +} + +#[tokio::test] +async fn the_end_of_an_iteration_broadcasts_the_state() { + let (mut rt, mut h) = runtime().await; + h.state_rx.mark_unchanged(); + rt.state.main_page.log("a line only this iteration wrote"); + assert!(rt.end_of_iteration().await.is_none()); + assert!(h.state_rx.has_changed().unwrap()); + assert!(logged( + &h.state_rx.borrow(), + "a line only this iteration wrote" + )); +} + +#[tokio::test] +async fn a_listener_connecting_is_tracked_and_a_drop_untracked() { + let (mut rt, _h) = runtime().await; + rt.on_listener_event(Ok(didcomm::ListenerStatus::Connected { + listener_id: "listener-a".into(), + })) + .await; + assert!(rt.state.connection.online_listeners.contains("listener-a")); + rt.on_listener_event(Ok(didcomm::ListenerStatus::Disconnected { + listener_id: "listener-a".into(), + error: None, + })) + .await; + assert!(!rt.state.connection.online_listeners.contains("listener-a")); +} + +#[tokio::test] +async fn a_lifecycle_event_reaches_the_activity_log() { + let (mut rt, _h) = runtime().await; + let before = rt.state.main_page.activity_log.len(); + rt.on_lifecycle_log(didcomm::LifecycleLog::Connected { + listener_id: "listener-b".into(), + }) + .await; + assert_eq!(rt.state.main_page.activity_log.len(), before + 1); +} + +#[tokio::test] +async fn a_failed_save_is_said_and_left_dirty() { + let (mut rt, _h) = runtime().await; + rt.on_save_done(Err("disk full".into())).await; + assert!(logged(&rt.state, "Failed to save config")); +} + +#[tokio::test] +async fn a_dispatch_outcome_frees_its_domain() { + let (mut rt, _h) = runtime().await; + let domain = background_dispatch::DispatchDomain::AgentName; + assert!(rt.in_flight.try_begin(domain)); + rt.on_dispatch_outcome(background_dispatch::DispatchOutcome::AgentName(Vec::new())) + .await; + assert!(!rt.in_flight.is_busy(domain)); +} + +#[tokio::test] +async fn a_vta_sync_tick_without_a_session_starts_nothing() { + let (mut rt, _h) = runtime().await; + assert!(!rt.on_vta_sync_tick().await, "quiet: nothing started"); + assert!(rt.vta_sync_rx.try_recv().is_err()); +} + +#[tokio::test] +async fn a_pong_is_timed_against_the_ping_and_closes_it() { + let (mut rt, _h) = runtime().await; + rt.ping_sent_at = Some(ago(1)); + rt.on_inbound( + didcomm::DIDCommEvent::TrustPongReceived { + from: Some("did:example:peer".into()), + }, + None, + ) + .await; + assert!(rt.ping_sent_at.is_none()); + let status = rt + .state + .main_page + .content_panel + .relationships + .status_message + .as_ref() + .expect("the pong is reported where the ping was sent"); + assert!(status.text.contains("reachable")); +} + +#[tokio::test] +async fn an_unanswered_ping_runs_out_its_reply_window() { + let (mut rt, _h) = runtime().await; + rt.ping_sent_at = Some(ago(super::super::PING_REPLY_WINDOW.as_secs() + 1)); + rt.on_capabilities_sweep().await; + assert!(rt.ping_sent_at.is_none()); + assert!(logged(&rt.state, "No reply to the ping")); +} + +#[tokio::test] +async fn an_unanswered_capability_query_fails_closed() { + let (mut rt, _h) = runtime().await; + let mut view = CapabilitiesView::new( + "did:example:vtc".into(), + openvtc_core::config::account::PersonaId::new(), + "A community".into(), + ); + view.pending_thid = Some("thid-1".into()); + view.sent_at = Some(ago(31)); + rt.state.main_page.content_panel.capabilities.view = Some(view); + rt.on_capabilities_sweep().await; + let view = rt + .state + .main_page + .content_panel + .capabilities + .view + .as_ref() + .unwrap(); + assert!(view.pending_thid.is_none()); + assert!(matches!(view.phase, CapabilitiesPhase::Failed(_))); +} + +#[tokio::test] +async fn the_pending_expiry_sweep_over_an_empty_account_changes_nothing() { + let (mut rt, _h) = runtime().await; + rt.on_pending_expiry().await; + assert!( + !rt.save.needs_flush(), + "nothing changed, so nothing to save" + ); +} + +/// The join no longer parks the loop (#529): with its screen up, the other +/// arms still run, and its actions are the join's. +#[tokio::test] +async fn an_open_join_leaves_the_other_arms_running() { + let (mut rt, _h) = runtime().await; + assert!(matches!( + rt.on_action(Action::StartJoin).await, + Flow::Continue + )); + assert!(rt.end_of_iteration().await.is_none()); + assert!(rt.join.is_active()); + assert!(matches!(rt.state.active_page, state::ActivePage::Join)); + + let domain = background_dispatch::DispatchDomain::AgentName; + assert!(rt.in_flight.try_begin(domain)); + rt.on_dispatch_outcome(background_dispatch::DispatchOutcome::AgentName(Vec::new())) + .await; + assert!(!rt.in_flight.is_busy(domain), "another arm ran mid-join"); + + assert!(matches!( + rt.on_action(Action::JoinCancel).await, + Flow::Continue + )); + assert!(!rt.join.is_active()); + assert!(matches!(rt.state.active_page, state::ActivePage::Main)); +} + +#[tokio::test] +async fn an_interrupt_with_a_join_open_stops_the_loop() { + let (mut rt, h) = runtime().await; + rt.on_action(Action::StartJoin).await; + rt.end_of_iteration().await; + assert!(rt.join.is_active()); + h.interrupt_tx.send(Interrupted::UserInt).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(10), rt.run()) + .await + .expect("the loop stops on an interrupt with a join open") + .expect("the loop returns its outcome"); + assert!(matches!(outcome, Interrupted::UserInt)); +} + +#[tokio::test] +async fn f10_on_a_join_page_stops_the_loop_and_tells_the_terminator() { + let (mut rt, mut h) = runtime().await; + rt.on_action(Action::StartJoin).await; + rt.end_of_iteration().await; + assert!(matches!( + rt.on_action(Action::Exit).await, + Flow::Exit(Interrupted::UserInt) + )); + assert!(matches!( + h.interrupt_seen.try_recv(), + Ok(Interrupted::UserInt) + )); +} diff --git a/openvtc/src/state_handler/runtime/timers.rs b/openvtc/src/state_handler/runtime/timers.rs new file mode 100644 index 00000000..1ad67315 --- /dev/null +++ b/openvtc/src/state_handler/runtime/timers.rs @@ -0,0 +1,624 @@ +//! The runtime's periodic sweeps. + +use super::*; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; +use crate::state_handler::*; + +impl Runtime { + /// Coalesced-save debounce (R11). Fires when the debounce window + /// since the first dirty mark of a burst elapses. Builds an owned + /// snapshot on this (the single mutator) thread and runs the heavy + /// serialize+encrypt+keyring+card I/O on a blocking thread so the + /// loop stays responsive. At most one save is in flight; a mark + /// that lands while a save runs is re-scheduled on completion. + /// When nothing is scheduled the arm parks forever (no busy-wait). + pub(super) async fn on_capabilities_sweep(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + pending_replies, + ping_sent_at, + join_entry, + awaiting_requirements, + awaiting_challenge, + awaiting_wallet, + handler, + .. + } = self; + // The Repos panel: reply windows, and polling a forge-account + // link no faster than the spec's five seconds. + repos_actions::tick(&mut repos_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + // The Rooms panel: reply windows, and a step left on a busy lane. + rooms_actions::tick(&mut rooms_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + // The Elections panel: the same. + elections_actions::tick(&mut elections_actions::Loop { + state, + config, + tdk, + vta: admin_vta.as_ref(), + dispatch_tx, + in_flight, + pending_replies, + }) + .await; + // Questions to communities have a reply window too (R1.2). + vetting_actions::expire_queries(state, config, chrono::Utc::now()); + // A ticket whose question about its community's mode went unanswered, or + // could not be sent, ends here, saying which. + vetting_actions::settle_pending_tickets(state, config, save, chrono::Utc::now()); + // Hidden-vetting work for the VTA — answers a community or a vetter sent, + // an application that needs its engine — goes as soon as it arises, + // backed off while the VTA cannot be reached (R1.4). + if vetting_actions::pcs_work_due(config, chrono::Utc::now()) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + pending_replies, + }; + vetting_actions::start_pcs_work(&mut ctx, Vec::new()); + } + // A request reached the desk: re-read what its community + // requires now, before a session opens on a stale answer. + if std::mem::take(&mut config.private.vetting.vetter_refresh_due) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + pending_replies, + }; + vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; + } else if vetting_actions::desk_open(state) { + // While the desk is open, how each community vets is kept no older + // than its TTL — asked at most once per re-ask interval (R1.4). + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + pending_replies, + }; + vetting_actions::refresh_stale_modes(&mut ctx).await; + } + if awaiting_requirements + .as_ref() + .is_some_and(|a| a.asked_at.elapsed() >= join_flow::REQUIREMENTS_WAIT) + && let Some(awaiting) = awaiting_requirements.take() + { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Resume { + vtc_did: awaiting.vtc_did, + outcome: join_flow::RequirementsOutcome::Unanswered(format!( + "no answer within {} seconds — its service may be offline", + join_flow::REQUIREMENTS_WAIT.as_secs() + )), + }); + } + // R1.2 / R6.4: a challenge that never comes is the community + // not answering — our side sent the question, so it is not + // our mediator, and nothing refused it. + if awaiting_challenge + .as_ref() + .is_some_and(|a| a.asked_at.elapsed() >= join_flow::CHALLENGE_WAIT) + && let Some(awaiting) = awaiting_challenge.take() + { + config.private.vetting.forget_query(&awaiting.document_id); + *join_entry = Some(join_flow::JoinEntry::Challenge { + awaiting, + outcome: join_flow::ChallengeOutcome::Unanswered(format!( + "the request was sent, but no answer came within {} seconds — \ + its service may be offline or unreachable", + join_flow::CHALLENGE_WAIT.as_secs() + )), + }); + } + // A wallet session is polled on this tick, and given up on + // when the community stops answering (R1.2). + if let Some(awaiting) = awaiting_wallet.as_mut() + && let Some(entry) = + join_flow::wallet_tick(awaiting, config, tdk, Some(didcomm_service)).await + { + *awaiting_wallet = None; + *join_entry = Some(entry); + } + // A ping has a reply window too (#584): past it, say so in + // the panel that sent it and expire the outbound task, so + // the Inbox does not keep a ping nobody will answer. + if ping_sent_at.is_some_and(|t| t.elapsed() > PING_REPLY_WINDOW) { + *ping_sent_at = None; + let cutoff = chrono::Utc::now() + - chrono::Duration::from_std(PING_REPLY_WINDOW).unwrap_or_default(); + let before = config.private.tasks.tasks.len(); + config.private.tasks.tasks.retain(|_, t| { + !(matches!(t.type_, openvtc_core::tasks::TaskType::TrustPing { .. }) + && t.created < cutoff) + }); + if config.private.tasks.tasks.len() != before { + save.mark_dirty(); + state.main_page.sync_from_config(config); + } + let line = format!( + "No reply to the ping after {} s — they may be offline, or not \ + listening on that DID.", + PING_REPLY_WINDOW.as_secs() + ); + state.main_page.content_panel.relationships.status_message = + Some(Status::warning(line.clone())); + state.main_page.log_warning(line); + } + // R4.3: a capability query has a defined reply window. The + // send was fire-and-forget; if no correlated reply arrived, + // fail the view closed with a distinct, actionable message. + if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() + && view.pending_thid.is_some() + && view + .sent_at + .is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(30)) + { + view.pending_thid = None; + view.sent_at = None; + if matches!( + view.phase, + crate::state_handler::main_page::content::CapabilitiesPhase::Loading + ) { + view.phase = crate::state_handler::main_page::content::CapabilitiesPhase::Failed( + "no reply within 30s — the community's governance host may be offline" + .to_string(), + ); + } else { + view.status_message = Some(Status::error( + "no reply to the change within 30s — refresh (r) to re-check".to_string(), + )); + } + } + } + + pub(super) async fn on_pending_expiry(&mut self) { + let Runtime { + state, + config, + didcomm_service, + save, + session_manager, + handler, + .. + } = self; + // The vetting book's own clock: forget cards past their + // retention, move finished desk requests to the record of + // who we vetted, close sessions nobody answered. Fires at + // start-up too, so a card from a previous run does not wait + // an hour to go. + let mut vetting_changed = config.private.vetting.prune(chrono::Utc::now()); + // Applications whose persona this account no longer holds: + // left by a deletion before deletions took them along, and + // unusable — their DID is gone. Judged by the persona + // record, not the loaded identity, which can be missing for + // a run without the persona being gone. + let personas = &config.account.personas; + let applications = &mut config.private.vetting.applications; + let before = applications.len(); + applications.retain(|a| personas.contains_key(&a.persona)); + let orphaned = before - applications.len(); + if orphaned > 0 { + vetting_changed = true; + state.main_page.log(format!( + "Dropped {orphaned} vetting application{} made as an identity that no \ + longer exists.", + if orphaned == 1 { "" } else { "s" } + )); + } + // Applications whose join is done and that hold nothing a + // rejoin could present again (see `retire_joined`). + let members: std::collections::HashSet<(String, openvtc_core::config::account::PersonaId)> = + config + .account + .memberships() + .filter(|m| m.status.is_active()) + .map(|m| (m.vtc_did.clone(), m.persona_ref)) + .collect(); + let retired = config.private.vetting.retire_joined( + |a| members.contains(&(a.community.clone(), a.persona)), + chrono::Utc::now(), + ); + if retired > 0 { + vetting_changed = true; + state.main_page.log(format!( + "Cleared {retired} finished vetting application{} — joined, and its \ + statements have expired.", + if retired == 1 { "" } else { "s" } + )); + } + if vetting_changed { + save.mark_dirty(); + state.main_page.sync_from_config(config); + } + // The vetter side runs on its own schedule — the + // hidden-vetting tick below, at least hourly. + // R-B-7: expire Pending joins unanswered for 7 days — or for + // the `decisionSla` a vetting community publishes — raising + // actions-required, and tear down each one's now-dead session + // (R-S-3). Records are retained read-only (R-S-1). + let vetting = &config.private.vetting; + let default_timeout = + chrono::TimeDelta::days(openvtc_core::config::account::PENDING_TIMEOUT_DAYS); + let timeouts: std::collections::HashMap<_, _> = config + .account + .memberships() + .filter_map(|c| { + vetting + .decision_sla(&c.vtc_did, c.persona_ref) + .map(|sla| ((c.vtc_did.clone(), c.persona_ref), sla)) + }) + .collect(); + let expired = config + .account + .expire_stale_pending_with(chrono::Utc::now(), |c| { + timeouts + .get(&(c.vtc_did.clone(), c.persona_ref)) + .copied() + .unwrap_or(default_timeout) + }); + if !expired.is_empty() { + save.mark_dirty(); + for (vtc, persona) in &expired { + deregister_inactive_community( + session_manager, + didcomm_service, + config, + state, + vtc, + *persona, + ) + .await; + } + state.main_page.sync_from_config(config); + state.main_page.log(format!( + "{} pending join{} expired (no decision within the time allowed).", + expired.len(), + if expired.len() == 1 { "" } else { "s" }, + )); + let _ = handler.state_tx.publish(state); + } + + // The other thing that lapses by the clock: our own `vetter` + // role credential. Nothing arrives to announce it — the first + // sign is an applicant's request refused at their end — so + // this sweep is where it gets noticed. Said once per grant + // per state; see `take_grant_warnings`. + let warnings = config + .private + .vetting + .take_grant_warnings(chrono::Utc::now()); + if !warnings.is_empty() { + for warning in &warnings { + let community = + crate::state_handler::community_label(config, &warning.community, None, 64); + state.main_page.log(if warning.expired { + format!( + "Your vetter credential from {community} expired on {}. \ + Requests to you there are refused until it is reissued — \ + ask for it again from your vetting desk (g).", + warning.valid_until.format("%Y-%m-%d"), + ) + } else { + format!( + "Your vetter credential from {community} expires on {}. \ + Ask for it again from your vetting desk (g).", + warning.valid_until.format("%Y-%m-%d"), + ) + }); + let id = warning.id(); + config.private.tasks.tasks.insert( + id.clone().into(), + openvtc_core::tasks::Task { + id: std::sync::Arc::new(id), + type_: openvtc_core::tasks::TaskType::VetterGrantExpiring { + community: std::sync::Arc::new(community), + expired: warning.expired, + valid_until: warning.valid_until.format("%Y-%m-%d").to_string(), + }, + created: chrono::Utc::now(), + our_persona: Some(warning.persona), + }, + ); + } + save.mark_dirty(); + state.main_page.sync_from_config(config); + let _ = handler.state_tx.publish(state); + } + } + + pub(super) async fn on_join_status_tick(&mut self) { + let Runtime { + state, + config, + tdk, + didcomm_service, + save, + verifier, + restore_backlog, + seen_messages, + pending_replies, + join_status_pacer, + .. + } = self; + // Kept messages still waiting for room (the request rate, + // say) are tried again even if no check is running. + if !restore_backlog.is_empty() + && restore_kept( + verifier, + restore_backlog, + config, + state, + tdk, + didcomm_service, + seen_messages, + pending_replies, + ) + .await + { + save.mark_dirty(); + } + // Ask each community about a join it has not resolved — + // every Pending join, including one that never got any + // reply, which is asked id-less; see `openvtc_core::join::status_poll`. + // + // Spawned detached rather than through `background_dispatch`: + // there is no outcome to apply on the loop thread (the reply + // arrives on the persona's listener and goes through inbound + // dispatch like any other), and the pacer already prevents + // pile-up by marking a record polled before the send. + let candidates = config.account.pollable_pending(chrono::Utc::now()); + if !candidates.is_empty() + && let Some(atm) = tdk.atm.clone() + { + let due = join_status_pacer.due(candidates, std::time::Instant::now()); + let polls = openvtc_core::join::status_poll::build(config, tdk, due).await; + if !polls.is_empty() { + tokio::spawn(openvtc_core::join::status_poll::send_all(atm, polls)); + } + } + } + + pub(super) async fn on_hidden_vetting_tick(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + didcomm_service, + save, + in_flight, + dispatch_tx, + session_manager, + witness_router, + pending_replies, + ping_sent_at, + hidden_vetting_pacer, + handler, + .. + } = self; + let now = chrono::Utc::now(); + // A question left unanswered since the last pass is retried on its + // backoff, not at the next scheduled pass (R1.4). + hidden_vetting_pacer.pull_forward(&config.private.vetting, now); + if hidden_vetting_pacer.due(now) { + let mut ctx = runtime_actions::ActionCtx { + state, + config, + save, + in_flight, + dispatch_tx, + tdk, + admin_vta: admin_vta.as_ref(), + didcomm_service, + session_manager, + ping_sent_at, + state_tx: &handler.state_tx, + profile: handler.profile.as_str(), + witness_router, + pending_replies, + }; + vetting_actions::dispatch(&mut ctx, actions::VettingAction::RefreshVetterSide).await; + let next = hidden_vetting_pacer.schedule( + &config.private.vetting, + chrono::Utc::now(), + &mut rand::thread_rng(), + ); + debug!(%next, "next hidden-vetting pass"); + } + } + + pub(super) async fn on_community_profile_tick(&mut self) { + let Runtime { + config, + tdk, + community_profile_pacer, + pending_replies, + .. + } = self; + // Ask each Active community, once, for its profile so the + // new-relationship form can default to the community's + // declared identifier form (issue #241). Spawned detached, + // like the status poll: the reply arrives on the persona's + // listener and folds in through inbound dispatch, and the + // pacer marks each community asked before the send so a tick + // never re-asks one. + if let Some(atm) = tdk.atm.clone() { + let mut asks = Vec::new(); + for ask in community_profile_pacer.due(config) { + // Local key lookup; an ask whose key cannot be + // read is dropped rather than sent unsigned. + if let Ok(keys) = config.get_persona_keys_for(ask.persona, tdk).await { + asks.push(ask.with_signer(keys.authentication.secret.clone())); + } + } + if !asks.is_empty() { + tokio::spawn(community_profile_poll::send_all(atm, asks, pending_replies)); + } + } + } + + pub(super) async fn on_agent_name_tick(&mut self) { + let Runtime { + state, + config, + tdk, + admin_vta, + in_flight, + dispatch_tx, + contexts_checked, + grants_swept, + .. + } = self; + // Collect DIDs whose agent name is uncached or stale and, if + // any, resolve them in one background job (read-only I/O). + // Results are folded into the persisted cache on the loop + // thread by `apply_outcome`. The busy-guard drops the tick if + // a prior sweep is still running, so ticks never pile up. + let targets = config.agent_name_refresh_targets(chrono::Utc::now()); + if !targets.is_empty() { + let job = agent_name_refresh::AgentNameRefreshJob { + resolver: tdk.did_resolver().clone(), + dids: targets, + }; + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); + } + + // Ask what each persona presents. Shares this tick for the + // same reason as the transport probe — the loop gains no + // extra timer — and re-asks every sweep rather than on a + // TTL, because the answer is the holder's own decision and + // they may have changed it from `pnm` a moment ago. The + // busy-guard keeps a slow agent from stacking sweeps. + dispatch_binding_refresh(config, admin_vta.as_ref(), in_flight, dispatch_tx); + + // Register the contexts memberships name. Joins before + // per-community contexts recorded an id without creating it; + // faces worn there worked only because nothing checked. + let top_context_id = config.account.top_context_id.clone(); + let unchecked: Vec = config + .account + .memberships() + .map(|c| c.sub_context_id.clone()) + .filter(|id| { + openvtc_core::config::community_context::is_sub_context(id, &top_context_id) + && !contexts_checked.contains(id) + }) + .collect::>() + .into_iter() + .collect(); + if !unchecked.is_empty() + && let Some(client) = admin_vta.as_ref() + { + let job = community_context_actions::ContextRegistrationJob { + client: client.clone(), + top_context_id, + contexts: unchecked.clone(), + }; + if background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}) { + contexts_checked.extend(unchecked); + } + } + + // Read each membership context's device grants, once a run, + // after registration has made sure the contexts exist — a + // grant listing for a context not yet created would read as + // a failure. + if !*grants_swept + && !in_flight.is_busy(background_dispatch::DispatchDomain::CommunityContexts) + && let Some(client) = admin_vta.as_ref() + { + let targets = community_context_actions::sweep_targets(config); + if !targets.is_empty() { + let job = community_context_actions::DeviceGrantSweepJob { + client: client.clone(), + contexts: targets, + }; + if background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}) { + *grants_swept = true; + } + } + } + + // Probe what transports the VTA advertises, for the VTA + // panel. Runs on this tick rather than its own so the loop + // gains no extra timer. Re-probed only while the answer is + // still missing or failed — once a VTA has answered, its + // advertised services are stable for the session, so a + // healthy setup probes exactly once per launch (R1.4). + let needs_probe = state + .main_page + .content_panel + .vta + .transports + .advertised + .as_ref() + .is_none_or(|a| a.error.is_some()); + if needs_probe + && let openvtc_core::config::KeyBackend::Vta { vta_did, .. } = &config.key_backend + && !vta_did.is_empty() + { + let job = vta_transports::VtaTransportsProbe { + vta_did: vta_did.clone(), + }; + background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| {}); + } + } +} diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 973429b1..392bb538 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -13,10 +13,13 @@ //! //! Three arms stay in the loop, because they are not about acting on state: //! `Exit` and `UXError` signal the terminator and `break` with the loop's own -//! outcome type, and `StartJoin` drives the join flow's own action loop with the -//! receiver this one is selecting on. +//! outcome type, and `StartJoin` opens the join, which the loop then drives +//! (`join_flow::JoinDriver`): while a join is open every action is the +//! driver's, and never reaches this module. use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use tokio::sync::{mpsc::UnboundedSender, watch}; use crate::state_handler::actions::Action; @@ -57,10 +60,14 @@ pub(crate) struct ActionCtx<'a> { /// state rather than `State`: it is a stopwatch, not something the UI /// renders, and it is read by the inbound arm that stays in the loop. pub(crate) ping_sent_at: &'a mut Option, - pub(crate) state_tx: &'a watch::Sender, + pub(crate) state_tx: &'a watch::Sender>, pub(crate) profile: &'a str, /// Where the inbound arm hands a witness's answers to a witnessing in progress. pub(crate) witness_router: &'a openvtc_core::witness::ReplyRouter, + /// The replies the runtime waits on by thread — the inbound gate admits + /// them only because we asked. Recorded here, on the loop thread, before + /// a send is spawned. + pub(crate) pending_replies: &'a mut openvtc_core::pending_replies::PendingReplies, } /// What the loop must do after an action was handled. @@ -585,6 +592,21 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha Some(Status::progress( "Asking the community to renew your membership…".to_string(), )); + // Waited on from before the send, so a reply quicker than + // the send's return still finds it — and only when the + // job will run: a busy dispatcher sends nothing. + let request_id = uuid::Uuid::new_v4(); + if !ctx + .in_flight + .is_busy(background_dispatch::DispatchDomain::Community) + { + openvtc_core::renewal::record_request( + ctx.pending_replies, + request_id, + &vtc, + &member_did, + ); + } spawn_community_job( ctx.dispatch_tx, ctx.in_flight, @@ -603,6 +625,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha persona: persona_id, verb: community_actions::Verb::Renew { document_signer: Box::new(keys.authentication.secret.clone()), + request_id, }, }, ) @@ -947,7 +970,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha if let Some(overlay) = ctx.state.main_page.create_persona.as_mut() { overlay.copied = copied; } - let _ = ctx.state_tx.send(ctx.state.clone()); + let _ = ctx.state_tx.publish(ctx.state); } } Action::Inbox(ia) => { @@ -1315,8 +1338,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::TokenWriteKeys(..) => {} // Owned by the loop: `Exit` and `UXError` signal the terminator and - // break with its outcome type, and the two `StartJoin` verbs drive the - // join flow's own action loop using the receiver this one selects on. + // break with its outcome type, and the two `StartJoin` verbs open the + // join the loop then drives (`join_flow::JoinDriver`). // Listed so the match stays exhaustive — the guarantee is that a NEW // variant cannot be added without a decision here, and these already // have one. @@ -1510,8 +1533,9 @@ mod tests { didcomm_service: Messaging, session_manager: SessionManager, ping_sent_at: Option, - state_tx: watch::Sender, + state_tx: watch::Sender>, witness_router: openvtc_core::witness::ReplyRouter, + pending_replies: openvtc_core::pending_replies::PendingReplies, } impl Harness { @@ -1522,7 +1546,7 @@ mod tests { /// thing as one that succeeds. async fn new(with_session: bool) -> Self { let (dispatch_tx, dispatch_rx) = mpsc::unbounded_channel(); - let (state_tx, _state_rx) = watch::channel(State::default()); + let (state_tx, _state_rx) = watch::channel(std::sync::Arc::new(State::default())); let (event_tx, _event_rx) = mpsc::unbounded_channel(); Self { state: State::default(), @@ -1542,6 +1566,7 @@ mod tests { ping_sent_at: None, state_tx, witness_router: openvtc_core::witness::ReplyRouter::default(), + pending_replies: Default::default(), } } @@ -1560,6 +1585,7 @@ mod tests { state_tx: &self.state_tx, profile: "test", witness_router: &self.witness_router, + pending_replies: &mut self.pending_replies, }; handle_action(&mut ctx, action).await } diff --git a/openvtc/src/state_handler/save_coalesce.rs b/openvtc/src/state_handler/save_coalesce.rs index 6fe14615..c1616b56 100644 --- a/openvtc/src/state_handler/save_coalesce.rs +++ b/openvtc/src/state_handler/save_coalesce.rs @@ -227,11 +227,11 @@ impl SaveScheduler { /// `spawn_blocking`, awaiting completion. This drains any pending dirty state: /// after a successful flush, `dirty` is cleared and the deadline disarmed. /// - /// It does not coordinate with an already in-flight background save (the - /// caller's mutation may post-date it); flushing unconditionally guarantees - /// the *current* live state hits disk, which is the durability guarantee we - /// want. The in-flight flag is left untouched so a concurrently completing - /// background save still calls `finish` correctly. + /// It does not wait for an already in-flight background save (the caller's + /// mutation may post-date it); flushing unconditionally puts the *current* + /// live state on disk now. Because that older save may land afterwards, + /// the config is left dirty while one is in flight, and its `finish` + /// writes the current state once more. /// /// # Errors /// @@ -248,12 +248,20 @@ impl SaveScheduler { .await .map_err(|e| OpenVTCError::Config(format!("save task panicked: {e}")))?; if result.is_ok() { - self.dirty = false; - self.deadline = None; + self.settle_after_flush(); } result } + /// The bookkeeping after a successful [`flush`](Self::flush). A background + /// save still in flight holds an *older* snapshot and may land after the + /// flush, overwriting it; leaving the config dirty makes its + /// [`finish`](Self::finish) schedule one more save of the current state. + fn settle_after_flush(&mut self) { + self.dirty = self.in_flight; + self.deadline = None; + } + /// Build a [`PendingSave`] from the live config without touching the /// scheduler's dirty/in-flight state. Used by the shutdown path, which runs a /// *direct* blocking save after the loop has broken (no runtime arm left to @@ -274,20 +282,6 @@ impl SaveScheduler { pub(crate) fn needs_flush(&self) -> bool { self.dirty || self.in_flight } - - /// Drop any pending coalesced dirty state because the caller just persisted - /// the *current* config synchronously by another path (a force-flush save, - /// e.g. a passphrase/protection change or export). This avoids a redundant - /// debounced re-save of state that is already on disk. - /// - /// It does **not** touch the `in_flight` flag: a concurrently-running - /// background save must still call [`finish`](Self::finish). If that - /// background save raced ahead of this external save its data is a subset - /// (older) of what we just wrote, so clearing `dirty` is safe. - pub(crate) fn clear_after_external_save(&mut self) { - self.dirty = false; - self.deadline = None; - } } #[cfg(test)] @@ -342,6 +336,26 @@ mod tests { /// At most one save in flight: while a save is running, the deadline firing /// must NOT start a second one. A mark that lands during the in-flight save /// is remembered and scheduled by `finish`. + /// A flush that overlaps a background save must not let the older + /// snapshot have the last word: the config stays dirty, and the + /// background save's `finish` schedules a fresh one. + #[test] + fn a_flush_during_a_background_save_schedules_another() { + let mut s = SaveScheduler::new("test"); + s.mark_dirty(); + s.dirty = false; + s.in_flight = true; // a background save with an older snapshot + s.settle_after_flush(); + assert!(s.dirty, "the overlapped flush must leave the config dirty"); + s.finish(true); + assert!(s.deadline.is_some(), "finish must schedule another save"); + + let mut idle = SaveScheduler::new("test"); + idle.mark_dirty(); + idle.settle_after_flush(); + assert!(!idle.dirty && idle.deadline.is_none()); + } + #[test] fn at_most_one_in_flight_and_reschedules() { let mut sched = SaveScheduler::new("test"); diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 081f4fc9..f6e58857 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -1,6 +1,9 @@ //! Settings action handlers for the TUI. +use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome, InFlight}; use crate::state_handler::main_page::status::Status; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use anyhow::Result; #[cfg(feature = "openpgp-card")] use openvtc_core::config::ConfigProtectionType; @@ -8,16 +11,6 @@ use openvtc_core::{config::Config, logs::LogFamily}; use secrecy::{ExposeSecret, SecretString}; use tracing::info; -/// Save the config to disk using the profile name. -pub fn save_config(config: &Config, profile: &str) -> Result<()> { - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| {}, - )?; - Ok(()) -} - /// Update the friendly name. /// /// R11: mutates + logs only; persistence is the caller's responsibility (the @@ -53,11 +46,11 @@ pub fn update_friendly_name(config: &mut Config, name: &str) { /// `spawn_blocking` thread via `UnlockKey::derive_blocking` so it does not peg /// the async event-loop / render task. The key is derived under a fresh random /// salt (#566), which the save writes beside the ciphertext. The subsequent -/// `save_config` does not run Argon2 (it AES-encrypts with the already-derived +/// force-flush ([`SaveScheduler::flush`]) does not run Argon2 (it AES-encrypts with the already-derived /// key), so this is the only KDF on this path. pub async fn set_passphrase( config: &mut Config, - profile: &str, + save: &mut SaveScheduler, passphrase: &SecretString, ) -> Result<()> { use openvtc_core::config::{ConfigProtectionType, UnlockKey, validate_passphrase}; @@ -70,13 +63,16 @@ pub async fn set_passphrase( LogFamily::Config, "Profile protection changed to an unlock code".to_string(), ); - save_config(config, profile)?; + save.flush(config).await?; info!("config protection set to passphrase encrypted"); Ok(()) } /// Remove passphrase protection, reverting to keyring-only. -pub fn remove_passphrase(config: &mut Config, profile: &str) -> Result<()> { +/// +/// Force-flushes through [`SaveScheduler::flush`]: the protection change must +/// be on disk before success is reported. +pub async fn remove_passphrase(config: &mut Config, save: &mut SaveScheduler) -> Result<()> { use openvtc_core::config::ConfigProtectionType; config.unlock_code = None; @@ -85,7 +81,7 @@ pub fn remove_passphrase(config: &mut Config, profile: &str) -> Result<()> { LogFamily::Config, "Config protection changed to keyring only (no additional encryption)".to_string(), ); - save_config(config, profile)?; + save.flush(config).await?; info!("config protection reverted to keyring only"); Ok(()) } @@ -143,7 +139,7 @@ use crate::state_handler::{ state::{self, State}, }; use crate::ui::pages::main::components::settings_panel as rows; -use tokio::sync::watch; +use tokio::sync::{mpsc::UnboundedSender, watch}; fn handle_select(state: &mut State, index: usize) { #[cfg(feature = "openpgp-card")] @@ -325,9 +321,8 @@ fn handle_submit_edit( async fn handle_export_config_action( config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, - profile: &str, path: &str, passphrase: SecretString, ) { @@ -338,7 +333,7 @@ async fn handle_export_config_action( // drawing this status (the runtime is no longer pegged by the KDF). state.main_page.content_panel.settings.status_message = Some(Status::progress("Deriving encryption key…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); match export_config(config, path, passphrase).await { Ok(()) => { @@ -348,16 +343,13 @@ async fn handle_export_config_action( .insert(LogFamily::Config, format!("Config exported to {}", path)); // R11 force-flush: export is durability-critical (the exported file // must reflect persisted state and the export-log entry must hit - // disk now). Keep this save synchronous on the loop thread — it is a - // deliberate force-flush point, not the coalesced hot path. It also - // clears any pending coalesced dirty state, so we drop the scheduler's - // deadline below. - if let Err(e) = save_config(config, profile) { + // disk now). The flush is awaited before the handler returns — a + // deliberate force-flush point, not the coalesced hot path — and a + // successful one also clears any pending coalesced dirty state. + if let Err(e) = save.flush(config).await { state .main_page .log_error("Failed to persist export-log entry", &e); - } else { - save.clear_after_external_save(); } state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( @@ -378,11 +370,10 @@ async fn handle_export_config_action( } } -fn handle_import_config_action( +async fn handle_import_config_action( config: &mut Box, state: &mut State, save: &mut SaveScheduler, - profile: &str, path: &str, passphrase: &SecretString, ) { @@ -393,12 +384,10 @@ fn handle_import_config_action( .logs .insert(LogFamily::Config, format!("Config imported from {}", path)); // Force-flush the import-log entry (see export handler). - if let Err(e) = save_config(config, profile) { + if let Err(e) = save.flush(config).await { state .main_page .log_error("Failed to persist import-log entry", &e); - } else { - save.clear_after_external_save(); } state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( @@ -432,7 +421,7 @@ fn handle_change_protection(state: &mut State) { async fn handle_set_passphrase( config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, profile: &str, passphrase: &SecretString, @@ -443,19 +432,16 @@ async fn handle_set_passphrase( // `spawn_blocking` derive, during which the render task keeps drawing. state.main_page.content_panel.settings.status_message = Some(Status::progress("Deriving encryption key…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // R11 force-flush: a protection change re-derives the unlock key and rewrites // the secured config; it MUST be persisted before reporting success (the // keyring entry now requires the new passphrase to decrypt). `set_passphrase` - // saves synchronously — a deliberate force-flush point. (R12 moves its Argon2 - // derivation off the runtime.) - match set_passphrase(config, profile, passphrase).await { + // force-flushes and awaits it — a deliberate force-flush point, which also + // drops any pending coalesced dirty mark. (R12 moves its Argon2 derivation + // off the runtime.) + match set_passphrase(config, save, passphrase).await { Ok(()) => { - // The synchronous save above already persisted the latest state; - // drop any pending coalesced dirty mark so we don't redundantly - // re-save. - save.clear_after_external_save(); state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( &mut state.main_page, @@ -483,18 +469,17 @@ async fn handle_set_passphrase( } } -fn handle_remove_passphrase( +async fn handle_remove_passphrase( config: &mut Box, state: &mut State, save: &mut SaveScheduler, profile: &str, ) { // R11 force-flush: same rationale as `handle_set_passphrase` — a protection - // change must be on disk before reporting success. `remove_passphrase` saves - // synchronously. - match remove_passphrase(config, profile) { + // change must be on disk before reporting success. `remove_passphrase` + // force-flushes and awaits it. + match remove_passphrase(config, save).await { Ok(()) => { - save.clear_after_external_save(); state.main_page.content_panel.settings.mode = SettingsMode::View; dispatch_util::save_and_sync( &mut state.main_page, @@ -589,6 +574,122 @@ fn handle_wipe_confirm(state: &mut State, config: &Config, profile: &str) -> boo true } +/// Detect the did-git-sign install for the persona shown in Settings, off the +/// loop: the read is two small files, but on a slow or network home directory +/// that is the stall, and `sync_from_config` used to do it on every config +/// change. The answer arrives as [`DispatchOutcome::DidGitSign`] and is stored by +/// [`apply_did_git_sign`]. +/// +/// Call it at startup, and whenever the install may have changed. When a +/// detection is already running this one is queued +/// ([`SettingsState::did_git_sign_wanted`]) and started by the next +/// [`maybe_refresh_did_git_sign`], so the answer reflects the latest change. +/// +/// [`SettingsState::did_git_sign_wanted`]: crate::state_handler::main_page::content::SettingsState::did_git_sign_wanted +pub(crate) fn refresh_did_git_sign( + state: &mut State, + in_flight: &mut InFlight, + dispatch_tx: &UnboundedSender, +) { + let settings = &mut state.main_page.content_panel.settings; + let persona_did = settings.persona_did.clone(); + // No persona (a zero-persona account, or before the first sync): nothing + // can be configured for it, and there is nothing to read. + if persona_did.is_empty() { + settings.did_git_sign = None; + settings.did_git_sign_for = Some(persona_did); + settings.did_git_sign_wanted = false; + return; + } + let job = DidGitSignJob { persona_did }; + let started = background_dispatch::dispatch(job, in_flight, dispatch_tx, |_| { + settings.did_git_sign_wanted = true; + }); + if started { + settings.did_git_sign_wanted = false; + } +} + +/// One did-git-sign detection for `persona_did`, by +/// [`detect_did_git_sign_off_loop`]. +struct DidGitSignJob { + persona_did: String, +} + +impl background_dispatch::Job for DidGitSignJob { + fn domain(&self) -> DispatchDomain { + DispatchDomain::DidGitSign + } + + async fn execute(self) -> DispatchOutcome { + let info = detect_did_git_sign_off_loop(self.persona_did.clone()).await; + DispatchOutcome::DidGitSign { + persona_did: self.persona_did, + info, + } + } +} + +/// [`detect_did_git_sign_info`](crate::state_handler::main_page::detect_did_git_sign_info) +/// on a blocking thread. A detection that panics reads as "not set up". +pub(crate) async fn detect_did_git_sign_off_loop( + persona_did: String, +) -> Option { + crate::state_handler::repos_workspace::blocking(move || { + crate::state_handler::main_page::detect_did_git_sign_info(&persona_did) + }) + .await + .ok() + .flatten() +} + +/// Whether the did-git-sign info should be read again, and note what is on +/// screen: due when it was never read for the current persona (startup, a +/// persona switch), when a refresh was asked for, or when Help / Status or +/// Settings has just been opened. Cheap — no I/O — so it can run on every pass +/// of the loop. +fn did_git_sign_refresh_due(state: &mut State) -> bool { + let viewing = matches!( + state.main_page.menu_panel.selected_menu, + crate::state_handler::main_page::menu::MainMenu::Help + | crate::state_handler::main_page::menu::MainMenu::Settings + ); + let settings = &mut state.main_page.content_panel.settings; + let opened = viewing && !settings.did_git_sign_viewing; + settings.did_git_sign_viewing = viewing; + opened + || settings.did_git_sign_wanted + || settings.did_git_sign_for.as_deref() != Some(settings.persona_did.as_str()) +} + +/// [`refresh_did_git_sign`] when one is due (see [`did_git_sign_refresh_due`]). +/// Meant for the loop: after an action or on a tick. Starts nothing while a +/// detection is running. +pub(crate) fn maybe_refresh_did_git_sign( + state: &mut State, + in_flight: &mut InFlight, + dispatch_tx: &UnboundedSender, +) { + if did_git_sign_refresh_due(state) && !in_flight.is_busy(DispatchDomain::DidGitSign) { + refresh_did_git_sign(state, in_flight, dispatch_tx); + } +} + +/// Store a finished detection — unless the persona changed while it ran, in +/// which case it describes a persona no longer shown and is dropped; the +/// current persona is still unread, so the next check refreshes it. +pub(crate) fn apply_did_git_sign( + state: &mut State, + persona_did: String, + info: Option, +) { + let settings = &mut state.main_page.content_panel.settings; + if settings.persona_did == persona_did { + settings.did_git_sign = info; + settings.did_git_sign_for = Some(persona_did); + } +} + /// The did-git-sign identities this profile's personas sign with: each /// persona's assertion method, and the default identity the settings panel /// detected (an install from before per-persona profiles). @@ -991,7 +1092,7 @@ pub(crate) async fn dispatch( action: SettingsAction, config: &mut Box, state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, save: &mut SaveScheduler, profile: &str, ) -> SettingsOutcome { @@ -1027,17 +1128,18 @@ pub(crate) async fn dispatch( // remaining (explicit, operator-driven) way to ask for one. SettingsAction::SubmitEdit { value } => handle_submit_edit(config, state, save, &value), SettingsAction::ExportConfig { path, passphrase } => { - handle_export_config_action(config, state, state_tx, save, profile, &path, passphrase) - .await + handle_export_config_action(config, state, state_tx, save, &path, passphrase).await } SettingsAction::ImportConfig { path, passphrase } => { - handle_import_config_action(config, state, save, profile, &path, &passphrase) + handle_import_config_action(config, state, save, &path, &passphrase).await } SettingsAction::ChangeProtection => handle_change_protection(state), SettingsAction::SetPassphrase { passphrase } => { handle_set_passphrase(config, state, state_tx, save, profile, &passphrase).await } - SettingsAction::RemovePassphrase => handle_remove_passphrase(config, state, save, profile), + SettingsAction::RemovePassphrase => { + handle_remove_passphrase(config, state, save, profile).await + } #[cfg(feature = "openpgp-card")] SettingsAction::TokenManagement => handle_token_management(state), #[cfg(feature = "openpgp-card")] @@ -1099,6 +1201,145 @@ pub(crate) async fn dispatch( mod tests { use super::*; + fn git_sign_info(did_key_id: &str) -> crate::state_handler::main_page::content::DidGitSignInfo { + crate::state_handler::main_page::content::DidGitSignInfo { + did_key_id: did_key_id.into(), + ssh_public_key: String::new(), + config_path: String::new(), + } + } + + /// A detection that finished after the persona changed is dropped, so one + /// persona's signing key is never shown as another's; a sync for another + /// persona hides what was detected for the last one. + #[test] + fn did_git_sign_info_is_only_shown_for_the_persona_it_was_read_for() { + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:b".into(); + + apply_did_git_sign( + &mut state, + "did:example:a".into(), + Some(git_sign_info("did:example:a#key-0")), + ); + let settings = &state.main_page.content_panel.settings; + assert!(settings.did_git_sign.is_none()); + assert_eq!(settings.did_git_sign_for, None); + + apply_did_git_sign( + &mut state, + "did:example:b".into(), + Some(git_sign_info("did:example:b#key-0")), + ); + let settings = &state.main_page.content_panel.settings; + assert!(settings.did_git_sign.is_some()); + assert_eq!(settings.did_git_sign_for.as_deref(), Some("did:example:b")); + + // A sync for a persona the info was not read for hides it. + let config = crate::state_handler::dispatch_util::test_config(); + state.main_page.sync_from_config(&config); + let settings = &state.main_page.content_panel.settings; + if config.persona_did() != "did:example:b" { + assert!(settings.did_git_sign.is_none()); + } + } + + /// When a refresh is due: never read for this persona, asked for, or Help / + /// Settings just opened — and not again while it stays open. + #[test] + fn did_git_sign_refresh_is_due_on_switch_request_and_open() { + use crate::state_handler::main_page::menu::MainMenu; + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:a".into(); + assert!(did_git_sign_refresh_due(&mut state), "never read"); + + apply_did_git_sign(&mut state, "did:example:a".into(), None); + assert!( + !did_git_sign_refresh_due(&mut state), + "read, nothing changed" + ); + + state.main_page.content_panel.settings.did_git_sign_wanted = true; + assert!(did_git_sign_refresh_due(&mut state), "asked for"); + state.main_page.content_panel.settings.did_git_sign_wanted = false; + + state.main_page.menu_panel.selected_menu = MainMenu::Help; + assert!(did_git_sign_refresh_due(&mut state), "Help opened"); + assert!(!did_git_sign_refresh_due(&mut state), "still open"); + state.main_page.menu_panel.selected_menu = MainMenu::Settings; + assert!(!did_git_sign_refresh_due(&mut state), "Help to Settings"); + state.main_page.menu_panel.selected_menu = MainMenu::Logs; + assert!(!did_git_sign_refresh_due(&mut state), "closed"); + state.main_page.menu_panel.selected_menu = MainMenu::Settings; + assert!(did_git_sign_refresh_due(&mut state), "Settings reopened"); + + state.main_page.content_panel.settings.persona_did = "did:example:b".into(); + assert!(did_git_sign_refresh_due(&mut state), "persona switched"); + } + + /// A refresh runs off the loop and comes back as an outcome for the persona + /// it was started for; one asked for while it runs is queued, not dropped. + #[tokio::test] + async fn did_git_sign_refresh_runs_as_a_dispatch_and_queues_a_second() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let mut in_flight = InFlight::default(); + let mut state = State::default(); + state.main_page.content_panel.settings.persona_did = "did:example:none".into(); + + maybe_refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(in_flight.is_busy(DispatchDomain::DidGitSign)); + refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(state.main_page.content_panel.settings.did_git_sign_wanted); + + let outcome = rx.recv().await.expect("the detection reports"); + assert_eq!(outcome.domain(), DispatchDomain::DidGitSign); + let DispatchOutcome::DidGitSign { persona_did, .. } = &outcome else { + panic!("a did-git-sign outcome"); + }; + assert_eq!(persona_did, "did:example:none"); + let mut config = crate::state_handler::dispatch_util::test_config(); + let mut save = crate::state_handler::save_coalesce::SaveScheduler::new("test"); + background_dispatch::apply_outcome( + &mut state, + &mut config, + &mut save, + &mut in_flight, + &mut openvtc_core::pending_replies::PendingReplies::default(), + outcome, + ); + assert!(!in_flight.is_busy(DispatchDomain::DidGitSign)); + assert_eq!( + state + .main_page + .content_panel + .settings + .did_git_sign_for + .as_deref(), + Some("did:example:none") + ); + + // The queued one starts on the next check. + maybe_refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(in_flight.is_busy(DispatchDomain::DidGitSign)); + assert!(!state.main_page.content_panel.settings.did_git_sign_wanted); + assert!(rx.recv().await.is_some()); + + // No persona: answered without a read. + let mut in_flight = InFlight::default(); + state.main_page.content_panel.settings.persona_did.clear(); + refresh_did_git_sign(&mut state, &mut in_flight, &tx); + assert!(!in_flight.is_busy(DispatchDomain::DidGitSign)); + assert_eq!( + state + .main_page + .content_panel + .settings + .did_git_sign_for + .as_deref(), + Some("") + ); + } + /// The picker previews as it moves, keeps what Enter chose, and puts back /// what was in use when cancelled. One test, because the active theme is /// process-wide. @@ -1230,7 +1471,7 @@ mod tests { let mut config = Box::new(crate::state_handler::dispatch_util::test_config()); let mut state = State::default(); let mut save = crate::state_handler::save_coalesce::SaveScheduler::new("test"); - let (state_tx, _rx) = watch::channel(State::default()); + let (state_tx, _rx) = watch::channel(std::sync::Arc::new(State::default())); for idx in [ rows::FRIENDLY_NAME_ROW, diff --git a/openvtc/src/state_handler/setup_sequence/config.rs b/openvtc/src/state_handler/setup_sequence/config.rs index 236b2ddf..5a121f1b 100644 --- a/openvtc/src/state_handler/setup_sequence/config.rs +++ b/openvtc/src/state_handler/setup_sequence/config.rs @@ -1,5 +1,7 @@ /*! Contains specific Config extensions for the CLI Application. */ +use crate::state_handler::state::PublishState; + use affinidi_tdk::{TDK, messaging::profiles::ATMProfile, secrets_resolver::SecretsResolver}; use anyhow::{Result, bail}; use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD}; @@ -40,7 +42,7 @@ pub trait ConfigExtension { /// profile: Profile name to import the configuration into fn import( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, import_unlock_passphrase: &SecretString, new_unlock_passphrase: &SecretString, file: &str, @@ -72,6 +74,16 @@ pub trait ConfigExtension { tdk: &TDK, profile: &str, ) -> Result; + + /// [`mint_persona_into`](Self::mint_persona_into) without the save: write + /// the persona into `config` in memory and return its id. The caller owns + /// persisting it — the TUI loops force-flush through their + /// `SaveScheduler`, the headless join saves directly. + async fn mint_persona_in_memory( + config: &mut Config, + state: &SetupState, + tdk: &TDK, + ) -> Result; } /// The operator-chosen name in `SetupState::username`, or `None` when there @@ -131,7 +143,7 @@ impl ConfigExtension for Config { /// Import previously exported configuration settings from an encrypted file fn import( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, import_unlock_passphrase: &SecretString, new_unlock_passphrase: &SecretString, file: &str, @@ -147,7 +159,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't read from file ({file}). Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("File read error"); } }; @@ -162,7 +174,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't base64 decode file content. Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("base64 decoding error"); } }; @@ -187,7 +199,7 @@ impl ConfigExtension for Config { .push(MessageType::Error(format!( "Couldn't deserialize configuration settings. Reason: {e}" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); bail!("deserialization error"); } }; @@ -259,7 +271,7 @@ impl ConfigExtension for Config { .push(MessageType::Info( "Please touch token hardware to unlock keys".to_string(), )); - let _ = state_tx_clone.send(state_mut); + let _ = state_tx_clone.publish(&state_mut); }) .map_err(|e| anyhow::anyhow!("Couldn't save Secured Config: {e}"))?; } @@ -295,6 +307,22 @@ impl ConfigExtension for Config { state: &SetupState, tdk: &TDK, profile: &str, + ) -> Result { + let persona_id = Self::mint_persona_in_memory(config, state, tdk).await?; + config.save( + profile, + #[cfg(feature = "openpgp-card")] + &|| { + eprintln!("Touch confirmation needed for decryption"); + }, + )?; + Ok(persona_id) + } + + async fn mint_persona_in_memory( + config: &mut Config, + state: &SetupState, + tdk: &TDK, ) -> Result { // The callers have already refused a mint with no VTA mediator before // touching the VTA; this is the backstop for any path that forgets, so @@ -419,14 +447,6 @@ impl ConfigExtension for Config { config.public.friendly_name = name.to_string(); } - config.save( - profile, - #[cfg(feature = "openpgp-card")] - &|| { - eprintln!("Touch confirmation needed for decryption"); - }, - )?; - Ok(persona_id) } } diff --git a/openvtc/src/state_handler/setup_sequence/openpgp_card.rs b/openvtc/src/state_handler/setup_sequence/openpgp_card.rs index b51a09f6..431db53d 100644 --- a/openvtc/src/state_handler/setup_sequence/openpgp_card.rs +++ b/openvtc/src/state_handler/setup_sequence/openpgp_card.rs @@ -2,6 +2,8 @@ * Handles writing of data to the OpenPGP Card */ +use crate::state_handler::state::PublishState; + use anyhow::{Result, bail}; use ed25519_dalek_bip32::VerifyingKey; use openpgp_card::{ @@ -29,7 +31,7 @@ use crate::state_handler::{setup_sequence::MessageType, state::State}; /// Writes keys to the card pub fn write_keys_to_card( state: &mut State, - action_tx: &watch::Sender, + action_tx: &watch::Sender>, card: Arc>>, ) -> Result<()> { state @@ -45,7 +47,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Unlocking token in admin mode...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); // Try unlocking the card with the admin PIN let mut lock = card .try_lock() @@ -63,13 +65,13 @@ pub fn write_keys_to_card( if let Some(last) = state.setup.token_reset.messages.last_mut() { *last = MessageType::Info("✓ Unlocked token in admin mode".to_string()); } - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); // Create a PGP secret key packet state.setup.token_reset.messages.push(MessageType::Info( "Writing signing key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.signing, KeyPurpose::Signing)?; card.import_key(&uk, KeyType::Signing)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -79,7 +81,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Writing authentication key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.authentication, KeyPurpose::Authentication)?; card.import_key(&uk, KeyType::Authentication)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -89,7 +91,7 @@ pub fn write_keys_to_card( state.setup.token_reset.messages.push(MessageType::Info( "Writing decryption key to token...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); let uk = create_pgp_secret_packet(&keys.decryption, KeyPurpose::Encryption)?; card.import_key(&uk, KeyType::Decryption)?; if let Some(last) = state.setup.token_reset.messages.last_mut() { @@ -222,7 +224,7 @@ fn create_pgp_secret_packet(key: &KeyInfo, kp: KeyPurpose) -> Result, + action_tx: &watch::Sender>, card: Arc>>, ) -> Result<()> { let mut lock = card @@ -242,7 +244,7 @@ pub fn set_signing_touch_policy( state.setup.token_set_touch.messages.push(MessageType::Info( "Setting touch policy on signing key...".to_string(), )); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); card.set_touch_policy(KeyType::Signing, TouchPolicy::On)?; state.setup.token_set_touch.messages.push(MessageType::Info( @@ -256,7 +258,7 @@ pub fn set_signing_touch_policy( /// name: Max length is 39 characters pub fn set_cardholder_name( state: &mut State, - action_tx: &watch::Sender, + action_tx: &watch::Sender>, card: Arc>>, name: &str, ) -> Result<()> { @@ -281,7 +283,7 @@ pub fn set_cardholder_name( .push(MessageType::Info(format!( "Setting cardholder name to ({name})..." ))); - let _ = action_tx.send(state.clone()); + let _ = action_tx.publish(state); card.set_cardholder_name(name)?; state .setup diff --git a/openvtc/src/state_handler/setup_token_actions.rs b/openvtc/src/state_handler/setup_token_actions.rs index a7cd3917..5ba7ce81 100644 --- a/openvtc/src/state_handler/setup_token_actions.rs +++ b/openvtc/src/state_handler/setup_token_actions.rs @@ -93,7 +93,7 @@ pub(crate) async fn handle_factory_reset(state: &mut State, token: Option, + state_tx: &watch::Sender>, token: Option>>>, ) { use crate::state_handler::setup_sequence::openpgp_card::write_keys_to_card; @@ -148,7 +148,7 @@ pub(crate) async fn handle_token_write_keys( #[cfg(feature = "openpgp-card")] pub(crate) fn handle_set_touch_policy( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, token: Option>>>, ) { use crate::state_handler::setup_sequence::openpgp_card::set_signing_touch_policy; @@ -180,7 +180,7 @@ pub(crate) fn handle_set_touch_policy( #[cfg(feature = "openpgp-card")] pub(crate) fn handle_set_token_name( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, token: Option>>>, name: &str, ) { diff --git a/openvtc/src/state_handler/setup_vta_actions.rs b/openvtc/src/state_handler/setup_vta_actions.rs index 8a069113..9b5e6ddc 100644 --- a/openvtc/src/state_handler/setup_vta_actions.rs +++ b/openvtc/src/state_handler/setup_vta_actions.rs @@ -1,4 +1,6 @@ use crate::env_overrides::WizardUrlOverride; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::state_handler::{ setup_sequence::{Completion, MessageType, RebuildOutcome, SetupPage}, state::State, @@ -100,7 +102,7 @@ fn kept_mediator(connected: Option, advertised_didcomm: Option) /// failure we stay on `VtaEnterDid` so the operator can edit and resubmit. pub(crate) async fn handle_vta_submit_did( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, vta_did: String, ) -> anyhow::Result<()> { // The transition from StartAsk → VtaEnterDid is a UI-only navigation @@ -132,7 +134,7 @@ pub(crate) async fn handle_vta_submit_did( "DEV OVERRIDE: {VTA_URL_OVERRIDE_ENV} set — using REST endpoint {url} \ (skipping DID resolution)." ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); state.setup.vta.vta_url = url; state.setup.vta.advertised = vec![Protocol::Rest]; // The URL is pinned, but the account still needs the mediator its @@ -154,7 +156,7 @@ pub(crate) async fn handle_vta_submit_did( state.setup.vta.messages.push(MessageType::Info( "Resolving VTA service endpoint…".to_string(), )); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // Use `resolve_vta` (not `resolve_vta_url`) so we get an honest answer: // `rest_url` is `Some` only when the DID document advertises a `#vta-rest` @@ -236,7 +238,7 @@ pub(crate) async fn handle_vta_submit_did( state.setup.vta.setup_key = Some(setup_key); state.setup.vta.completed = Completion::CompletedOK; state.setup.active_page = SetupPage::VtaAclInstructions; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); Ok(()) } @@ -256,7 +258,7 @@ pub(crate) async fn handle_vta_submit_did( /// in-flight responses). Returns `Ok(None)` if provisioning did not complete. pub(crate) async fn handle_vta_start_provision( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, context_id: String, transport: Option, ) -> anyhow::Result> { @@ -288,7 +290,7 @@ pub(crate) async fn handle_vta_start_provision( state.setup.vta.diagnostics.clear(); state.setup.vta.credential_did.clear(); state.setup.vta.completed = Completion::NotFinished; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // AdminRotated mints a fresh long-term admin DID on the VTA side; the // ephemeral setup did:key only authenticates the bootstrap call. The reply @@ -327,12 +329,12 @@ pub(crate) async fn handle_vta_start_provision( "URL-direct provisioning failed: {e}" ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } } } else { state.setup.vta.diagnostics = pending_list(); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let (tx, mut rx) = mpsc::unbounded_channel::(); let runner_vta_did = vta_did.clone(); @@ -393,13 +395,13 @@ pub(crate) async fn handle_vta_start_provision( .messages .push(MessageType::Error(reason.clone())); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } // `VtaEvent` is non-exhaustive (vta-sdk #2196): an event this // build does not know carries nothing the setup page acts on. _ => {} } - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } } @@ -409,7 +411,7 @@ pub(crate) async fn handle_vta_start_provision( "Provisioning ended without an admin credential.".to_string(), )); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } return Ok(None); }; @@ -450,14 +452,14 @@ pub(crate) async fn handle_vta_start_provision( "{label} transport selected but no mediator DID was advertised." ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } }; state.setup.vta.messages.push(MessageType::Info(format!( "Opening {label} session as rotated admin DID…" ))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let rest_fallback = if state.setup.vta.vta_url.is_empty() { None @@ -497,7 +499,7 @@ pub(crate) async fn handle_vta_start_provision( "{label} session open failed: {e}" ))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } } @@ -512,7 +514,7 @@ pub(crate) async fn handle_vta_start_provision( .to_string(), )); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } PostBootstrapAuth::Rest => { @@ -521,7 +523,7 @@ pub(crate) async fn handle_vta_start_provision( .vta .messages .push(MessageType::Info("Authenticating with VTA…".to_string())); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let vta_url = state.setup.vta.vta_url.clone(); match vta::authenticate( @@ -578,7 +580,7 @@ pub(crate) async fn handle_vta_start_provision( .messages .push(MessageType::Error(format!("Authentication failed: {e}"))); state.setup.vta.completed = Completion::CompletedFail; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return Ok(None); } } @@ -613,7 +615,7 @@ pub(crate) async fn handle_vta_start_provision( // Stay on VtaProvisioning so the operator can see the admin DID rotation // result (ephemeral setup DID → long-term admin DID) before advancing on // Enter. - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); // Hand the live admin session back to the wizard. It is kept open and reused // for the key/DID-creation steps (one mediator connection for the whole @@ -634,7 +636,7 @@ pub(crate) async fn handle_vta_start_provision( /// a listing failed would be a worse outcome than a page that explains itself. pub(crate) async fn handle_recover_plan_context( state: &mut State, - state_tx: &watch::Sender, + state_tx: &watch::Sender>, client: &vta_sdk::client::VtaClient, ) { let context_id = state.setup.vta.context_id.clone().unwrap_or_default(); @@ -642,7 +644,7 @@ pub(crate) async fn handle_recover_plan_context( state.setup.active_page = SetupPage::RecoverConfirm; state.setup.vta.rebuild = None; - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); let outcome = match openvtc_core::rebuild::plan(client, &context_id, now).await { Ok(plan) => { @@ -675,7 +677,7 @@ pub(crate) async fn handle_recover_plan_context( Err(e) => { state.setup.vta.rebuild = Some(Err(format!("could not list this context's keys: {e}"))); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); return; } }; @@ -687,7 +689,7 @@ pub(crate) async fn handle_recover_plan_context( }; state.setup.vta.rebuild = Some(outcome); - let _ = state_tx.send(state.clone()); + let _ = state_tx.publish(state); } #[cfg(test)] diff --git a/openvtc/src/state_handler/setup_wizard.rs b/openvtc/src/state_handler/setup_wizard.rs index 16c2eb34..a3f15735 100644 --- a/openvtc/src/state_handler/setup_wizard.rs +++ b/openvtc/src/state_handler/setup_wizard.rs @@ -1,5 +1,7 @@ #[cfg(feature = "openpgp-card")] use crate::state_handler::setup_token_actions; +#[allow(unused_imports)] +use crate::state_handler::state::PublishState; use crate::{ Interrupted, state_handler::{ @@ -40,7 +42,7 @@ impl StateHandler { // `?` error out of a handler below — and never leaks (the LeakGuard). let exit: Result = async { Ok(loop { - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); tokio::select! { Some(action) = action_rx.recv() => match action { Action::Exit => { @@ -90,7 +92,7 @@ impl StateHandler { // the blocking pool rather than this loop or the render task. state.setup.active_page = SetupPage::FinalPage; state.setup.final_page.messages.push(MessageType::Info("Deriving the encryption key from your unlock code...".to_string())); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); // A fresh random salt per profile (#566), written beside // the ciphertext by the first save. match UnlockKey::derive_blocking(passphrase).await { @@ -209,7 +211,7 @@ impl StateHandler { state.setup.final_page.messages.push(MessageType::Info( "Your device may prompt for authentication to access OS secure storage.".to_string(), )); - let _ = self.state_tx.send(state.clone()); + let _ = self.state_tx.publish(state); // R-A-5: setup bootstraps a State-A account (VTA admin credential + // top-level context, no persona/community). A persona is minted later // by the State-B join flow. diff --git a/openvtc/src/state_handler/state.rs b/openvtc/src/state_handler/state.rs index 90565dae..10a7d67d 100644 --- a/openvtc/src/state_handler/state.rs +++ b/openvtc/src/state_handler/state.rs @@ -8,6 +8,29 @@ use secrecy::SecretString; use crate::state_handler::{join::JoinState, main_page::MainPageState, setup_sequence::SetupState}; +/// How the state handler publishes [`State`] to the UI (#556). +/// +/// The channel carries an `Arc`: publishing clones the state once, into the +/// `Arc`, and the UI's copies of the snapshot share it instead of cloning it +/// again. Every publish still sends — there is no dirty tracking, so the UI is +/// never left showing a stale state (the reason R23 was declined). +pub(crate) trait PublishState { + /// Send a snapshot of `state`. Errs only when the UI has gone. + fn publish( + &self, + state: &State, + ) -> Result<(), tokio::sync::watch::error::SendError>>; +} + +impl PublishState for tokio::sync::watch::Sender> { + fn publish( + &self, + state: &State, + ) -> Result<(), tokio::sync::watch::error::SendError>> { + self.send(Arc::new(state.clone())) + } +} + /// State holds the state of the application #[derive(Default, Debug, Clone)] pub struct State { diff --git a/openvtc/src/state_handler/vetting_actions/pcs.rs b/openvtc/src/state_handler/vetting_actions/pcs.rs index 0d01173b..3202ed58 100644 --- a/openvtc/src/state_handler/vetting_actions/pcs.rs +++ b/openvtc/src/state_handler/vetting_actions/pcs.rs @@ -17,7 +17,7 @@ use super::hidden::{cannot_attest_hidden, vetter_context}; use super::jobs::Sent; use super::send::{sign_and_send, spawn_send}; use super::words::community_display; -use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; +use crate::state_handler::background_dispatch; use crate::state_handler::hidden_vetting_vta::{PcsDone, PcsJob, PcsOutcome, PcsWork}; use crate::state_handler::main_page::sanitize_display; use crate::state_handler::runtime_actions::ActionCtx; @@ -80,23 +80,19 @@ pub(crate) fn start_pcs_work(ctx: &mut ActionCtx<'_>, extra: Vec) -> bo ctx.config.private.vetting.pcs_due = false; return true; } - if !ctx.in_flight.try_begin(DispatchDomain::HiddenVetting) { - // The job running now takes what it carried; this is asked for when it is back. - ctx.config.private.vetting.pcs_due = true; - return false; - } - ctx.config.private.vetting.pcs_due = false; let job = PcsJob { client, inbox_taken, work, }; - background_dispatch::spawn_dispatch( - ctx.dispatch_tx.clone(), - DispatchDomain::HiddenVetting, - async move { DispatchOutcome::HiddenVetting(Box::new(job.run().await)) }, - ); - true + let started = background_dispatch::dispatch(job, ctx.in_flight, ctx.dispatch_tx, |_| { + // The job running now takes what it carried; this is asked for when it is back. + ctx.config.private.vetting.pcs_due = true; + }); + if started { + ctx.config.private.vetting.pcs_due = false; + } + started } /// The call that hands `item` to the VTA, with what it needs that the item does not carry. diff --git a/openvtc/src/state_handler/vic.rs b/openvtc/src/state_handler/vic.rs index 90c484f3..000dbe7a 100644 --- a/openvtc/src/state_handler/vic.rs +++ b/openvtc/src/state_handler/vic.rs @@ -250,12 +250,41 @@ impl VicVerb { } } +/// One backgrounded VIC-list refresh: the vault query, with the filter it was +/// asked under. +pub(crate) struct VicRefreshJob { + pub(crate) admin_vta: VtaClient, + pub(crate) include_inactive: bool, +} + +impl crate::state_handler::background_dispatch::Job for VicRefreshJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Vic + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::Vic( + VicRefreshOutcome::run(self.admin_vta, self.include_inactive).await, + ) + } +} + /// One backgrounded vault mutation. pub(crate) struct VicJob { pub(crate) admin_vta: VtaClient, pub(crate) verb: VicVerb, } +impl crate::state_handler::background_dispatch::Job for VicJob { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::Vic + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::VicMutation(self.run().await) + } +} + impl VicJob { /// Do the round-trip. I/O only. pub(crate) async fn run(self) -> VicMutationOutcome { diff --git a/openvtc/src/state_handler/vta_transports.rs b/openvtc/src/state_handler/vta_transports.rs index 83214ec2..1b834be5 100644 --- a/openvtc/src/state_handler/vta_transports.rs +++ b/openvtc/src/state_handler/vta_transports.rs @@ -27,6 +27,23 @@ use crate::state_handler::main_page::content::AdvertisedTransports; +/// One probe of `vta_did`, by [`probe`]. +pub(crate) struct VtaTransportsProbe { + pub(crate) vta_did: String, +} + +impl crate::state_handler::background_dispatch::Job for VtaTransportsProbe { + fn domain(&self) -> crate::state_handler::background_dispatch::DispatchDomain { + crate::state_handler::background_dispatch::DispatchDomain::VtaTransports + } + + async fn execute(self) -> crate::state_handler::background_dispatch::DispatchOutcome { + crate::state_handler::background_dispatch::DispatchOutcome::VtaTransports( + probe(self.vta_did).await, + ) + } +} + /// Ceiling on one probe. `resolve_vta` resolves a DID document through the /// resolver cache and sets no deadline of its own, so the bound is imposed here /// (VTI R1.2): a hung publication endpoint must produce an error the panel can diff --git a/openvtc/src/ui/component.rs b/openvtc/src/ui/component.rs index 59136859..9ff283cb 100644 --- a/openvtc/src/ui/component.rs +++ b/openvtc/src/ui/component.rs @@ -1,13 +1,15 @@ +use std::sync::Arc; + use crate::state_handler::{actions::Action, state::State}; use crossterm::event::KeyEvent; use ratatui::Frame; use tokio::sync::mpsc::UnboundedSender; pub trait Component { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &Arc, action_tx: UnboundedSender) -> Self where Self: Sized; - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &Arc) -> Self where Self: Sized; diff --git a/openvtc/src/ui/mod.rs b/openvtc/src/ui/mod.rs index bae71b93..23c6060f 100644 --- a/openvtc/src/ui/mod.rs +++ b/openvtc/src/ui/mod.rs @@ -26,6 +26,7 @@ pub mod badges; pub mod component; pub mod journey; pub mod pages; +pub mod shared; pub struct UiManager { action_tx: mpsc::UnboundedSender, @@ -55,7 +56,7 @@ impl UiManager { pub async fn main_loop( self, - mut state_rx: watch::Receiver, + mut state_rx: watch::Receiver>, mut interrupt_rx: broadcast::Receiver, ) -> Result { let Self { diff --git a/openvtc/src/ui/pages/join_flow/context_choice.rs b/openvtc/src/ui/pages/join_flow/context_choice.rs index 826c1981..3e820e2f 100644 --- a/openvtc/src/ui/pages/join_flow/context_choice.rs +++ b/openvtc/src/ui/pages/join_flow/context_choice.rs @@ -212,7 +212,7 @@ mod tests { }, ]; state.join.context_selected = selected; - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/identity_choice.rs b/openvtc/src/ui/pages/join_flow/identity_choice.rs index b681c5c1..386bcc55 100644 --- a/openvtc/src/ui/pages/join_flow/identity_choice.rs +++ b/openvtc/src/ui/pages/join_flow/identity_choice.rs @@ -275,7 +275,7 @@ mod tests { }, ]; mutate(&mut state.join); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/invitation_choice.rs b/openvtc/src/ui/pages/join_flow/invitation_choice.rs index 4cbef968..bade4712 100644 --- a/openvtc/src/ui/pages/join_flow/invitation_choice.rs +++ b/openvtc/src/ui/pages/join_flow/invitation_choice.rs @@ -317,7 +317,7 @@ mod tests { state.join.page = JoinPage::InvitationChoice; state.join.invitation_options = vec![vic("urn:uuid:one")]; mutate(&mut state.join); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(code: KeyCode) -> KeyEvent { diff --git a/openvtc/src/ui/pages/join_flow/join_progress.rs b/openvtc/src/ui/pages/join_flow/join_progress.rs index be60ef0e..aff9198d 100644 --- a/openvtc/src/ui/pages/join_flow/join_progress.rs +++ b/openvtc/src/ui/pages/join_flow/join_progress.rs @@ -275,7 +275,7 @@ mod tests { let mut state = State::default(); state.join.processing = true; state.join.waiting_on_challenge = waiting; - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) }; let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); diff --git a/openvtc/src/ui/pages/join_flow/mod.rs b/openvtc/src/ui/pages/join_flow/mod.rs index 73838942..3f355b07 100644 --- a/openvtc/src/ui/pages/join_flow/mod.rs +++ b/openvtc/src/ui/pages/join_flow/mod.rs @@ -11,6 +11,7 @@ //! `Input` lives on this component (mirroring how `vta_enter_did` holds its //! input), and persists across re-renders via `move_with_state`. +use crate::ui::shared::Shared; use crate::{ state_handler::{ actions::Action, @@ -81,7 +82,7 @@ pub struct JoinFlow { #[derive(Clone)] pub struct Props { - pub state: JoinState, + pub state: Shared, /// The create-persona overlay, while one is open. /// /// Page-level state shared with the main page rather than a copy of its @@ -90,14 +91,14 @@ pub struct Props { /// entering the community's DID a second time on the way back. The same /// overlay, the same phases, the same keys — floated over whichever page /// asked for it. - pub create_persona: Option, + pub create_persona: Shared>, } -impl From<&State> for Props { - fn from(state: &State) -> Self { +impl From<&std::sync::Arc> for Props { + fn from(state: &std::sync::Arc) -> Self { Props { - state: state.join.clone(), - create_persona: state.main_page.create_persona.clone(), + state: Shared::new(state, |s| &s.join), + create_persona: Shared::new(state, |s| &s.main_page.create_persona), } } } @@ -176,7 +177,7 @@ impl JoinFlow { } impl Component for JoinFlow { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -196,7 +197,7 @@ impl Component for JoinFlow { .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { @@ -304,7 +305,7 @@ mod tests { fn flow() -> JoinFlow { let (tx, _rx) = unbounded_channel(); - JoinFlow::new(&State::default(), tx) + JoinFlow::new(&std::sync::Arc::new(State::default()), tx) } /// Set an issuer on the state and push it through the same path the runtime @@ -312,7 +313,7 @@ mod tests { fn broadcast(flow: JoinFlow, issuer: Option<&str>) -> JoinFlow { let mut state = State::default(); state.join.invitation_issuer = issuer.map(str::to_string); - flow.move_with_state(&state) + flow.move_with_state(&std::sync::Arc::new(state.clone())) } /// Each fresh entry into the flow starts with an empty box; a redraw within @@ -322,11 +323,11 @@ mod tests { let mut state = State::default(); let mut flow = flow(); flow.vtc_did = Input::new("did:webvh:last.join".to_string()); - let mut flow = flow.move_with_state(&state); + let mut flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), "did:webvh:last.join", "same entry"); state.join.reset(); - flow = flow.move_with_state(&state); + flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), "", "a new entry starts empty"); } @@ -338,7 +339,7 @@ mod tests { let mut state = State::default(); state.join.reset(); state.join.invitation_issuer = Some(ISSUER.to_string()); - let flow = flow.move_with_state(&state); + let flow = flow.move_with_state(&std::sync::Arc::new(state.clone())); assert_eq!(flow.vtc_did.value(), ISSUER); } diff --git a/openvtc/src/ui/pages/join_flow/registry_consent.rs b/openvtc/src/ui/pages/join_flow/registry_consent.rs index bb236d1e..1e26b6ed 100644 --- a/openvtc/src/ui/pages/join_flow/registry_consent.rs +++ b/openvtc/src/ui/pages/join_flow/registry_consent.rs @@ -422,7 +422,10 @@ mod tests { let (tx, mut rx) = unbounded_channel(); let mut state = crate::state_handler::state::State::default(); state.join.registry = Some(choice(false)); - let mut flow = ::new(&state, tx); + let mut flow = ::new( + &std::sync::Arc::new(state.clone()), + tx, + ); let press = |code| KeyEvent::new(code, KeyModifiers::NONE); for c in ['y', 'Y', 'p', 'x'] { diff --git a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs index b5e37e82..07360b95 100644 --- a/openvtc/src/ui/pages/join_flow/vetting_requirements.rs +++ b/openvtc/src/ui/pages/join_flow/vetting_requirements.rs @@ -663,7 +663,7 @@ mod tests { let mut state = State::default(); state.join.page = JoinPage::Vetting; state.join.vetting = Some(view(phase)); - (JoinFlow::new(&state, tx), rx) + (JoinFlow::new(&std::sync::Arc::new(state.clone()), tx), rx) } fn press(flow: &mut JoinFlow, code: KeyCode) { diff --git a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs index 04ec25b8..d3836660 100644 --- a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs +++ b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs @@ -32,8 +32,8 @@ pub struct VtcEnterDid; impl VtcEnterDid { pub fn handle_key_event(state: &mut JoinFlow, key: KeyEvent) { // Input is locked while the background sequence runs, except F10: a - // quit must always be heard. It is acted on once the in-flight - // resolve ends (each step is bounded by a timeout). + // quit must always be heard. The loop hears it at once — a resolve or + // a community read runs off the loop, and is aborted. if state.props.state.processing && key.code != KeyCode::F(10) { return; } @@ -439,7 +439,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, _rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); VtcEnterDid::handle_key_event( &mut flow, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), @@ -465,7 +465,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, mut rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); flow.apply_entry_paste(" did:webvh:QmRoot:vtc.example.com "); assert_eq!(flow.vtc_did.value(), "did:webvh:QmRoot:vtc.example.com"); @@ -497,7 +497,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel; let (tx, mut rx) = unbounded_channel(); - let mut flow = JoinFlow::new(&State::default(), tx); + let mut flow = JoinFlow::new(&std::sync::Arc::new(State::default()), tx); VtcEnterDid::handle_key_event( &mut flow, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), diff --git a/openvtc/src/ui/pages/loading/mod.rs b/openvtc/src/ui/pages/loading/mod.rs index 5b9adf9a..7aec174c 100644 --- a/openvtc/src/ui/pages/loading/mod.rs +++ b/openvtc/src/ui/pages/loading/mod.rs @@ -10,6 +10,7 @@ //! struct mapped `From<&State>` carries everything the screen renders, so the //! component itself stays free of business logic. +use crate::state_handler::state::ActivePage; use crate::{ colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, @@ -117,24 +118,28 @@ pub struct LoadingScreen { } impl Component for LoadingScreen { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { LoadingScreen { action_tx, - props: Props::from(state), + props: Props::from(&**state), scroll: 0, rendered_lines: std::cell::Cell::new(0), } } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { + // Only while the loading screen is up (#556): it is not drawn after. + if !matches!(state.active_page, ActivePage::Loading) { + return self; + } LoadingScreen { - props: Props::from(state), + props: Props::from(&**state), ..self } } @@ -800,7 +805,7 @@ mod tests { fn screen(state: &State) -> LoadingScreen { let (tx, _rx) = unbounded_channel(); - LoadingScreen::new(state, tx) + LoadingScreen::new(&std::sync::Arc::new(state.clone()), tx) } #[test] diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index e44623d9..87f52de0 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -2,6 +2,7 @@ use crate::colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::ui::shared::Shared; use crate::{ state_handler::{ actions::{Action, SettingsAction}, @@ -80,21 +81,21 @@ pub struct MainPage { const CONTENT_PAGE: u16 = 10; struct Props { - main_page: MainPageState, - connection: ConnectionState, + main_page: Shared, + connection: Shared, } -impl From<&State> for Props { - fn from(state: &State) -> Self { +impl From<&std::sync::Arc> for Props { + fn from(state: &std::sync::Arc) -> Self { Props { - main_page: state.main_page.clone(), - connection: state.connection.clone(), + main_page: Shared::new(state, |s| &s.main_page), + connection: Shared::new(state, |s| &s.connection), } } } impl Component for MainPage { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -117,7 +118,7 @@ impl Component for MainPage { .move_with_state(state) } - fn move_with_state(mut self, state: &State) -> Self + fn move_with_state(mut self, state: &std::sync::Arc) -> Self where Self: Sized, { @@ -1526,7 +1527,7 @@ mod key_handler_tests { // The content panel must be focused for keys to route to its handler. state.main_page.content_panel.selected = true; mutate(&mut state); - let page = MainPage::new(&state, tx); + let page = MainPage::new(&std::sync::Arc::new(state.clone()), tx); (page, rx) } diff --git a/openvtc/src/ui/pages/mod.rs b/openvtc/src/ui/pages/mod.rs index 5e2ca1e2..29b3392c 100644 --- a/openvtc/src/ui/pages/mod.rs +++ b/openvtc/src/ui/pages/mod.rs @@ -57,12 +57,12 @@ impl AppRouter { } impl Component for AppRouter { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { AppRouter { - props: Props::from(state), + props: Props::from(&**state), // main_page: MainPage::new(state, action_tx.clone()), setup_flow: SetupFlow::new(state, action_tx.clone()), @@ -72,12 +72,12 @@ impl Component for AppRouter { .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { AppRouter { - props: Props::from(state), + props: Props::from(&**state), // main_page: self.main_page.move_with_state(state), setup_flow: self.setup_flow.move_with_state(state), diff --git a/openvtc/src/ui/pages/setup_flow/mod.rs b/openvtc/src/ui/pages/setup_flow/mod.rs index 0fb15123..eb0db8d1 100644 --- a/openvtc/src/ui/pages/setup_flow/mod.rs +++ b/openvtc/src/ui/pages/setup_flow/mod.rs @@ -102,7 +102,7 @@ impl From<&State> for Props { } impl Component for SetupFlow { - fn new(state: &State, action_tx: UnboundedSender) -> Self + fn new(state: &std::sync::Arc, action_tx: UnboundedSender) -> Self where Self: Sized, { @@ -134,17 +134,26 @@ impl Component for SetupFlow { final_page: FinalPage::default(), // set the props - props: Props::from(state), + props: Props::from(&**state), } .move_with_state(state) } - fn move_with_state(self, state: &State) -> Self + fn move_with_state(self, state: &std::sync::Arc) -> Self where Self: Sized, { + // Copied only while the wizard is on screen (#556): its props are a + // working copy the pages edit, and an update that arrives while + // another page is up has nothing here to refresh. + if !matches!( + state.active_page, + crate::state_handler::state::ActivePage::Setup + ) { + return self; + } SetupFlow { - props: Props::from(state), + props: Props::from(&**state), // propagate the update to the child components ..self } diff --git a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs index fe2f50f2..141d1e2c 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs @@ -1071,7 +1071,7 @@ mod tests { #[test] fn enter_starts_step_two_and_clears_the_banner() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.vta_acl_instructions = back_with(ProvisionFailure::NotAuthorised, None); flow.vta_acl_instructions.context_id = Input::new(" my-ctx ".to_string()); @@ -1095,7 +1095,7 @@ mod tests { #[test] fn tab_picks_an_advertised_transport_and_enter_carries_it() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.vta.advertised = vec![Protocol::Tsp, Protocol::Rest]; press(&mut flow, KeyCode::Tab); @@ -1122,7 +1122,7 @@ mod tests { #[test] fn a_single_transport_offers_no_choice() { let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.vta.advertised = vec![Protocol::DidComm]; press(&mut flow, KeyCode::Tab); assert_eq!(flow.vta_acl_instructions.transport, None); @@ -1143,7 +1143,7 @@ mod tests { #[test] fn typing_keeps_the_banner() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.vta_acl_instructions = back_with(ProvisionFailure::NotAuthorised, None); press(&mut flow, KeyCode::Char('x')); assert!(flow.vta_acl_instructions.retry_reason.is_some()); @@ -1155,7 +1155,7 @@ mod tests { fn esc_walks_back_through_the_wizard_keeping_the_input() { use crate::ui::component::Component as _; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state.active_page = SetupPage::VtaAclInstructions; flow.vta_acl_instructions.context_id = Input::new("my-ctx".to_string()); let esc = KeyEvent { diff --git a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs index 2037dc8b..eb269bcc 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs @@ -441,7 +441,7 @@ mod tests { fn flow(state: SetupState) -> (SetupFlow, UnboundedReceiver) { let (tx, rx) = unbounded_channel(); - let mut flow = SetupFlow::new(&State::default(), tx); + let mut flow = SetupFlow::new(&std::sync::Arc::new(State::default()), tx); flow.props.state = state; (flow, rx) } diff --git a/openvtc/src/ui/shared.rs b/openvtc/src/ui/shared.rs new file mode 100644 index 00000000..b10862ee --- /dev/null +++ b/openvtc/src/ui/shared.rs @@ -0,0 +1,108 @@ +//! A page's view of the published [`State`] without copying it (#556). +//! +//! The state handler publishes an `Arc`. A page's props used to clone +//! the part they render out of it on every update — the whole main-page model, +//! the join state — whether or not the page was on screen. [`Shared`] holds the +//! `Arc` instead and derefs to the part, so a props update is a reference-count +//! increment and the page reads its fields exactly as before. +//! +//! A page that edits its props locally (a selection the next update +//! overwrites) still can: the first write copies the part, as every update used +//! to, and only that page's copy changes. + +use std::{ + fmt, + ops::{Deref, DerefMut}, + sync::Arc, +}; + +use crate::state_handler::state::State; + +/// One part of a published [`State`], borrowed from the shared snapshot. +pub struct Shared { + state: Arc, + part: fn(&State) -> &T, + /// A local copy, made by the first write through [`DerefMut`]. + edited: Option>, +} + +impl Shared { + pub fn new(state: &Arc, part: fn(&State) -> &T) -> Self { + Shared { + state: Arc::clone(state), + part, + edited: None, + } + } +} + +impl Deref for Shared { + type Target = T; + + fn deref(&self) -> &T { + match &self.edited { + Some(edited) => edited, + None => (self.part)(&self.state), + } + } +} + +impl DerefMut for Shared { + fn deref_mut(&mut self) -> &mut T { + let part = self.part; + let state = &self.state; + self.edited + .get_or_insert_with(|| Box::new(part(state).clone())) + } +} + +impl Clone for Shared { + fn clone(&self) -> Self { + Shared { + state: Arc::clone(&self.state), + part: self.part, + edited: self.edited.clone(), + } + } +} + +impl fmt::Debug for Shared { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + (**self).fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_part_reads_through_to_the_snapshot_without_copying_it() { + let mut state = State::default(); + state.main_page.log("seen through the share"); + let state = Arc::new(state); + let part = Shared::new(&state, |s| &s.main_page); + assert_eq!(Arc::strong_count(&state), 2, "shared, not copied"); + assert!( + part.activity_log + .iter() + .any(|e| e.summary.contains("seen through the share")) + ); + let again = part.clone(); + assert_eq!(Arc::strong_count(&state), 3); + assert!(std::ptr::eq(&*part, &*again)); + } + + #[test] + fn a_local_edit_copies_the_part_and_leaves_the_snapshot_alone() { + let state = Arc::new(State::default()); + let mut part = Shared::new(&state, |s| &s.main_page); + part.log("only this page's copy"); + assert!( + part.activity_log + .iter() + .any(|e| e.summary.contains("only this page's copy")) + ); + assert!(state.main_page.activity_log.is_empty()); + } +}