Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Changed

- **Inbound dispatch and the join protocol live in `openvtc-core`**
([#530](https://github.com/OpenVTC/openvtc/issues/530)). `process_inbound_message` and
what it reports (`InboundEffects`, the off-loop `VerifyJob`s) moved from the CLI to
`openvtc_core::inbound`; relationship-DID key creation to
`openvtc_core::relationships::keys`; the join sequence's protocol steps (manifest over
HTTP, context choice, submit transport, signed submit, pending record, persona
rollback) to `openvtc_core::join::sequence`, and the join status poll to
`openvtc_core::join::status_poll`. The TUI and `openvtc community join` now send a join
through the same code. `openvtc community join` prints a line when the submit goes
over a different transport than the community prefers, as the TUI already did.
- **One registry decides which inbound message types are accepted and who handles them**
([#531](https://github.com/OpenVTC/openvtc/issues/531)). The transport's accept gate
and the dispatcher are both derived from `openvtc_core::inbound::registry`, so a
handler cannot exist for a type the gate drops. The gate no longer admits whole
namespaces no handler claims (any `spec/vtc/*`, the legacy `openvtc/vtc/*` authority,
any `linuxfoundation.org/openvtc/*`): those messages were already ignored by the
dispatcher as unknown, and are now dropped at the gate instead, still logged at `warn`.
- **Remote failures carry a typed class** ([#536](https://github.com/OpenVTC/openvtc/issues/536)).
A VTA call that fails now reports whether the VTA was unreachable, timed out, refused
our credentials, answered in a shape this client cannot read, did not know the thing,
refused with a reason, or failed on its side — in the message (e.g. `VTA timed out:
…` where it used to say `VTA Error: …`) and as data. Startup's "couldn't reach the
VTA" guidance follows the class.
- **Library API** (`openvtc-core`, additive unless noted): `inbound` (`process_inbound_message`,
`apply_inline`, `InboundEffects`, `ThreadedReply`, `VerifyJob`, `PreVerified`,
`Arrival`, `Deferred`, `registry::{ROUTES, Family, TypeMatcher, Route, classify}`);
`relationships::keys`; `join::sequence`; `join::status_poll`; `vta_task::{retry,
retryable, MAX_ATTEMPTS}`; `capabilities::CAPABILITY_TYPE_PREFIX`;
`OpenVTCError::Remote` with `RemoteService`, `RemoteKind`, `ErrorClass`,
`OpenVTCError::{vta, remote, class, remote_kind}` and `errors::class_of`;
`DiscoverError::kind` and `MessagingError::class`; a `test-support` feature (test-only
hooks). **Removed:** `didcomm::OPENVTC_CATCH_ALL_PATTERN` (the gate is the registry
now; `didcomm::routes_inbound_type` still answers whether a type is admitted). Errors
that were `OpenVTCError::Vta(String)` from VTA calls are now `OpenVTCError::Remote`.

- **Faster startup, sends and `health`** ([#557](https://github.com/OpenVTC/openvtc/issues/557),
[#559](https://github.com/OpenVTC/openvtc/issues/559),
[#560](https://github.com/OpenVTC/openvtc/issues/560)). A persona's VTA-held keys are
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ qrcode = { version = "0.14", default-features = false }
# rand_core 0.6 traits; our `OsRng` is handed to it directly. Moves with `pgp`.
rand = "0.8"
ratatui = "0.30"
regex = "1.12"
secrecy = { version = "0.10", features = ["serde"] }
serde = "1.0"
serde_json = "1.0"
Expand Down
19 changes: 13 additions & 6 deletions docs/design/tui-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`):
|--------|------|
| `actions/` | The `Action` enum + domain sub-enums (`InboxAction`, `RelationshipAction`, `CredentialAction`, `SettingsAction`). |
| `state.rs` | The `State` struct + sub-states (`MainPageState`, `ConnectionState`, `MediatorStatus`, `ActivePage`, `LoadingProgress`). |
| `message_dispatch.rs` | Inbound DIDComm orchestration: `process_inbound_message` (async I/O only). |
| `background_dispatch.rs` | Off-loop network dispatch plumbing (see §4). |
| `save_coalesce.rs` | Coalesced, off-runtime `Config` persistence (see §4). |
| `agent_name_refresh.rs` | Off-loop batch resolution of agent names (DID→name), applied via background-dispatch (see §4). |
Expand All @@ -87,7 +86,10 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`):
credential-issue), VRC vetting/proof verification, and the replay guard
(`SeenMessages`) were moved to
[`openvtc-core/src/messaging.rs`](../../openvtc-core/src/messaging.rs) so they are
testable without the TUI crate; `message_dispatch.rs` imports and calls into them.
testable without the TUI crate. Inbound dispatch itself (`process_inbound_message`,
`InboundEffects`, the off-loop `VerifyJob`s) lives in
[`openvtc-core/src/inbound/`](../../openvtc-core/src/inbound/mod.rs); the runtime
loop calls it and applies the returned `InboundEffects` to `State`.
The `MessageType` protocol enum lives in
[`openvtc-core/src/lib.rs:171`](../../openvtc-core/src/lib.rs).

Expand Down Expand Up @@ -164,10 +166,15 @@ the verified `(did, name)` results into `Config::agent_names` on the loop thread
2. Put the **pure** handling logic (validation, body parsing, state derivation)
in [`openvtc-core/src/messaging.rs`](../../openvtc-core/src/messaging.rs) so it
is unit-testable without the TUI.
3. Add a `MessageType::…` arm to `process_inbound_message` in
[`state_handler/message_dispatch.rs`](../../openvtc/src/state_handler/message_dispatch.rs),
calling the core handler and returning `Ok(true)` if it mutated `Config` (the
loop then `mark_dirty`s and re-syncs the UI).
3. Add a `Family` variant and its route to the inbound registry
([`openvtc-core/src/inbound/registry.rs`](../../openvtc-core/src/inbound/registry.rs)).
The transport's accept gate and the dispatcher both read that one table, so
the type is admitted exactly when it is routed. The dispatcher's
`match family` is exhaustive: add the family's handler in
[`openvtc-core/src/inbound/mod.rs`](../../openvtc-core/src/inbound/mod.rs),
calling the core logic and returning `Ok(true)` if it mutated `Config` (the
loop then `mark_dirty`s and re-syncs the UI). Add the type to the census in
`openvtc-core/tests/inbound_gate_census.rs`.
4. If sending the message is user-initiated, add the corresponding `Action` +
key (recipes above) and build the outbound message with
`openvtc_core::messaging::build_didcomm_message`.
2 changes: 1 addition & 1 deletion docs/design/vetting-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ Throttling:

**Where the gate lives.**
- **V0:** tickets are stored in `ProtectedConfig` (per community, like
`tasks`). The gate runs in `openvtc/src/state_handler/message_dispatch.rs` before a
`tasks`). The gate runs in `openvtc-core/src/inbound/mod.rs` before a
`TaskType` is created.
- **V1:** tickets move to the vetter's VTA (`vetting/tickets/*`). The gate
runs as `vta-policy` Rego on the inbound task URI, so rejected requests
Expand Down
12 changes: 7 additions & 5 deletions openvtc-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ openpgp-card = [
# (issue #124). Off by default; nightly fuzz builds opt in. Pulls chrono's
# own `arbitrary` feature for the `LogMessage` timestamp.
arbitrary = ["dep:arbitrary", "chrono/arbitrary"]
# Test-only hooks other crates' tests need (e.g. inbound `VerifyJob`s that
# hang or panic, for the CLI's verify-queue tests). Never enabled by a
# release build; the CLI turns it on from `[dev-dependencies]` only.
test-support = []

[dependencies]
# The commit signer the Repos panel sets up for a persona (`git_signing`): its
Expand Down Expand Up @@ -63,9 +67,6 @@ affinidi-messaging-delivery = { workspace = true }
affinidi-messaging-core = { workspace = true }
affinidi-messaging-sdk = { workspace = true }
futures-util = { workspace = true }
# The inbound dispatcher compiles `OPENVTC_CATCH_ALL_PATTERN` to decide which
# message types reach the state handler; the delivery layer has no type routing.
regex = { workspace = true }
agent-names.workspace = true
base64.workspace = true
bip39.workspace = true
Expand Down Expand Up @@ -133,8 +134,9 @@ ed25519-dalek-bip32 = { workspace = true }
secrecy = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
# `net`: the loopback listener in the `health` tests.
tokio = { workspace = true, features = ["net"] }
# `net`: the loopback listener in the `health` tests. `test-util`: the paused
# clock for the VTA retry back-off tests.
tokio = { workspace = true, features = ["net", "test-util"] }
# Mock HTTP server for the agent-name resolve→verify e2e: it serves the
# `/@name` → 302 → DID redirect the `HttpRedirectResolver` follows, so the whole
# chain is exercised without a live host.
Expand Down
4 changes: 4 additions & 0 deletions openvtc-core/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ pub use trust_tasks_capability_client::{
correlation_thread, parse_capability_reply, parse_envelope_document, parse_envelope_reply,
};

/// The type-URI prefix every `governance/capability/*` document shares: the
/// answers this client routes to the capability view, in every version.
pub const CAPABILITY_TYPE_PREFIX: &str = "https://trusttasks.org/spec/governance/capability/";

/// A request document as the builders here (and [`crate::git_ns::build`])
/// return it — named so a caller can hold one between building and sending
/// without depending on `trust-tasks-rs` itself.
Expand Down
2 changes: 1 addition & 1 deletion openvtc-core/src/community_rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub use offers::OfferV2 as Offer;
pub use offers::Renewal;

/// Every type this module sends or reads starts with this.
const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/rooms/";
pub(crate) const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/rooms/";

/// Longest text from the community kept for display.
const MAX_PEER_TEXT: usize = 512;
Expand Down
76 changes: 42 additions & 34 deletions openvtc-core/src/config/community_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,10 @@ pub async fn ensure_context(
Ok(_) => continue,
Err(VtaError::NotFound(_)) => {}
Err(e) => {
return Err(OpenVTCError::Vta(format!(
"could not look up context {path}: {e}"
)));
return Err(OpenVTCError::vta(
format!("could not look up context {path}"),
&e,
));
}
}
let Some((parent, slug)) = parse_sub_context_id(path) else {
Expand All @@ -318,9 +319,10 @@ pub async fn ensure_context(
Ok(_) => created = true,
Err(VtaError::Conflict(_)) => {}
Err(e) => {
return Err(OpenVTCError::Vta(format!(
"could not create context {path}: {e}"
)));
return Err(OpenVTCError::vta(
format!("could not create context {path}"),
&e,
));
}
}
}
Expand Down Expand Up @@ -750,25 +752,28 @@ pub(crate) fn vta_failure(action: &str, e: VtaError) -> OpenVTCError {
// Not "could not reach": the request was sent, and it is the reply that
// is missing — which, repeated, is a session whose inbox is not being
// collected, with its own remedy (R6.4, `vta_receive_leg`).
ref e if crate::vta_receive_leg::is_reply_timeout(e) => OpenVTCError::Vta(format!(
"the request to {action} was sent, but no reply from your VTA reached this app \
in time: {e}"
)),
ref e if crate::vta_receive_leg::is_reply_timeout(e) => OpenVTCError::vta(
format!(
"the request to {action} was sent, but no reply from your VTA reached this app \
in time"
),
e,
),
VtaError::Network(_) | VtaError::DidcommTransport(_) | VtaError::TspTransport(_) => {
OpenVTCError::Vta(format!("could not reach your VTA to {action}: {e}"))
OpenVTCError::vta(format!("could not reach your VTA to {action}"), &e)
}
VtaError::Server { .. } => {
OpenVTCError::Vta(format!("your VTA failed while trying to {action}: {e}"))
OpenVTCError::vta(format!("your VTA failed while trying to {action}"), &e)
}
VtaError::Auth(_) => OpenVTCError::Auth(format!(
"your VTA did not accept this session to {action}: {e}"
)),
VtaError::Forbidden(_) => OpenVTCError::Auth(format!(
"your VTA does not allow this session to {action}: {e}"
)),
other => OpenVTCError::Vta(format!(
"your VTA rejected the request to {action}: {other}"
)),
VtaError::Auth(_) => OpenVTCError::vta(
format!("your VTA did not accept this session to {action}"),
&e,
),
VtaError::Forbidden(_) => OpenVTCError::vta(
format!("your VTA does not allow this session to {action}"),
&e,
),
other => OpenVTCError::vta(format!("your VTA rejected the request to {action}"), &other),
}
}

Expand Down Expand Up @@ -1353,29 +1358,32 @@ mod tests {
/// Unreachable, not accepted, and refused read differently.
#[test]
fn a_vta_failure_says_which_kind_it_was() {
let unreachable = vta_failure("x", VtaError::DidcommTransport("timeout".into()));
assert!(matches!(unreachable, OpenVTCError::Vta(ref m) if m.contains("could not reach")));
use crate::errors::ErrorClass;
let unreachable = vta_failure("x", VtaError::DidcommTransport("refused".into()));
assert_eq!(unreachable.class(), ErrorClass::Unreachable);
assert!(unreachable.to_string().contains("could not reach"));
let auth = vta_failure("x", VtaError::Auth("expired".into()));
assert!(matches!(auth, OpenVTCError::Auth(ref m) if m.contains("did not accept")));
assert_eq!(auth.class(), ErrorClass::AuthRejected);
assert!(auth.to_string().contains("did not accept"));
let forbidden = vta_failure("x", VtaError::Forbidden("no".into()));
assert!(matches!(forbidden, OpenVTCError::Auth(ref m) if m.contains("does not allow")));
assert_eq!(forbidden.class(), ErrorClass::AuthRejected);
assert!(forbidden.to_string().contains("does not allow"));
let rejected = vta_failure("x", VtaError::Validation("bad".into()));
assert!(matches!(rejected, OpenVTCError::Vta(ref m) if m.contains("rejected")));
assert_eq!(rejected.class(), ErrorClass::Refused);
assert!(rejected.to_string().contains("rejected"));
// A reply timeout is none of the three: the request was sent.
for timeout in [
VtaError::TspTransport(
"timed out waiting for the TSP reply to request 'urn:uuid:1'".into(),
),
VtaError::DidcommTransport("timeout waiting for DIDComm response".into()),
] {
match vta_failure("x", timeout) {
OpenVTCError::Vta(m) => {
assert!(m.contains("was sent, but no reply"), "{m}");
assert!(!m.contains("could not reach"), "{m}");
assert!(!m.contains("rejected"), "{m}");
}
other => panic!("expected Vta, got {other:?}"),
}
let e = vta_failure("x", timeout);
assert_eq!(e.class(), ErrorClass::Timeout);
let m = e.to_string();
assert!(m.contains("was sent, but no reply"), "{m}");
assert!(!m.contains("could not reach"), "{m}");
assert!(!m.contains("rejected"), "{m}");
}
}

Expand Down
10 changes: 5 additions & 5 deletions openvtc-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,15 +1581,15 @@ async fn enable_tsp_with_resolver(
/// callers can keep distinguishing retryable transport/auth failures from
/// genuine config corruption (R18). An empty `vta_url` on the REST path comes
/// back as [`vta_sdk::error::VtaError::Validation`] — that is a bad on-disk
/// config, so it maps to [`OpenVTCError::Config`]; auth rejection maps to
/// [`OpenVTCError::Auth`]; everything else (network, DIDComm session open) is a
/// live-VTA reachability problem, [`OpenVTCError::Vta`].
/// config, so it maps to [`OpenVTCError::Config`]; everything else is a live-VTA
/// failure, [`OpenVTCError::Remote`] classified from the SDK error (auth
/// rejected, unreachable, timed out, …).
fn map_connect_error(e: vta_sdk::error::VtaError) -> OpenVTCError {
use vta_sdk::error::VtaError;
match e {
VtaError::Validation(msg) => OpenVTCError::Config(msg),
VtaError::Auth(msg) => OpenVTCError::Auth(format!("VTA authentication failed: {msg}")),
other => OpenVTCError::Vta(format!("VTA connection failed: {other}")),
other @ VtaError::Auth(_) => OpenVTCError::vta("VTA authentication failed", &other),
other => OpenVTCError::vta("VTA connection failed", &other),
}
}

Expand Down
24 changes: 17 additions & 7 deletions openvtc-core/src/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,19 @@ pub async fn register(
Ok(response) => response,
Err(e) if is_already_registered(&e) => return Ok(Registration::AlreadyRegistered),
Err(e) => {
return Err(OpenVTCError::Vta(format!(
"device registration failed: {e}"
)));
return Err(OpenVTCError::vta("device registration failed", &e));
}
};

parse_binding(&response)
.map(Registration::Claimed)
.ok_or_else(|| OpenVTCError::Vta("device registration returned no binding".to_string()))
.ok_or_else(|| {
OpenVTCError::remote(
crate::errors::RemoteService::Vta,
crate::errors::RemoteKind::Contract,
"device registration returned no binding",
)
})
}

/// Whether a `device/register` failure is "this DID is already registered".
Expand Down Expand Up @@ -291,7 +295,7 @@ pub async fn heartbeat(
.await,
)
.map(|_| ())
.map_err(|e| OpenVTCError::Vta(format!("device heartbeat failed: {e}")))
.map_err(|e| OpenVTCError::vta("device heartbeat failed", &e))
}

/// Whether `row` — this install's own binding — is showing a stale name.
Expand Down Expand Up @@ -320,12 +324,18 @@ pub async fn list(
) -> Result<Vec<DeviceRecord>, OpenVTCError> {
let response = receive
.observe(client.device_list(serde_json::json!({})).await)
.map_err(|e| OpenVTCError::Vta(format!("device list failed: {e}")))?;
.map_err(|e| OpenVTCError::vta("device list failed", &e))?;

let devices = response
.get("devices")
.and_then(|d| d.as_array())
.ok_or_else(|| OpenVTCError::Vta("device list returned no `devices` array".to_string()))?;
.ok_or_else(|| {
OpenVTCError::remote(
crate::errors::RemoteService::Vta,
crate::errors::RemoteKind::Contract,
"device list returned no `devices` array",
)
})?;

// One unparseable row must not lose the rest of the listing: this is a
// diagnostic surface, and a partial answer beats none.
Expand Down
Loading
Loading