From cad9f16c373c32dd70df612df827d6201c0b5427 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 16:43:33 -0400 Subject: [PATCH 01/19] =?UTF-8?q?feat(beltic):=20add=20houston-beltic=20cr?= =?UTF-8?q?ate=20=E2=80=94=20REST=20client,=20issuer,=20JWT-VC=20verifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New transport-neutral Rust crate wrapping Beltic's Credentials API. Models on houston-composio (third-party-integration reference). 35 inline tests, all green; no Tauri / React / axum coupling. - errors.rs — typed BelticError mirroring Beltic's nested `{ error: {code, message} }` envelope; from_envelope() mapper; is_retryable() for backoff - config.rs — Configuration::from_env(); jwks_url() / status_list_url() derived from BELTIC_BASE_URL so local Beltic just works - client.rs — reqwest wrapper with X-Api-Key, JSON in/out, parses the actual Beltic error envelope - issuer.rs — issue / revoke / get + client-side FinCEN guard: delegated_by_subject_id required on any wallet-scoped agent permission - webhook_verifier.rs — Stripe-pattern Beltic-Signature: sha256= + Beltic-Timestamp with 300s replay window; constant-time compare - verifier/ — split into mod/jwks/status_list/policy (200-line rule): - jwks: cache w/ Cache-Control max-age, ES256 P-256 signature verify - status_list: W3C Status List 2021 gzip + base64url bit lookup - policy: lte/lt/gte/gt/eq/neq/in operators on permission conditions --- Cargo.lock | 88 +++++++ Cargo.toml | 2 + engine/houston-beltic/Cargo.toml | 38 +++ engine/houston-beltic/src/client.rs | 228 ++++++++++++++++++ engine/houston-beltic/src/config.rs | 207 ++++++++++++++++ engine/houston-beltic/src/errors.rs | 139 +++++++++++ engine/houston-beltic/src/issuer.rs | 224 +++++++++++++++++ engine/houston-beltic/src/lib.rs | 29 +++ engine/houston-beltic/src/verifier/jwks.rs | 125 ++++++++++ engine/houston-beltic/src/verifier/mod.rs | 127 ++++++++++ engine/houston-beltic/src/verifier/policy.rs | 158 ++++++++++++ .../src/verifier/status_list.rs | 153 ++++++++++++ engine/houston-beltic/src/webhook_verifier.rs | 219 +++++++++++++++++ 13 files changed, 1737 insertions(+) create mode 100644 engine/houston-beltic/Cargo.toml create mode 100644 engine/houston-beltic/src/client.rs create mode 100644 engine/houston-beltic/src/config.rs create mode 100644 engine/houston-beltic/src/errors.rs create mode 100644 engine/houston-beltic/src/issuer.rs create mode 100644 engine/houston-beltic/src/lib.rs create mode 100644 engine/houston-beltic/src/verifier/jwks.rs create mode 100644 engine/houston-beltic/src/verifier/mod.rs create mode 100644 engine/houston-beltic/src/verifier/policy.rs create mode 100644 engine/houston-beltic/src/verifier/status_list.rs create mode 100644 engine/houston-beltic/src/webhook_verifier.rs diff --git a/Cargo.lock b/Cargo.lock index 3e33719c4..1bca418e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,6 +1355,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2343,6 +2344,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -2431,6 +2441,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "houston-beltic" +version = "0.4.12" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "flate2", + "hex", + "hmac", + "jsonwebtoken", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", +] + [[package]] name = "houston-claude-installer" version = "0.4.12" @@ -3282,6 +3314,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -4018,6 +4065,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4035,6 +4092,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4474,6 +4540,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6117,6 +6193,18 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" diff --git a/Cargo.toml b/Cargo.toml index f2f314c6f..dda10edcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "engine/houston-agents-conversations", "engine/houston-file-watcher", "engine/houston-composio", + "engine/houston-beltic", "engine/houston-cli-bundle", "engine/houston-claude-installer", "engine/houston-engine-core", @@ -46,6 +47,7 @@ houston-ui-events = { version = "0.4.12", path = "engine/houston-ui-events" } houston-agents-conversations = { version = "0.4.12", path = "engine/houston-agents-conversations" } houston-file-watcher = { version = "0.4.12", path = "engine/houston-file-watcher" } houston-composio = { version = "0.4.12", path = "engine/houston-composio" } +houston-beltic = { version = "0.4.12", path = "engine/houston-beltic" } houston-cli-bundle = { version = "0.4.12", path = "engine/houston-cli-bundle" } houston-claude-installer = { version = "0.4.12", path = "engine/houston-claude-installer" } houston-engine-core = { version = "0.4.12", path = "engine/houston-engine-core" } diff --git a/engine/houston-beltic/Cargo.toml b/engine/houston-beltic/Cargo.toml new file mode 100644 index 000000000..96c1ebd4d --- /dev/null +++ b/engine/houston-beltic/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "houston-beltic" +version = "0.4.12" +edition = "2021" +description = "Beltic verifiable-credentials integration — REST client, issuer, JWT-VC verifier, webhook verifier (transport-neutral)" +license = "MIT" +repository = "https://github.com/gethouston/houston" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +async-trait = { workspace = true } +thiserror = "1" + +# HTTP client (matches version pinned in houston-composio) +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# JWT-VC verification (ES256 / P-256) +jsonwebtoken = "9" + +# Webhook HMAC (Beltic-Signature: sha256=) +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +subtle = "2" + +# Status List 2021 — base64url + gzip bitstring +base64 = "0.22" +flate2 = "1" + +# URL parsing for the jwks/status-list URL derivation +url = "2" + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/engine/houston-beltic/src/client.rs b/engine/houston-beltic/src/client.rs new file mode 100644 index 000000000..53531506a --- /dev/null +++ b/engine/houston-beltic/src/client.rs @@ -0,0 +1,228 @@ +//! HTTP client for Beltic's Credentials API. +//! +//! Thin wrapper around `reqwest::Client` that: +//! - injects `X-Api-Key` +//! - parses Beltic's nested error envelope `{ "error": { "code", "message" } }` +//! into typed `BelticError` variants +//! - exposes generic `post_json` / `get_json` / `delete_json` so per-resource +//! methods on `Issuer` stay short +//! +//! Retries are NOT inside this client — call sites (e.g., a background job +//! that drives credential issuance) own backoff policy. The client returns +//! `BelticError::is_retryable()` so callers can decide. + +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE, USER_AGENT}; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::config::Configuration; +use crate::errors::{BelticError, BelticResult}; + +#[derive(Debug, Clone)] +pub struct Client { + inner: reqwest::Client, + config: Configuration, +} + +impl Client { + pub fn new(config: Configuration) -> BelticResult { + if !config.configured() { + return Err(BelticError::Configuration( + "api_key not set — set BELTIC_API_KEY before constructing the client".into(), + )); + } + let inner = build_reqwest_client(&config)?; + Ok(Self { inner, config }) + } + + pub fn config(&self) -> &Configuration { + &self.config + } + + pub async fn post_json(&self, path: &str, body: &Req) -> BelticResult + where + Req: Serialize + ?Sized, + Res: DeserializeOwned, + { + let response = self + .inner + .post(self.url(path)) + .json(body) + .send() + .await + .map_err(transport_err)?; + handle_response(response).await + } + + pub async fn get_json(&self, path: &str) -> BelticResult + where + Res: DeserializeOwned, + { + let response = self + .inner + .get(self.url(path)) + .send() + .await + .map_err(transport_err)?; + handle_response(response).await + } + + pub async fn delete_json(&self, path: &str) -> BelticResult + where + Res: DeserializeOwned, + { + let response = self + .inner + .delete(self.url(path)) + .send() + .await + .map_err(transport_err)?; + handle_response(response).await + } + + fn url(&self, path: &str) -> String { + let base = self.config.base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + format!("{base}/{path}") + } +} + +fn build_reqwest_client(config: &Configuration) -> BelticResult { + let mut headers = HeaderMap::new(); + let api_key_value = HeaderValue::from_str(config.api_key.as_deref().unwrap_or("")) + .map_err(|e| BelticError::Configuration(format!("api_key not a valid header: {e}")))?; + let api_header_name = HeaderName::from_static("x-api-key"); + headers.insert(api_header_name, api_key_value); + headers.insert( + USER_AGENT, + HeaderValue::from_static("houston-engine houston-beltic"), + ); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + reqwest::Client::builder() + .default_headers(headers) + .timeout(config.request_timeout) + .connect_timeout(config.open_timeout) + .build() + .map_err(|e| BelticError::Configuration(format!("failed to build reqwest client: {e}"))) +} + +fn transport_err(e: reqwest::Error) -> BelticError { + BelticError::Transport(e.to_string()) +} + +async fn handle_response(response: reqwest::Response) -> BelticResult { + let status = response.status(); + let body_bytes = response.bytes().await.map_err(transport_err)?; + + if status.is_success() { + return serde_json::from_slice::(&body_bytes).map_err(|e| { + BelticError::BadResponseBody(format!( + "could not decode success body: {e} (body: {})", + preview(&body_bytes), + )) + }); + } + + // Try to parse Beltic's nested error envelope. Fall back to a synthetic + // error if the body isn't JSON (rare but possible on infra layer like + // nginx 504 / API Gateway 503). + match serde_json::from_slice::(&body_bytes) { + Ok(wire) => { + let code = wire.error.code; + let message = wire.error.message; + Err(BelticError::from_envelope(status.as_u16(), &code, &message)) + } + Err(_) => Err(BelticError::Client { + code: format!("http_{}", status.as_u16()), + message: preview(&body_bytes), + }), + } +} + +fn preview(bytes: &[u8]) -> String { + let s = String::from_utf8_lossy(bytes); + if s.len() <= 256 { + s.into_owned() + } else { + format!("{}…", &s[..256]) + } +} + +/// Wire shape of Beltic's nested error envelope. We accept either +/// `details` or `request_id` being null/absent. +#[derive(Debug, serde::Deserialize)] +struct EnvelopeWire { + error: EnvelopeBody, +} + +#[derive(Debug, serde::Deserialize)] +struct EnvelopeBody { + code: String, + message: String, + #[serde(default)] + #[allow(dead_code)] + request_id: Option, + #[serde(default)] + #[allow(dead_code)] + details: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_construction_without_api_key() { + let cfg = Configuration::default(); + let err = Client::new(cfg).unwrap_err(); + assert!(matches!(err, BelticError::Configuration(_))); + } + + #[test] + fn url_joins_path_without_double_slash() { + let cfg = Configuration { + api_key: Some("sk_test_xxx".into()), + base_url: "http://localhost:8080/v1".into(), + ..Default::default() + }; + let client = Client::new(cfg).unwrap(); + assert_eq!( + client.url("/credentials"), + "http://localhost:8080/v1/credentials" + ); + // Without leading slash on path + assert_eq!( + client.url("credentials/cred_abc"), + "http://localhost:8080/v1/credentials/cred_abc" + ); + // Without trailing slash on base + let cfg2 = Configuration { + api_key: Some("sk_test_xxx".into()), + base_url: "http://localhost:8080/v1/".into(), + ..Default::default() + }; + let client = Client::new(cfg2).unwrap(); + assert_eq!( + client.url("/credentials"), + "http://localhost:8080/v1/credentials" + ); + } + + #[test] + fn beltic_unused_field_warnings_kept_off() { + // Compile-only check: EnvelopeBody parses successfully when + // `details` and `request_id` are absent. + let raw = br#"{"error":{"code":"validation_failed","message":"x"}}"#; + let env: EnvelopeWire = serde_json::from_slice(raw).unwrap(); + assert_eq!(env.error.code, "validation_failed"); + } + + #[test] + fn preview_truncates_long_bodies() { + let long = vec![b'a'; 1024]; + let p = preview(&long); + assert!(p.ends_with('…')); + assert!(p.len() < 1024); + } +} diff --git a/engine/houston-beltic/src/config.rs b/engine/houston-beltic/src/config.rs new file mode 100644 index 000000000..4a3899563 --- /dev/null +++ b/engine/houston-beltic/src/config.rs @@ -0,0 +1,207 @@ +//! Runtime configuration for the Beltic client. +//! +//! Constructed from ENV at process start in `houston-engine-server`. The +//! `jwks_url` and `status_list_url` defaults are derived from `base_url`, +//! so pointing the integration at a local Beltic platform via +//! `BELTIC_BASE_URL=http://localhost:8080/v1` just works — no need to +//! override the well-known URLs separately. + +use std::time::Duration; + +use url::Url; + +use crate::errors::{BelticError, BelticResult}; + +pub const DEFAULT_BASE_URL: &str = "https://api.beltic.com/v1"; +pub const DEFAULT_ISSUER_DID: &str = "did:web:beltic.com"; +const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 10; +const DEFAULT_OPEN_TIMEOUT_SECS: u64 = 5; + +#[derive(Debug, Clone)] +pub struct Configuration { + pub api_key: Option, + pub base_url: String, + pub webhook_secret: Option, + pub org_credential_id: Option, + pub org_subject_id: Option, + pub issuer_did: String, + pub jwks_url_override: Option, + pub status_list_url_override: Option, + pub request_timeout: Duration, + pub open_timeout: Duration, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + api_key: None, + base_url: DEFAULT_BASE_URL.to_string(), + webhook_secret: None, + org_credential_id: None, + org_subject_id: None, + issuer_did: DEFAULT_ISSUER_DID.to_string(), + jwks_url_override: None, + status_list_url_override: None, + request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + open_timeout: Duration::from_secs(DEFAULT_OPEN_TIMEOUT_SECS), + } + } +} + +impl Configuration { + /// Construct from environment variables. Returns defaults when vars are + /// unset — `configured()` reports whether `api_key` is actually present. + pub fn from_env() -> Self { + let mut cfg = Self::default(); + if let Ok(v) = std::env::var("BELTIC_API_KEY") { + cfg.api_key = Some(v); + } + if let Ok(v) = std::env::var("BELTIC_BASE_URL") { + cfg.base_url = v; + } + if let Ok(v) = std::env::var("BELTIC_WEBHOOK_SECRET") { + cfg.webhook_secret = Some(v); + } + if let Ok(v) = std::env::var("BELTIC_ORG_CREDENTIAL_ID") { + cfg.org_credential_id = Some(v); + } + if let Ok(v) = std::env::var("BELTIC_ORG_SUBJECT_ID") { + cfg.org_subject_id = Some(v); + } + if let Ok(v) = std::env::var("BELTIC_ISSUER_DID") { + cfg.issuer_did = v; + } + if let Ok(v) = std::env::var("BELTIC_JWKS_URL") { + cfg.jwks_url_override = Some(v); + } + if let Ok(v) = std::env::var("BELTIC_STATUS_LIST_URL") { + cfg.status_list_url_override = Some(v); + } + cfg + } + + pub fn configured(&self) -> bool { + self.api_key.as_deref().is_some_and(|s| !s.is_empty()) + } + + /// JWKS endpoint URL. Derived from `base_url` (stripping the `/v1` + /// suffix and joining `/.well-known/jwks.json`) unless explicitly + /// overridden. + pub fn jwks_url(&self) -> BelticResult { + if let Some(v) = &self.jwks_url_override { + return Ok(v.clone()); + } + Ok(well_known(&self.base_url, "jwks.json")?) + } + + /// Status List 2021 endpoint URL. Same derivation as `jwks_url`. + pub fn status_list_url(&self) -> BelticResult { + if let Some(v) = &self.status_list_url_override { + return Ok(v.clone()); + } + Ok(well_known(&self.base_url, "status-lists/v1")?) + } +} + +/// Strip a versioned API path suffix (`/v1`, `/v2`, …) from `base_url` +/// and join `.well-known/` onto the origin. So +/// `https://api.beltic.com/v1` + `jwks.json` → `https://api.beltic.com/.well-known/jwks.json`. +fn well_known(base_url: &str, suffix: &str) -> BelticResult { + let mut parsed = Url::parse(base_url) + .map_err(|e| BelticError::Configuration(format!("invalid base_url: {e}")))?; + + // Strip a leading "/vN[/]" segment if present, leaving the bare origin. + let mut path = parsed.path().trim_end_matches('/').to_string(); + if let Some(rest) = path.strip_prefix('/') { + if let Some(first_seg) = rest.split('/').next() { + if first_seg.starts_with('v') + && first_seg.len() > 1 + && first_seg[1..].chars().all(|c| c.is_ascii_digit()) + { + path = rest[first_seg.len()..].to_string(); + if !path.starts_with('/') { + path = format!("/{path}"); + } + } + } + } + parsed.set_path(&format!( + "{}/.well-known/{}", + path.trim_end_matches('/'), + suffix + )); + Ok(parsed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_well_known_from_versioned_base() { + let cfg = Configuration { + base_url: "https://api.beltic.com/v1".into(), + ..Default::default() + }; + assert_eq!( + cfg.jwks_url().unwrap(), + "https://api.beltic.com/.well-known/jwks.json" + ); + assert_eq!( + cfg.status_list_url().unwrap(), + "https://api.beltic.com/.well-known/status-lists/v1" + ); + } + + #[test] + fn derives_well_known_from_local_base() { + let cfg = Configuration { + base_url: "http://localhost:8080/v1".into(), + ..Default::default() + }; + assert_eq!( + cfg.jwks_url().unwrap(), + "http://localhost:8080/.well-known/jwks.json" + ); + } + + #[test] + fn override_wins_over_derivation() { + let cfg = Configuration { + base_url: "https://api.beltic.com/v1".into(), + jwks_url_override: Some("https://custom.example.com/jwks".into()), + ..Default::default() + }; + assert_eq!(cfg.jwks_url().unwrap(), "https://custom.example.com/jwks"); + } + + #[test] + fn handles_base_without_version_suffix() { + let cfg = Configuration { + base_url: "https://api.beltic.com".into(), + ..Default::default() + }; + assert_eq!( + cfg.jwks_url().unwrap(), + "https://api.beltic.com/.well-known/jwks.json" + ); + } + + #[test] + fn configured_requires_non_empty_api_key() { + let cfg = Configuration::default(); + assert!(!cfg.configured()); + + let cfg = Configuration { + api_key: Some(String::new()), + ..Default::default() + }; + assert!(!cfg.configured()); + + let cfg = Configuration { + api_key: Some("sk_staging_xxx".into()), + ..Default::default() + }; + assert!(cfg.configured()); + } +} diff --git a/engine/houston-beltic/src/errors.rs b/engine/houston-beltic/src/errors.rs new file mode 100644 index 000000000..0906666fc --- /dev/null +++ b/engine/houston-beltic/src/errors.rs @@ -0,0 +1,139 @@ +//! Typed errors mapping Beltic's nested error envelope to local Rust types. +//! +//! Beltic's wire format (verified against `apps/api/credentials` source in +//! the Beltic platform repo): every 4xx/5xx response carries +//! `{ "error": { "code", "message", "details", "request_id" } }`. We unwrap +//! that into one of the variants below so callers can pattern-match instead +//! of grepping strings. + +use thiserror::Error; + +pub type BelticResult = Result; + +#[derive(Debug, Error)] +pub enum BelticError { + /// API key missing or rejected. HTTP 401. + #[error("beltic auth failed: {0}")] + Unauthorized(String), + + /// API key lacks the required scope (e.g., `credentials:write`). HTTP 403. + #[error("beltic forbidden: {0}")] + Forbidden(String), + + /// Resource not found. HTTP 404. + #[error("beltic not found: {0}")] + NotFound(String), + + /// Self-attestation gate not satisfied. HTTP 400 + code + /// `self_attestation_incomplete`. Do NOT retry — the caller flipped the + /// flag without actually completing attestation, or the request is + /// malformed. + #[error("beltic self-attestation gate not satisfied: {0}")] + SelfAttestationIncomplete(String), + + /// Schema validation rejected the request (Zod failure). HTTP 400 + code + /// `validation_failed` / `malformed_request` / `missing_required_field`. + #[error("beltic schema validation failed: {0}")] + SchemaValidation(String), + + /// FinCEN/AML constraint — agent_authorization with wallet permissions + /// requires `claims.delegated_by_subject_id`. We enforce client-side too. + #[error("beltic requires delegated_by_subject_id on wallet-scoped agent permissions")] + DelegationMissing, + + /// Generic 4xx not covered above. + #[error("beltic client error ({code}): {message}")] + Client { code: String, message: String }, + + /// 5xx — `internal_error`, `upstream_error`, `kms_signing_failed`, etc. + #[error("beltic server error ({code}): {message}")] + Server { code: String, message: String }, + + /// Transport-level: timeout, DNS, TLS, etc. Safe to retry. + #[error("beltic transport error: {0}")] + Transport(String), + + /// JSON parse failure on a response body. + #[error("beltic response was not valid JSON: {0}")] + BadResponseBody(String), + + /// Configuration error — typically `api_key` not set. + #[error("beltic not configured: {0}")] + Configuration(String), + + /// Webhook signature failed verification. + #[error("beltic webhook signature invalid: {0}")] + WebhookSignature(String), + + /// JWT-VC verification failed (signature, expiry, revocation, policy). + #[error("beltic verification failed ({reason}): {detail}")] + Verification { + reason: &'static str, + detail: String, + }, +} + +impl BelticError { + /// True if this error class is worth retrying with backoff (transport + + /// 5xx). Schema / attestation / auth errors are NOT retried. + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Transport(_) | Self::Server { .. }) + } + + /// Build a typed error from Beltic's error envelope `{ error: { code, + /// message, ... } }`. Falls back to a generic Client error if the code + /// doesn't match a known taxonomy entry. + pub fn from_envelope(http_status: u16, code: &str, message: &str) -> Self { + match (http_status, code) { + (401, _) => Self::Unauthorized(message.to_string()), + (403, _) => Self::Forbidden(message.to_string()), + (404, _) => Self::NotFound(message.to_string()), + (_, "self_attestation_incomplete") => { + Self::SelfAttestationIncomplete(message.to_string()) + } + (_, "validation_failed" | "malformed_request" | "missing_required_field") => { + Self::SchemaValidation(message.to_string()) + } + (s, c) if (500..=599).contains(&s) => Self::Server { + code: c.to_string(), + message: message.to_string(), + }, + (_, c) => Self::Client { + code: c.to_string(), + message: message.to_string(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_self_attestation_to_typed_variant() { + let err = BelticError::from_envelope(400, "self_attestation_incomplete", "flag not set"); + assert!(matches!(err, BelticError::SelfAttestationIncomplete(_))); + assert!(!err.is_retryable()); + } + + #[test] + fn maps_5xx_to_retryable() { + let err = BelticError::from_envelope(500, "kms_signing_failed", "kms key arn missing"); + assert!(matches!(err, BelticError::Server { .. })); + assert!(err.is_retryable()); + } + + #[test] + fn maps_401_to_unauthorized_regardless_of_code() { + let err = BelticError::from_envelope(401, "unknown_code", "bad key"); + assert!(matches!(err, BelticError::Unauthorized(_))); + } + + #[test] + fn maps_validation_to_schema_error() { + let err = BelticError::from_envelope(400, "validation_failed", "subject.type missing"); + assert!(matches!(err, BelticError::SchemaValidation(_))); + assert!(!err.is_retryable()); + } +} diff --git a/engine/houston-beltic/src/issuer.rs b/engine/houston-beltic/src/issuer.rs new file mode 100644 index 000000000..524b9fc6f --- /dev/null +++ b/engine/houston-beltic/src/issuer.rs @@ -0,0 +1,224 @@ +//! Typed issuance methods for Beltic credentials. +//! +//! Each credential_type has its own constructor that wraps an `IssueRequest` +//! around the right `subject` + `claims` shape. The wire format is verified +//! against `packages/schemas/src/credentials/{business,user,agent-authorization}` +//! in the Beltic platform repo: +//! +//! - `subject.type` is the British "organisation" for businesses +//! - `subject.id` for agents MUST be `did:jwk:...` (V1 constraint) +//! - `claims.delegated_by_subject_id` is REQUIRED when any +//! `claims.permissions[].resource_type == "wallet"` (FinCEN AML) +//! +//! We enforce the wallet-permission constraint client-side too so failures +//! surface before the network round-trip. + +use serde::{Deserialize, Serialize}; + +use crate::client::Client; +use crate::errors::{BelticError, BelticResult}; + +/// One issued credential, as returned by Beltic. +/// +/// Field set matches the V1 API response. `claims` is left as a generic +/// `serde_json::Value` so per-type structure stays in the schemas crate +/// rather than being mirrored here. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credential { + pub id: String, + pub credential_id: String, + pub credential_type: String, + #[serde(default)] + pub attestation_type: Option, + pub subject: serde_json::Value, + pub claims: serde_json::Value, + pub issuer_did: String, + pub kid: String, + pub alg: String, + pub proof_format: String, + #[serde(default)] + pub vct: Option, + pub signed_payload: String, + pub status: String, + pub status_list_index: u64, + #[serde(default)] + pub evidence_refs: Vec, + pub issued_at: String, + pub expires_at: String, + #[serde(default)] + pub revoked_at: Option, + #[serde(default)] + pub revocation_reason: Option, + #[serde(default)] + pub created_via: Option, + #[serde(default)] + pub developer_id: Option, +} + +/// Request body for `POST /v1/credentials`. We don't model the discriminated +/// union types explicitly — that lives in the schemas package on the Beltic +/// side. We do validate the `self_attestation_complete` gate + the FinCEN +/// delegation requirement client-side. +#[derive(Debug, Clone, Serialize)] +pub struct IssueRequest { + pub credential_type: String, + pub self_attestation_complete: bool, + pub subject: serde_json::Value, + pub claims: serde_json::Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct RevokeRequest<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a str>, +} + +#[derive(Debug, Clone)] +pub struct Issuer { + client: Client, +} + +impl Issuer { + pub fn new(client: Client) -> Self { + Self { client } + } + + pub async fn issue(&self, request: IssueRequest) -> BelticResult { + if !request.self_attestation_complete { + return Err(BelticError::SelfAttestationIncomplete( + "client-side gate: self_attestation_complete must be true before POSTing".into(), + )); + } + validate_delegation(&request)?; + self.client.post_json::<_, Credential>("/credentials", &request).await + } + + pub async fn revoke(&self, id: &str, reason: Option<&str>) -> BelticResult { + let path = format!("/credentials/{}/revoke", id); + let body = RevokeRequest { reason }; + self.client.post_json::<_, Credential>(&path, &body).await + } + + pub async fn get(&self, id: &str) -> BelticResult { + let path = format!("/credentials/{}", id); + self.client.get_json::(&path).await + } +} + +/// Beltic schema `.superRefine()` requires `claims.delegated_by_subject_id` +/// when any permission has `resource_type == "wallet"` (FinCEN AML). Catch +/// this before the network round-trip. +fn validate_delegation(req: &IssueRequest) -> BelticResult<()> { + if req.credential_type != "agent_authorization" { + return Ok(()); + } + let perms = req + .claims + .get("permissions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let has_wallet = perms.iter().any(|p| { + p.get("resource_type") + .and_then(|v| v.as_str()) + .map(|s| s == "wallet") + .unwrap_or(false) + }); + if !has_wallet { + return Ok(()); + } + let delegated = req + .claims + .get("delegated_by_subject_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + if delegated.is_none() { + return Err(BelticError::DelegationMissing); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn agent_request(claims: serde_json::Value) -> IssueRequest { + IssueRequest { + credential_type: "agent_authorization".into(), + self_attestation_complete: true, + subject: json!({ + "type": "agent", + "id": "did:jwk:abc123", + }), + claims, + evidence_refs: vec![], + ttl: None, + } + } + + #[test] + fn delegation_required_for_wallet_permissions() { + let req = agent_request(json!({ + "permissions": [{ + "resource_type": "wallet", + "actions": ["checkout"] + }] + })); + let err = validate_delegation(&req).unwrap_err(); + assert!(matches!(err, BelticError::DelegationMissing)); + } + + #[test] + fn delegation_satisfied_when_subject_id_present() { + let req = agent_request(json!({ + "permissions": [{ + "resource_type": "wallet", + "actions": ["checkout"] + }], + "delegated_by_subject_id": "usr_42", + })); + assert!(validate_delegation(&req).is_ok()); + } + + #[test] + fn delegation_not_required_for_non_wallet_permissions() { + let req = agent_request(json!({ + "permissions": [{ + "resource_type": "calendar", + "actions": ["read"] + }] + })); + assert!(validate_delegation(&req).is_ok()); + } + + #[test] + fn delegation_not_required_for_non_agent_types() { + let req = IssueRequest { + credential_type: "user".into(), + self_attestation_complete: true, + subject: json!({"type": "person", "id": "usr_1"}), + claims: json!({}), + evidence_refs: vec![], + ttl: None, + }; + assert!(validate_delegation(&req).is_ok()); + } + + #[test] + fn empty_delegated_by_treated_as_missing() { + let req = agent_request(json!({ + "permissions": [{ + "resource_type": "wallet", + "actions": ["checkout"] + }], + "delegated_by_subject_id": "", + })); + let err = validate_delegation(&req).unwrap_err(); + assert!(matches!(err, BelticError::DelegationMissing)); + } +} diff --git a/engine/houston-beltic/src/lib.rs b/engine/houston-beltic/src/lib.rs new file mode 100644 index 000000000..0b0efa360 --- /dev/null +++ b/engine/houston-beltic/src/lib.rs @@ -0,0 +1,29 @@ +//! houston-beltic — Beltic verifiable-credentials integration for Houston. +//! +//! Wraps Beltic's Credentials API: issuance, revocation, local JWT-VC +//! verification (JWKS + Status List 2021), and webhook signature +//! verification (`Beltic-Signature` Stripe-pattern). Transport-neutral — +//! no Tauri, no React, no axum. Routes that surface this crate live in +//! `houston-engine-server::routes::credentials` (added in chunk 3). +//! +//! Module map: +//! - [`config`] — runtime configuration; jwks/status URLs derive from `base_url` +//! - [`errors`] — typed errors mapping Beltic's nested error envelope +//! - [`client`] — reqwest HTTP client with `X-Api-Key` + retry hooks +//! - [`issuer`] — typed issue/revoke methods for each credential_type +//! - [`webhook_verifier`] — HMAC-SHA256 verification of webhook deliveries +//! - [`verifier`] — local JWT-VC verify: JWKS cache, Status List, policy + +pub mod client; +pub mod config; +pub mod errors; +pub mod issuer; +pub mod verifier; +pub mod webhook_verifier; + +pub use client::Client; +pub use config::Configuration; +pub use errors::{BelticError, BelticResult}; +pub use issuer::Issuer; +pub use verifier::{Verifier, VerifyResult}; +pub use webhook_verifier::WebhookVerifier; diff --git a/engine/houston-beltic/src/verifier/jwks.rs b/engine/houston-beltic/src/verifier/jwks.rs new file mode 100644 index 000000000..fa37457a9 --- /dev/null +++ b/engine/houston-beltic/src/verifier/jwks.rs @@ -0,0 +1,125 @@ +//! JWKS cache + ES256 signature verification. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::config::Configuration; +use crate::errors::{BelticError, BelticResult}; + +const DEFAULT_TTL: Duration = Duration::from_secs(3_600); + +#[derive(Debug, Clone)] +pub struct JwksCache { + pub keys: HashMap, + pub fetched_at: Instant, + pub ttl: Duration, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Jwk { + pub kid: String, + #[serde(default)] + pub alg: Option, + pub kty: String, + pub crv: String, + pub x: String, + pub y: String, +} + +#[derive(Debug, Deserialize)] +struct JwksResponse { + keys: Vec, +} + +pub async fn find_jwk( + cache: &Arc>>, + http: &reqwest::Client, + config: &Configuration, + kid: &str, +) -> BelticResult> { + if let Some(jwk) = lookup_cached(cache, kid).await { + return Ok(Some(jwk)); + } + refresh(cache, http, config).await?; + Ok(lookup_cached(cache, kid).await) +} + +async fn lookup_cached(cache: &Arc>>, kid: &str) -> Option { + let guard = cache.read().await; + let entry = guard.as_ref()?; + if entry.fetched_at.elapsed() > entry.ttl { + return None; + } + entry.keys.get(kid).cloned() +} + +async fn refresh( + cache: &Arc>>, + http: &reqwest::Client, + config: &Configuration, +) -> BelticResult<()> { + let url = config.jwks_url()?; + let response = http + .get(&url) + .send() + .await + .map_err(|e| BelticError::Transport(format!("jwks fetch: {e}")))?; + let ttl = parse_cache_control_max_age(&response).unwrap_or(DEFAULT_TTL); + let body: JwksResponse = response + .json() + .await + .map_err(|e| BelticError::BadResponseBody(format!("jwks decode: {e}")))?; + let keys = body.keys.into_iter().map(|k| (k.kid.clone(), k)).collect(); + *cache.write().await = Some(JwksCache { + keys, + fetched_at: Instant::now(), + ttl, + }); + Ok(()) +} + +/// Verify the JWT-VC signature using the supplied JWK. Returns the decoded +/// payload (claims) on success. Treats Beltic's V1 cipher (ES256 / P-256) as +/// the only acceptable algorithm. +/// +/// Errors are returned as `String` so the caller can choose how to surface +/// them — `BelticError::Verification` for transport-level failures vs. +/// `VerifyResult::fail("bad_signature", ...)` for credential-level rejections. +pub fn verify_signature(jwt: &str, jwk: &Jwk) -> Result { + if jwk.kty != "EC" || jwk.crv != "P-256" { + return Err(format!("unsupported kty/crv: {}/{}", jwk.kty, jwk.crv)); + } + let alg = match jwk.alg.as_deref() { + Some("ES256") | None => Algorithm::ES256, + Some(other) => return Err(format!("unsupported alg: {other}")), + }; + let key = DecodingKey::from_ec_components(&jwk.x, &jwk.y) + .map_err(|e| format!("could not build decoding key: {e}"))?; + let mut validation = Validation::new(alg); + // We do expiry + audience checks ourselves so we control the reason strings. + validation.validate_exp = false; + validation.required_spec_claims = std::collections::HashSet::new(); + let data = decode::(jwt, &key, &validation) + .map_err(|e| format!("signature verification failed: {e}"))?; + Ok(data.claims) +} + +pub(super) fn parse_cache_control_max_age(response: &reqwest::Response) -> Option { + response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()) + .and_then(|cc| { + cc.split(',').find_map(|part| { + part.trim() + .strip_prefix("max-age=") + .and_then(|n| n.parse::().ok()) + }) + }) + .map(Duration::from_secs) +} diff --git a/engine/houston-beltic/src/verifier/mod.rs b/engine/houston-beltic/src/verifier/mod.rs new file mode 100644 index 000000000..c5165504c --- /dev/null +++ b/engine/houston-beltic/src/verifier/mod.rs @@ -0,0 +1,127 @@ +//! Local JWT-VC verification. +//! +//! Beltic credentials are JWS-signed (ES256, P-256). Verifying locally is +//! sub-millisecond once the JWKS + Status List are cached, which is what we +//! want in the hot purchase path. Remote verification via +//! `POST /v1/credentials/{id}/verify` is also available (audit-friendly, +//! 50–200ms) but not implemented here; it's a one-line client call away. +//! +//! Verification steps: +//! 1. Parse JWT header → extract `kid` +//! 2. Look up matching JWK from the JWKS cache (fetched if cold) — [`jwks`] +//! 3. Verify ES256 signature via `jsonwebtoken` — [`jwks::verify_signature`] +//! 4. Check `exp` +//! 5. Check revocation against the Status List 2021 bitstring — [`status_list`] +//! 6. Evaluate `claims.permissions[]` against the transaction context — [`policy`] + +mod jwks; +mod policy; +mod status_list; + +use std::sync::Arc; + +use jsonwebtoken::decode_header; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::config::Configuration; +use crate::errors::{BelticError, BelticResult}; + +use self::jwks::JwksCache; +use self::status_list::StatusListCache; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyResult { + pub valid: bool, + pub reason: &'static str, + pub credential_id: Option, + pub detail: Option, +} + +impl VerifyResult { + fn ok(credential_id: Option) -> Self { + Self { valid: true, reason: "ok", credential_id, detail: None } + } + fn fail(reason: &'static str, detail: impl Into) -> Self { + Self { valid: false, reason, credential_id: None, detail: Some(detail.into()) } + } +} + +#[derive(Debug, Clone)] +pub struct Verifier { + config: Configuration, + http: reqwest::Client, + jwks: Arc>>, + status_list: Arc>>, +} + +impl Verifier { + pub fn new(config: Configuration) -> BelticResult { + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .build() + .map_err(|e| BelticError::Configuration(format!("verifier http: {e}")))?; + Ok(Self { + config, + http, + jwks: Arc::new(RwLock::new(None)), + status_list: Arc::new(RwLock::new(None)), + }) + } + + /// Force-clear the JWKS cache. Call after a `kid` miss to pick up a key + /// rotation on the next verify. + pub async fn invalidate_jwks(&self) { + *self.jwks.write().await = None; + } + + /// Force-clear the Status List cache. Call after a webhook indicates a + /// revocation so the result is seen immediately rather than at TTL expiry. + pub async fn invalidate_status_list(&self) { + *self.status_list.write().await = None; + } + + pub async fn verify(&self, jwt: &str, ctx: &serde_json::Value) -> BelticResult { + let header = decode_header(jwt).map_err(|e| BelticError::Verification { + reason: "malformed", + detail: format!("could not decode JWT header: {e}"), + })?; + let kid = header.kid.clone().ok_or_else(|| BelticError::Verification { + reason: "malformed", + detail: "JWT header missing kid".into(), + })?; + + let jwk = match jwks::find_jwk(&self.jwks, &self.http, &self.config, &kid).await? { + Some(k) => k, + None => { + return Ok(VerifyResult::fail("unknown_kid", format!("no JWK for kid={kid}"))) + } + }; + + let payload = match jwks::verify_signature(jwt, &jwk) { + Ok(p) => p, + Err(detail) => return Ok(VerifyResult::fail("bad_signature", detail)), + }; + + let cred_id = payload.get("jti").and_then(|v| v.as_str()).map(String::from); + + if let Some(exp) = payload.get("exp").and_then(|v| v.as_i64()) { + let now = chrono::Utc::now().timestamp(); + if exp < now { + return Ok(VerifyResult::fail("expired", format!("exp={exp} now={now}"))); + } + } + + if let Some(detail) = + status_list::check_revocation(&self.status_list, &self.http, &self.config, &payload).await? + { + return Ok(VerifyResult::fail("revoked", detail)); + } + + if let Some(detail) = policy::evaluate(&payload, ctx) { + return Ok(VerifyResult::fail("policy_denied", detail)); + } + + Ok(VerifyResult::ok(cred_id)) + } +} diff --git a/engine/houston-beltic/src/verifier/policy.rs b/engine/houston-beltic/src/verifier/policy.rs new file mode 100644 index 000000000..abea4c87e --- /dev/null +++ b/engine/houston-beltic/src/verifier/policy.rs @@ -0,0 +1,158 @@ +//! Permission policy evaluator. +//! +//! Beltic's `agent_authorization` credentials carry a `claims.permissions[]` +//! list of `{resource_type, resource_id?, actions[], conditions[]?}` triples. +//! At transaction time the caller supplies a context map (e.g., +//! `{"resource_type": "wallet", "action": "checkout", "transaction_amount": +//! 5000, "transaction_currency": "USD"}`) and we evaluate: +//! +//! 1. At least one permission must match the resource_type + action +//! 2. Every condition on at least one matching permission must hold +//! +//! Supported operators: `lte`, `lt`, `gte`, `gt`, `eq`, `neq`, `in`. + +use std::cmp::Ordering; + +/// Returns `None` on pass, `Some(detail)` on deny. An empty context is treated +/// as "no policy check requested" — useful for callers that want to assert +/// authenticity without making a transaction-level decision. +pub fn evaluate(payload: &serde_json::Value, ctx: &serde_json::Value) -> Option { + if ctx.as_object().map(|m| m.is_empty()).unwrap_or(true) { + return None; + } + let claims = payload + .pointer("/vc/credentialSubject/claims") + .or_else(|| payload.get("claims"))?; + let perms = claims.get("permissions").and_then(|v| v.as_array())?; + if perms.is_empty() { + return None; + } + + let matches: Vec<&serde_json::Value> = + perms.iter().filter(|p| permission_matches(p, ctx)).collect(); + if matches.is_empty() { + return Some("no permission grants this resource/action".into()); + } + for p in &matches { + let conds = p + .get("conditions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let failures: Vec = conds.iter().filter_map(|c| check_condition(c, ctx)).collect(); + if failures.is_empty() { + return None; + } + } + Some("conditions denied for all matching permissions".into()) +} + +fn permission_matches(p: &serde_json::Value, ctx: &serde_json::Value) -> bool { + let resource_ok = p + .get("resource_type") + .and_then(|v| v.as_str()) + .zip(ctx.get("resource_type").and_then(|v| v.as_str())) + .map(|(a, b)| a == b) + .unwrap_or(false); + if !resource_ok { + return false; + } + let actions = p.get("actions").and_then(|v| v.as_array()); + let Some(actions) = actions else { return true }; + let Some(ctx_action) = ctx.get("action").and_then(|v| v.as_str()) else { return true }; + actions.iter().any(|a| a.as_str() == Some(ctx_action)) +} + +fn check_condition(c: &serde_json::Value, ctx: &serde_json::Value) -> Option { + let op = c.get("operator").and_then(|v| v.as_str()).unwrap_or(""); + let field = c.get("field").and_then(|v| v.as_str()).unwrap_or(""); + let expected = c.get("value").unwrap_or(&serde_json::Value::Null); + let actual = ctx.get(field).unwrap_or(&serde_json::Value::Null); + let ok = match op { + "lte" => cmp_num(actual, expected).map(|o| o <= Ordering::Equal).unwrap_or(false), + "lt" => cmp_num(actual, expected).map(|o| o < Ordering::Equal).unwrap_or(false), + "gte" => cmp_num(actual, expected).map(|o| o >= Ordering::Equal).unwrap_or(false), + "gt" => cmp_num(actual, expected).map(|o| o > Ordering::Equal).unwrap_or(false), + "eq" => actual == expected, + "neq" => actual != expected, + "in" => expected.as_array().map(|arr| arr.contains(actual)).unwrap_or(false), + _ => false, + }; + if ok { + None + } else { + Some(format!("{field} {op} {expected} (was {actual})")) + } +} + +fn cmp_num(a: &serde_json::Value, b: &serde_json::Value) -> Option { + let af = a.as_f64()?; + let bf = b.as_f64()?; + af.partial_cmp(&bf) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn empty_ctx_passes() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[ + {"resource_type":"wallet","actions":["checkout"]} + ]}}}}); + assert!(evaluate(&payload, &json!({})).is_none()); + } + + #[test] + fn unmatched_resource_denies() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[ + {"resource_type":"wallet","actions":["checkout"]} + ]}}}}); + let ctx = json!({"resource_type":"calendar","action":"read"}); + let r = evaluate(&payload, &ctx).expect("should deny"); + assert!(r.contains("no permission")); + } + + #[test] + fn passes_with_satisfied_conditions() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[{ + "resource_type":"wallet","actions":["checkout"], + "conditions":[{"operator":"lte","field":"transaction_amount","value":10000}] + }]}}}}); + let ctx = json!({"resource_type":"wallet","action":"checkout","transaction_amount":5000}); + assert!(evaluate(&payload, &ctx).is_none()); + } + + #[test] + fn denies_when_condition_fails() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[{ + "resource_type":"wallet","actions":["checkout"], + "conditions":[{"operator":"lte","field":"transaction_amount","value":100}] + }]}}}}); + let ctx = json!({"resource_type":"wallet","action":"checkout","transaction_amount":999}); + assert!(evaluate(&payload, &ctx).is_some()); + } + + #[test] + fn in_operator_matches_currency() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[{ + "resource_type":"wallet","actions":["checkout"], + "conditions":[{"operator":"in","field":"transaction_currency","value":["USD","BRL"]}] + }]}}}}); + let pass = json!({"resource_type":"wallet","action":"checkout","transaction_currency":"USD"}); + let fail = json!({"resource_type":"wallet","action":"checkout","transaction_currency":"EUR"}); + assert!(evaluate(&payload, &pass).is_none()); + assert!(evaluate(&payload, &fail).is_some()); + } + + #[test] + fn unknown_operator_denies() { + let payload = json!({"vc":{"credentialSubject":{"claims":{"permissions":[{ + "resource_type":"wallet","actions":["checkout"], + "conditions":[{"operator":"bogus","field":"x","value":1}] + }]}}}}); + let ctx = json!({"resource_type":"wallet","action":"checkout","x":1}); + assert!(evaluate(&payload, &ctx).is_some()); + } +} diff --git a/engine/houston-beltic/src/verifier/status_list.rs b/engine/houston-beltic/src/verifier/status_list.rs new file mode 100644 index 000000000..37c4bb816 --- /dev/null +++ b/engine/houston-beltic/src/verifier/status_list.rs @@ -0,0 +1,153 @@ +//! Status List 2021 bitstring fetch + bit lookup. +//! +//! Beltic publishes revocations via a W3C Status List 2021 credential at +//! `/.well-known/status-lists/v1`. The payload's +//! `credentialSubject.encodedList` is gzip-then-base64url; bit at +//! `statusListIndex` is 1 if the credential is revoked. + +use std::io::Read; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use flate2::read::GzDecoder; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::config::Configuration; +use crate::errors::{BelticError, BelticResult}; + +const DEFAULT_TTL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone)] +pub struct StatusListCache { + pub bits: Vec, + pub fetched_at: Instant, + pub ttl: Duration, +} + +#[derive(Debug, Deserialize)] +struct StatusListResponse { + #[serde(rename = "credentialSubject")] + credential_subject: StatusListSubject, +} + +#[derive(Debug, Deserialize)] +struct StatusListSubject { + #[serde(rename = "encodedList")] + encoded_list: String, +} + +/// If the JWT-VC payload references a `credentialStatus`, check the bit and +/// return `Some(detail)` if revoked (so the caller can build a `VerifyResult`). +/// Returns `None` if not revoked OR if the credential has no +/// `credentialStatus` entry. +pub async fn check_revocation( + cache: &Arc>>, + http: &reqwest::Client, + config: &Configuration, + payload: &serde_json::Value, +) -> BelticResult> { + let status = payload + .pointer("/vc/credentialStatus") + .or_else(|| payload.get("credentialStatus")); + let Some(status) = status else { return Ok(None) }; + let index = status + .get("statusListIndex") + .and_then(|v| v.as_u64()) + .ok_or_else(|| BelticError::Verification { + reason: "malformed", + detail: "credentialStatus.statusListIndex missing or non-numeric".into(), + })?; + let bits = fetch(cache, http, config).await?; + if bit_is_set(&bits, index as usize) { + Ok(Some(format!("statusListIndex={index} bit=1"))) + } else { + Ok(None) + } +} + +async fn fetch( + cache: &Arc>>, + http: &reqwest::Client, + config: &Configuration, +) -> BelticResult> { + { + let guard = cache.read().await; + if let Some(entry) = guard.as_ref() { + if entry.fetched_at.elapsed() <= entry.ttl { + return Ok(entry.bits.clone()); + } + } + } + let url = config.status_list_url()?; + let response = http + .get(&url) + .send() + .await + .map_err(|e| BelticError::Transport(format!("status list fetch: {e}")))?; + let ttl = super::jwks::parse_cache_control_max_age(&response).unwrap_or(DEFAULT_TTL); + let body: StatusListResponse = response + .json() + .await + .map_err(|e| BelticError::BadResponseBody(format!("status list decode: {e}")))?; + let bits = decode_encoded_list(&body.credential_subject.encoded_list)?; + *cache.write().await = Some(StatusListCache { + bits: bits.clone(), + fetched_at: Instant::now(), + ttl, + }); + Ok(bits) +} + +fn bit_is_set(bytes: &[u8], index: usize) -> bool { + let byte_index = index / 8; + let bit_in_byte = 7 - (index % 8); + bytes + .get(byte_index) + .map(|b| (b >> bit_in_byte) & 1 == 1) + .unwrap_or(false) +} + +fn decode_encoded_list(encoded: &str) -> BelticResult> { + let gz = URL_SAFE_NO_PAD + .decode(encoded.trim_end_matches('=')) + .map_err(|e| BelticError::BadResponseBody(format!("status list base64: {e}")))?; + let mut out = Vec::new(); + GzDecoder::new(gz.as_slice()) + .read_to_end(&mut out) + .map_err(|e| BelticError::BadResponseBody(format!("status list gunzip: {e}")))?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bit_lookup_msb_first() { + assert!(bit_is_set(&[0b1000_0000], 0)); + assert!(!bit_is_set(&[0b1000_0000], 1)); + assert!(bit_is_set(&[0b0000_0001], 7)); + // Past end of bytes → false, no panic + assert!(!bit_is_set(&[0], 999)); + } + + #[test] + fn decode_encoded_list_round_trips() { + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write; + + let mut bits = vec![0u8; 16]; + bits[0] = 0b0000_0100; + let mut enc = GzEncoder::new(Vec::new(), Compression::default()); + enc.write_all(&bits).unwrap(); + let gz = enc.finish().unwrap(); + let encoded = URL_SAFE_NO_PAD.encode(&gz); + let decoded = decode_encoded_list(&encoded).unwrap(); + assert_eq!(decoded, bits); + assert!(bit_is_set(&decoded, 5)); + } +} diff --git a/engine/houston-beltic/src/webhook_verifier.rs b/engine/houston-beltic/src/webhook_verifier.rs new file mode 100644 index 000000000..e47e4fbf5 --- /dev/null +++ b/engine/houston-beltic/src/webhook_verifier.rs @@ -0,0 +1,219 @@ +//! HMAC verification for Beltic webhook deliveries. +//! +//! Wire format (verified against `apps/api/credentials/src/operations/audit/streams/hmac-sign.ts` +//! in the Beltic platform): +//! +//! Beltic-Signature: sha256= +//! Beltic-Timestamp: +//! +//! Signed payload is `format!("{timestamp}.{raw_body}")`. Anti-replay: reject +//! deliveries whose timestamp differs from `now()` by more than `tolerance` +//! seconds (Beltic recommends 300). +//! +//! Constant-time compare guards against timing oracles. + +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +use crate::errors::{BelticError, BelticResult}; + +pub const SIGNATURE_HEADER: &str = "Beltic-Signature"; +pub const TIMESTAMP_HEADER: &str = "Beltic-Timestamp"; +pub const DEFAULT_TOLERANCE_SECS: i64 = 300; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone)] +pub struct WebhookVerifier { + secret: Vec, + tolerance_secs: i64, +} + +impl WebhookVerifier { + pub fn new(secret: impl Into>) -> BelticResult { + let secret = secret.into(); + if secret.is_empty() { + return Err(BelticError::Configuration( + "beltic webhook_secret must not be empty".into(), + )); + } + Ok(Self { + secret, + tolerance_secs: DEFAULT_TOLERANCE_SECS, + }) + } + + pub fn with_tolerance_secs(mut self, secs: i64) -> Self { + self.tolerance_secs = secs; + self + } + + /// Returns `Ok(())` if signature + timestamp are both valid. Otherwise a + /// typed `BelticError::WebhookSignature`. The caller decides whether to + /// 401 or 400 based on whether parsing failed vs. crypto failed. + pub fn verify( + &self, + raw_body: &[u8], + signature_header: Option<&str>, + timestamp_header: Option<&str>, + now_unix_secs: i64, + ) -> BelticResult<()> { + let ts = parse_timestamp(timestamp_header)?; + check_freshness(ts, now_unix_secs, self.tolerance_secs)?; + let provided_sig = parse_signature(signature_header)?; + let expected = self.sign(ts, raw_body); + if bool::from(provided_sig.as_slice().ct_eq(expected.as_slice())) { + Ok(()) + } else { + Err(BelticError::WebhookSignature( + "Beltic-Signature does not match expected HMAC".into(), + )) + } + } + + fn sign(&self, ts: i64, body: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(&self.secret) + .expect("HMAC key length is valid for any byte slice"); + mac.update(ts.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + mac.finalize().into_bytes().to_vec() + } +} + +fn parse_timestamp(header: Option<&str>) -> BelticResult { + let raw = header + .filter(|s| !s.is_empty()) + .ok_or_else(|| BelticError::WebhookSignature("Beltic-Timestamp header missing".into()))?; + raw.parse::().map_err(|_| { + BelticError::WebhookSignature(format!( + "Beltic-Timestamp not numeric (got {raw:?})" + )) + }) +} + +fn check_freshness(ts: i64, now: i64, tolerance: i64) -> BelticResult<()> { + let delta = (now - ts).abs(); + if delta <= tolerance { + Ok(()) + } else { + Err(BelticError::WebhookSignature(format!( + "Beltic-Timestamp {ts} is outside the {tolerance}s tolerance (delta={delta}s)" + ))) + } +} + +fn parse_signature(header: Option<&str>) -> BelticResult> { + let raw = header + .filter(|s| !s.is_empty()) + .ok_or_else(|| BelticError::WebhookSignature("Beltic-Signature header missing".into()))?; + let hex = raw.strip_prefix("sha256=").ok_or_else(|| { + BelticError::WebhookSignature(format!( + "Beltic-Signature must use sha256= prefix (got {raw:?})" + )) + })?; + hex::decode(hex) + .map_err(|e| BelticError::WebhookSignature(format!("Beltic-Signature hex malformed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &[u8] = b"test-webhook-secret-that-is-long-enough"; + + fn sign_for_test(ts: i64, body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(SECRET).unwrap(); + mac.update(ts.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) + } + + #[test] + fn rejects_empty_secret() { + let err = WebhookVerifier::new(Vec::::new()).unwrap_err(); + assert!(matches!(err, BelticError::Configuration(_))); + } + + #[test] + fn accepts_valid_signature_within_window() { + let ts: i64 = 1_700_000_000; + let body = b"{\"event_type\":\"credential.issued\",\"credential_id\":\"cred_x\"}"; + let sig = sign_for_test(ts, body); + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + v.verify(body, Some(&sig), Some(&ts.to_string()), ts + 5).unwrap(); + } + + #[test] + fn rejects_signature_mismatch() { + let ts: i64 = 1_700_000_000; + let body = b"hello"; + let bad_sig = sign_for_test(ts, b"different body"); + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + let err = v.verify(body, Some(&bad_sig), Some(&ts.to_string()), ts + 1).unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn rejects_stale_timestamp() { + let ts: i64 = 1_700_000_000; + let body = b"hello"; + let sig = sign_for_test(ts, body); + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + // 1 hour later — outside 300s default tolerance + let err = v + .verify(body, Some(&sig), Some(&ts.to_string()), ts + 3600) + .unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn rejects_missing_timestamp_header() { + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + let err = v.verify(b"", Some("sha256=abc"), None, 0).unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn rejects_non_numeric_timestamp() { + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + let err = v + .verify(b"", Some("sha256=abc"), Some("not-a-number"), 0) + .unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn rejects_missing_sha256_prefix() { + let ts: i64 = 1_700_000_000; + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + let err = v + .verify(b"", Some("md5=abc"), Some(&ts.to_string()), ts) + .unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn rejects_malformed_hex() { + let ts: i64 = 1_700_000_000; + let v = WebhookVerifier::new(SECRET.to_vec()).unwrap(); + let err = v + .verify(b"", Some("sha256=zzz_not_hex"), Some(&ts.to_string()), ts) + .unwrap_err(); + assert!(matches!(err, BelticError::WebhookSignature(_))); + } + + #[test] + fn custom_tolerance_widens_window() { + let ts: i64 = 1_700_000_000; + let body = b"hello"; + let sig = sign_for_test(ts, body); + let v = WebhookVerifier::new(SECRET.to_vec()) + .unwrap() + .with_tolerance_secs(3600); + // 30 minutes later — outside default 300s but inside 3600s + v.verify(body, Some(&sig), Some(&ts.to_string()), ts + 1800).unwrap(); + } +} From e75918b144f94a69c59f263acd73359c3114bed9 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 16:48:42 -0400 Subject: [PATCH 02/19] =?UTF-8?q?feat(engine-core):=20credentials=20module?= =?UTF-8?q?=20=E2=80=94=20persist=20Beltic=20VCs=20in=20.houston/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores Beltic-issued credentials per agent (agent_authorization) and at the workspace level (user identity). Files-first per knowledge-base/ files-first.md: append-only Vec in .houston/credentials/credentials.json (agent) or .houston/identity/identity.json (workspace), reactive via the file watcher + WS event invalidation in chunk 3. - types.rs — VerifiableCredential (Beltic subject_id, claims, signed JWT, delegated_by_subject_id, status_list_index), CredentialStatus enum (Active/Suspended/Revoked/Expired) - store.rs — agent-scoped list/save/active/find_by_credential_id/ update_status. save() rejects duplicates by credential_id so retried issuance jobs don't produce phantom rows. update_status() auto-stamps revoked_at when transitioning to Revoked. - identity.rs — workspace-scoped equivalent for the user's `user` credential. Same shape; different folder. 22 inline tests passing (9 mine + 13 pre-existing in adjacent modules). --- .../src/credentials/identity.rs | 146 ++++++++++++ .../src/credentials/mod.rs | 21 ++ .../src/credentials/store.rs | 224 ++++++++++++++++++ .../src/credentials/types.rs | 84 +++++++ engine/houston-engine-core/src/lib.rs | 1 + 5 files changed, 476 insertions(+) create mode 100644 engine/houston-engine-core/src/credentials/identity.rs create mode 100644 engine/houston-engine-core/src/credentials/mod.rs create mode 100644 engine/houston-engine-core/src/credentials/store.rs create mode 100644 engine/houston-engine-core/src/credentials/types.rs diff --git a/engine/houston-engine-core/src/credentials/identity.rs b/engine/houston-engine-core/src/credentials/identity.rs new file mode 100644 index 000000000..9df31cda1 --- /dev/null +++ b/engine/houston-engine-core/src/credentials/identity.rs @@ -0,0 +1,146 @@ +//! Workspace-scoped identity credential — the user's Beltic `user` +//! credential lives at the workspace root, not per-agent. +//! +//! Pattern is identical to [`super::store`] but the file lives at +//! `/.houston/identity/identity.json` instead of inside +//! an agent. Same `Vec` shape — re-verifying creates +//! a new active row; the previous one is revoked but kept on disk. + +use std::path::Path; + +use crate::agents::store::{read_json, write_json}; +use crate::error::{CoreError, CoreResult}; + +use super::types::{CredentialStatus, NewCredential, VerifiableCredential}; + +const FILE: &str = "identity"; + +pub fn list(workspace_root: &Path) -> CoreResult> { + read_json::>(workspace_root, FILE) +} + +pub fn active(workspace_root: &Path) -> CoreResult> { + let mut items = list(workspace_root)?; + items.reverse(); + Ok(items.into_iter().find(|c| c.status.is_active())) +} + +pub fn find_by_credential_id( + workspace_root: &Path, + credential_id: &str, +) -> CoreResult> { + Ok(list(workspace_root)? + .into_iter() + .find(|c| c.credential_id == credential_id)) +} + +pub fn save( + workspace_root: &Path, + input: NewCredential, +) -> CoreResult { + let mut items = list(workspace_root)?; + if items.iter().any(|c| c.credential_id == input.credential_id) { + return Err(CoreError::Conflict(format!( + "identity credential {} already persisted", + input.credential_id + ))); + } + let row = VerifiableCredential { + credential_id: input.credential_id, + credential_type: input.credential_type, + subject_type: input.subject_type, + subject_id: input.subject_id, + status: CredentialStatus::Active, + issuer_did: input.issuer_did, + kid: input.kid, + alg: input.alg, + signed_payload: input.signed_payload, + claims: input.claims, + issued_at: input.issued_at, + expires_at: input.expires_at, + revoked_at: None, + revocation_reason: None, + delegated_by_subject_id: input.delegated_by_subject_id, + status_list_index: input.status_list_index, + }; + items.push(row.clone()); + write_json(workspace_root, FILE, &items)?; + Ok(row) +} + +pub fn update_status( + workspace_root: &Path, + credential_id: &str, + new_status: CredentialStatus, + revoked_at: Option, + revocation_reason: Option, +) -> CoreResult { + let mut items = list(workspace_root)?; + let row = items + .iter_mut() + .find(|c| c.credential_id == credential_id) + .ok_or_else(|| CoreError::NotFound(format!("identity credential {credential_id}")))?; + row.status = new_status; + if new_status == CredentialStatus::Revoked { + row.revoked_at = revoked_at.or_else(|| Some(chrono::Utc::now().to_rfc3339())); + if revocation_reason.is_some() { + row.revocation_reason = revocation_reason; + } + } + let cloned = row.clone(); + write_json(workspace_root, FILE, &items)?; + Ok(cloned) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + fn sample(id: &str) -> NewCredential { + NewCredential { + credential_id: id.into(), + credential_type: "user".into(), + subject_type: "person".into(), + subject_id: "usr_42".into(), + issuer_did: "did:web:beltic.com".into(), + kid: "kid_a".into(), + alg: "ES256".into(), + signed_payload: "eyJ.eyJ.sig".into(), + claims: json!({"kyc_status": "approved", "trust_level": "idv_verified"}), + issued_at: "2026-05-22T10:00:00Z".into(), + expires_at: "2027-05-22T10:00:00Z".into(), + delegated_by_subject_id: None, + status_list_index: 7, + } + } + + #[test] + fn save_and_find_workspace_identity_credential() { + let tmp = TempDir::new().unwrap(); + let row = save(tmp.path(), sample("cred_user_1")).unwrap(); + assert_eq!(row.credential_type, "user"); + assert!(find_by_credential_id(tmp.path(), "cred_user_1") + .unwrap() + .is_some()); + } + + #[test] + fn active_returns_most_recent_after_reverification() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_old")).unwrap(); + // The user re-verifies — old one gets revoked, new one is issued + update_status( + tmp.path(), + "cred_old", + CredentialStatus::Revoked, + None, + Some("re-verified".into()), + ) + .unwrap(); + save(tmp.path(), sample("cred_new")).unwrap(); + let active = active(tmp.path()).unwrap().unwrap(); + assert_eq!(active.credential_id, "cred_new"); + } +} diff --git a/engine/houston-engine-core/src/credentials/mod.rs b/engine/houston-engine-core/src/credentials/mod.rs new file mode 100644 index 000000000..2f375e1a1 --- /dev/null +++ b/engine/houston-engine-core/src/credentials/mod.rs @@ -0,0 +1,21 @@ +//! Beltic verifiable-credentials persistence inside `.houston/credentials/`. +//! +//! Each agent stores its credential history as an append-only list, newest +//! last. The "current" credential is the most-recent row with +//! `status == Active`. Older rows stay on disk for audit — Beltic revokes +//! don't delete the credential, they flip its status — so the agent has a +//! complete chain of issuance/revocation visible to it via its own +//! `.houston/` files (files-first reactivity rule). +//! +//! The same shape backs the workspace-level identity credential: the +//! workspace-scoped helpers in [`identity`] write to a sibling folder at +//! the workspace root instead of the agent root. + +pub mod identity; +pub mod store; +pub mod types; + +pub use store::{ + active, find_by_credential_id, list, save, update_status, +}; +pub use types::{CredentialStatus, NewCredential, VerifiableCredential}; diff --git a/engine/houston-engine-core/src/credentials/store.rs b/engine/houston-engine-core/src/credentials/store.rs new file mode 100644 index 000000000..cf866294c --- /dev/null +++ b/engine/houston-engine-core/src/credentials/store.rs @@ -0,0 +1,224 @@ +//! Agent-scoped CRUD over `.houston/credentials/credentials.json`. +//! +//! Mirrors the pattern in `agents::activity` — append-only `Vec`, newest +//! last, the active credential is the most recent row with `status == +//! Active`. Status changes are mutations on existing rows (driven by +//! Beltic webhooks); we never delete rows here so the audit trail stays +//! intact. + +use std::path::Path; + +use crate::agents::store::{read_json, write_json}; +use crate::error::{CoreError, CoreResult}; + +use super::types::{CredentialStatus, NewCredential, VerifiableCredential}; + +const FILE: &str = "credentials"; + +/// All credentials persisted for this agent root, newest last. +pub fn list(root: &Path) -> CoreResult> { + read_json::>(root, FILE) +} + +/// The most-recent credential with `status == Active`, or `None`. +pub fn active(root: &Path) -> CoreResult> { + let mut items = list(root)?; + items.reverse(); + Ok(items.into_iter().find(|c| c.status.is_active())) +} + +pub fn find_by_credential_id( + root: &Path, + credential_id: &str, +) -> CoreResult> { + Ok(list(root)? + .into_iter() + .find(|c| c.credential_id == credential_id)) +} + +/// Append a freshly-issued credential. Rejects duplicates by +/// `credential_id` so re-running an issuance job doesn't create a phantom +/// row. +pub fn save(root: &Path, input: NewCredential) -> CoreResult { + let mut items = list(root)?; + if items.iter().any(|c| c.credential_id == input.credential_id) { + return Err(CoreError::Conflict(format!( + "credential {} already persisted", + input.credential_id + ))); + } + let row = VerifiableCredential { + credential_id: input.credential_id, + credential_type: input.credential_type, + subject_type: input.subject_type, + subject_id: input.subject_id, + status: CredentialStatus::Active, + issuer_did: input.issuer_did, + kid: input.kid, + alg: input.alg, + signed_payload: input.signed_payload, + claims: input.claims, + issued_at: input.issued_at, + expires_at: input.expires_at, + revoked_at: None, + revocation_reason: None, + delegated_by_subject_id: input.delegated_by_subject_id, + status_list_index: input.status_list_index, + }; + items.push(row.clone()); + write_json(root, FILE, &items)?; + Ok(row) +} + +/// Mutate the `status` (and `revoked_at` if transitioning to Revoked) on +/// the row with the matching `credential_id`. Idempotent: calling with the +/// same status is a no-op. +pub fn update_status( + root: &Path, + credential_id: &str, + new_status: CredentialStatus, + revoked_at: Option, + revocation_reason: Option, +) -> CoreResult { + let mut items = list(root)?; + let row = items + .iter_mut() + .find(|c| c.credential_id == credential_id) + .ok_or_else(|| CoreError::NotFound(format!("credential {credential_id}")))?; + row.status = new_status; + if new_status == CredentialStatus::Revoked { + row.revoked_at = revoked_at.or_else(|| Some(chrono::Utc::now().to_rfc3339())); + if revocation_reason.is_some() { + row.revocation_reason = revocation_reason; + } + } + let cloned = row.clone(); + write_json(root, FILE, &items)?; + Ok(cloned) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + fn sample(credential_id: &str) -> NewCredential { + NewCredential { + credential_id: credential_id.into(), + credential_type: "agent_authorization".into(), + subject_type: "agent".into(), + subject_id: "did:jwk:abc".into(), + issuer_did: "did:web:beltic.com".into(), + kid: "kid_a".into(), + alg: "ES256".into(), + signed_payload: "eyJ.eyJ.sig".into(), + claims: json!({"permissions": []}), + issued_at: "2026-05-22T10:00:00Z".into(), + expires_at: "2027-05-22T10:00:00Z".into(), + delegated_by_subject_id: Some("usr_42".into()), + status_list_index: 17, + } + } + + #[test] + fn list_is_empty_for_fresh_agent() { + let tmp = TempDir::new().unwrap(); + assert!(list(tmp.path()).unwrap().is_empty()); + assert!(active(tmp.path()).unwrap().is_none()); + } + + #[test] + fn save_creates_then_finds_by_credential_id() { + let tmp = TempDir::new().unwrap(); + let row = save(tmp.path(), sample("cred_x")).unwrap(); + assert_eq!(row.status, CredentialStatus::Active); + assert_eq!(row.credential_id, "cred_x"); + + let found = find_by_credential_id(tmp.path(), "cred_x").unwrap().unwrap(); + assert_eq!(found.credential_id, "cred_x"); + assert_eq!(found.delegated_by_subject_id.as_deref(), Some("usr_42")); + } + + #[test] + fn save_rejects_duplicate_credential_id() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_x")).unwrap(); + let err = save(tmp.path(), sample("cred_x")).unwrap_err(); + assert!(matches!(err, CoreError::Conflict(_))); + } + + #[test] + fn active_returns_most_recent_active() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_old")).unwrap(); + save(tmp.path(), sample("cred_new")).unwrap(); + let active = active(tmp.path()).unwrap().unwrap(); + // newest is last in the list — active() should return it + assert_eq!(active.credential_id, "cred_new"); + } + + #[test] + fn active_skips_revoked_rows() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_active")).unwrap(); + save(tmp.path(), sample("cred_revoked")).unwrap(); + update_status( + tmp.path(), + "cred_revoked", + CredentialStatus::Revoked, + None, + Some("test".into()), + ) + .unwrap(); + let active = active(tmp.path()).unwrap().unwrap(); + assert_eq!(active.credential_id, "cred_active"); + } + + #[test] + fn update_status_sets_revoked_at_when_transitioning_to_revoked() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_x")).unwrap(); + let row = update_status( + tmp.path(), + "cred_x", + CredentialStatus::Revoked, + Some("2026-05-22T11:00:00Z".into()), + Some("revoked_by_user".into()), + ) + .unwrap(); + assert_eq!(row.status, CredentialStatus::Revoked); + assert_eq!(row.revoked_at.as_deref(), Some("2026-05-22T11:00:00Z")); + assert_eq!(row.revocation_reason.as_deref(), Some("revoked_by_user")); + } + + #[test] + fn update_status_not_found_returns_not_found_error() { + let tmp = TempDir::new().unwrap(); + let err = update_status( + tmp.path(), + "cred_nope", + CredentialStatus::Suspended, + None, + None, + ) + .unwrap_err(); + assert!(matches!(err, CoreError::NotFound(_))); + } + + #[test] + fn update_status_to_suspended_does_not_set_revoked_at() { + let tmp = TempDir::new().unwrap(); + save(tmp.path(), sample("cred_x")).unwrap(); + let row = update_status( + tmp.path(), + "cred_x", + CredentialStatus::Suspended, + None, + None, + ) + .unwrap(); + assert_eq!(row.status, CredentialStatus::Suspended); + assert!(row.revoked_at.is_none()); + } +} diff --git a/engine/houston-engine-core/src/credentials/types.rs b/engine/houston-engine-core/src/credentials/types.rs new file mode 100644 index 000000000..3805cf95c --- /dev/null +++ b/engine/houston-engine-core/src/credentials/types.rs @@ -0,0 +1,84 @@ +//! Domain types for stored Beltic credentials. +//! +//! The shape is a deliberate subset of Beltic's API response (see +//! `houston-beltic::issuer::Credential`) — we keep only what Houston needs +//! for verification, UI rendering, and audit. Notably we keep +//! `signed_payload` (the JWT-VC string) because the verifier needs it at +//! transaction time; at-rest encryption happens before this hits disk via +//! the cryptography layer added in chunk 3. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialStatus { + Active, + Suspended, + Revoked, + Expired, +} + +impl CredentialStatus { + pub fn is_active(self) -> bool { + matches!(self, Self::Active) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifiableCredential { + /// Beltic's `credential_id` (e.g., `cred_a8f3…`). Stable across status + /// changes; not the same as Houston's local row id since we don't have + /// one — we key by Beltic's id. + pub credential_id: String, + pub credential_type: String, + /// Beltic subject.type — "person" | "agent" | "organisation". + pub subject_type: String, + /// Beltic subject.id (e.g., "usr_42", "did:jwk:…", "org_houston_…"). + pub subject_id: String, + pub status: CredentialStatus, + pub issuer_did: String, + pub kid: String, + pub alg: String, + /// The signed JWT-VC. The verifier needs this to authenticate the + /// credential at transaction time without round-tripping to Beltic. + pub signed_payload: String, + /// Full Beltic claims object as JSON — preserves the shape since + /// per-type claim contents vary (kyc_status, kyb_status, permissions, + /// spend_limit, …). + pub claims: serde_json::Value, + /// ISO-8601 timestamps — kept as String to round-trip Beltic's wire + /// format without precision loss. + pub issued_at: String, + pub expires_at: String, + pub revoked_at: Option, + pub revocation_reason: Option, + /// For agent_authorization credentials: the user's Beltic subject.id + /// (e.g., "usr_42") that delegated authority to this agent. Always + /// present on wallet-permission credentials per FinCEN AML. + pub delegated_by_subject_id: Option, + /// Position in Beltic's revocation bitstring. The verifier checks the + /// bit at this index against the Status List 2021 endpoint. + pub status_list_index: u64, +} + +/// Input for `save` — what the integration layer hands us after a +/// successful Beltic issuance. We don't accept a `status` here because +/// every freshly-issued credential is `Active`; status mutations happen +/// only via `update_status` (driven by webhooks or by Houston's own +/// revoke action). +#[derive(Debug, Clone)] +pub struct NewCredential { + pub credential_id: String, + pub credential_type: String, + pub subject_type: String, + pub subject_id: String, + pub issuer_did: String, + pub kid: String, + pub alg: String, + pub signed_payload: String, + pub claims: serde_json::Value, + pub issued_at: String, + pub expires_at: String, + pub delegated_by_subject_id: Option, + pub status_list_index: u64, +} diff --git a/engine/houston-engine-core/src/lib.rs b/engine/houston-engine-core/src/lib.rs index cabaffc28..02bed39fc 100644 --- a/engine/houston-engine-core/src/lib.rs +++ b/engine/houston-engine-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod agents; pub mod agents_crud; pub mod attachments; pub mod conversations; +pub mod credentials; pub mod error; pub mod git_bash; pub mod paths; From 45ae4651f290dbe81adda1d19bf47fb7d922d9b1 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 16:56:53 -0400 Subject: [PATCH 03/19] feat(engine-server): credentials + webhook routes; HoustonEvent::Credential* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires houston-beltic into the engine: REST + WS exposure. Events route to `agent:{agent_path}` so the existing per-agent subscription picks them up alongside ActivityChanged/SkillsChanged/etc. - houston-ui-events: 3 new HoustonEvent variants — CredentialIssued, CredentialRevoked, CredentialSuspended (all carry agent_path + credential_id) - houston-engine-protocol::event_topic: routes the new variants to `agent:{agent_path}` so existing UI subscriptions get them - routes/credentials.rs: GET /v1/agents/credentials?agent_path=... list local POST /v1/agents/credentials?agent_path=... issue via Beltic POST /v1/agents/credentials/:id/revoke?agent_path=… revoke via Beltic POST /v1/agents/credentials/:id/verify?agent_path=… local JWT-VC verify - routes/webhooks_beltic.rs: POST /v1/webhooks/beltic receive + verify HMAC + propagate status change to the matching agent - routes/beltic_shared.rs: lazy OnceLock-backed BelticContext (Client + Issuer + Verifier from env) and map_beltic() error translator. Shared by both route files. - houston-beltic::IssueRequest now `Deserialize` so axum can parse it from JSON. WebhookVerifier gains associated constants SIGNATURE_HEADER/TIMESTAMP_HEADER for ergonomic access in handlers. 255 workspace tests passing (houston-beltic 35 + houston-engine-core 220). --- engine/houston-beltic/src/issuer.rs | 2 +- engine/houston-beltic/src/webhook_verifier.rs | 6 + engine/houston-engine-protocol/src/lib.rs | 3 + engine/houston-engine-server/Cargo.toml | 1 + engine/houston-engine-server/src/lib.rs | 2 + .../src/routes/beltic_shared.rs | 74 ++++++++ .../src/routes/credentials.rs | 174 ++++++++++++++++++ .../houston-engine-server/src/routes/mod.rs | 3 + .../src/routes/webhooks_beltic.rs | 152 +++++++++++++++ engine/houston-ui-events/src/lib.rs | 25 +++ 10 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 engine/houston-engine-server/src/routes/beltic_shared.rs create mode 100644 engine/houston-engine-server/src/routes/credentials.rs create mode 100644 engine/houston-engine-server/src/routes/webhooks_beltic.rs diff --git a/engine/houston-beltic/src/issuer.rs b/engine/houston-beltic/src/issuer.rs index 524b9fc6f..5a4ec967d 100644 --- a/engine/houston-beltic/src/issuer.rs +++ b/engine/houston-beltic/src/issuer.rs @@ -59,7 +59,7 @@ pub struct Credential { /// union types explicitly — that lives in the schemas package on the Beltic /// side. We do validate the `self_attestation_complete` gate + the FinCEN /// delegation requirement client-side. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct IssueRequest { pub credential_type: String, pub self_attestation_complete: bool, diff --git a/engine/houston-beltic/src/webhook_verifier.rs b/engine/houston-beltic/src/webhook_verifier.rs index e47e4fbf5..7f16f200e 100644 --- a/engine/houston-beltic/src/webhook_verifier.rs +++ b/engine/houston-beltic/src/webhook_verifier.rs @@ -31,6 +31,12 @@ pub struct WebhookVerifier { } impl WebhookVerifier { + /// Same value as the free `SIGNATURE_HEADER` constant — duplicated as + /// an associated constant for ergonomic `WebhookVerifier::SIGNATURE_HEADER` + /// access from route handlers that hold the type but not the module path. + pub const SIGNATURE_HEADER: &'static str = SIGNATURE_HEADER; + pub const TIMESTAMP_HEADER: &'static str = TIMESTAMP_HEADER; + pub fn new(secret: impl Into>) -> BelticResult { let secret = secret.into(); if secret.is_empty() { diff --git a/engine/houston-engine-protocol/src/lib.rs b/engine/houston-engine-protocol/src/lib.rs index 625e49e26..3b69d1d17 100644 --- a/engine/houston-engine-protocol/src/lib.rs +++ b/engine/houston-engine-protocol/src/lib.rs @@ -163,6 +163,9 @@ pub fn event_topic(event: &HoustonEvent) -> String { HoustonEvent::ProviderLoginUrl { .. } | HoustonEvent::ProviderLoginComplete { .. } => { "providers".into() } + HoustonEvent::CredentialIssued { agent_path, .. } + | HoustonEvent::CredentialRevoked { agent_path, .. } + | HoustonEvent::CredentialSuspended { agent_path, .. } => format!("agent:{agent_path}"), } } diff --git a/engine/houston-engine-server/Cargo.toml b/engine/houston-engine-server/Cargo.toml index 93de0a2d0..855382353 100644 --- a/engine/houston-engine-server/Cargo.toml +++ b/engine/houston-engine-server/Cargo.toml @@ -28,6 +28,7 @@ futures-util = "0.3" houston-engine-protocol = { version = "0.4.0", path = "../houston-engine-protocol" } houston-engine-core = { version = "0.4.0", path = "../houston-engine-core" } houston-composio = { workspace = true } +houston-beltic = { workspace = true } houston-claude-installer = { workspace = true } houston-cli-bundle = { workspace = true } houston-tunnel = { workspace = true } diff --git a/engine/houston-engine-server/src/lib.rs b/engine/houston-engine-server/src/lib.rs index 3142cdc90..9a2d58e44 100644 --- a/engine/houston-engine-server/src/lib.rs +++ b/engine/houston-engine-server/src/lib.rs @@ -38,6 +38,8 @@ pub fn build_router(state: Arc) -> Router { .merge(routes::agents::router()) .merge(routes::agent_files::router()) .merge(routes::composio::router()) + .merge(routes::credentials::router()) + .merge(routes::webhooks_beltic::router()) .merge(routes::claude::router()) .merge(routes::tunnel::router()) .merge(routes::watcher::router()) diff --git a/engine/houston-engine-server/src/routes/beltic_shared.rs b/engine/houston-engine-server/src/routes/beltic_shared.rs new file mode 100644 index 000000000..425427b5d --- /dev/null +++ b/engine/houston-engine-server/src/routes/beltic_shared.rs @@ -0,0 +1,74 @@ +//! Shared helpers for the Beltic-backed routes (`credentials` + +//! `webhooks_beltic`). Lazy-init context (env-driven Client + Issuer + +//! Verifier) and error mapping from typed `BelticError` to `ApiError`. + +use std::sync::OnceLock; + +use houston_beltic::{ + BelticError, Client as BelticClient, Configuration as BelticConfig, Issuer, Verifier, +}; +use houston_engine_core::CoreError; + +use super::error::ApiError; + +pub struct BelticContext { + pub issuer: Issuer, + pub verifier: Verifier, +} + +/// Lazy-init the Beltic client + issuer + verifier from env vars on first +/// access. Failures cache too — surface "BELTIC_API_KEY not set" as +/// `Unavailable` so the UI can render an empty-state instead of a crash. +pub fn ctx() -> Result<&'static BelticContext, ApiError> { + static BELTIC: OnceLock> = OnceLock::new(); + let cached = BELTIC.get_or_init(|| { + let cfg = BelticConfig::from_env(); + if !cfg.configured() { + return Err( + "BELTIC_API_KEY is not set — set it in env to issue or verify credentials".into(), + ); + } + let client = BelticClient::new(cfg.clone()).map_err(|e| e.to_string())?; + let verifier = Verifier::new(cfg).map_err(|e| e.to_string())?; + Ok(BelticContext { + issuer: Issuer::new(client), + verifier, + }) + }); + cached + .as_ref() + .map_err(|e| ApiError(CoreError::Unavailable(e.clone()))) +} + +/// Translate `BelticError` to `ApiError` (wraps `CoreError`). Network +/// failures and 5xx surface as `Unavailable` so retries make sense; user +/// errors (schema, attestation, delegation) surface as `BadRequest`. +pub fn map_beltic(err: BelticError) -> ApiError { + use BelticError as B; + let core = match err { + B::Unauthorized(m) | B::Forbidden(m) => CoreError::BadRequest(format!("beltic auth: {m}")), + B::NotFound(m) => CoreError::NotFound(m), + B::SelfAttestationIncomplete(m) => { + CoreError::BadRequest(format!("self-attestation gate: {m}")) + } + B::SchemaValidation(m) => CoreError::BadRequest(format!("beltic schema: {m}")), + B::DelegationMissing => CoreError::BadRequest( + "agent_authorization with wallet permissions requires delegated_by_subject_id (FinCEN AML)" + .into(), + ), + B::Configuration(m) => CoreError::Unavailable(format!("beltic not configured: {m}")), + B::Transport(m) => CoreError::Unavailable(format!("beltic transport: {m}")), + B::Server { code, message } => { + CoreError::Internal(format!("beltic upstream ({code}): {message}")) + } + B::Client { code, message } => { + CoreError::BadRequest(format!("beltic ({code}): {message}")) + } + B::BadResponseBody(m) => CoreError::Internal(format!("beltic body: {m}")), + B::WebhookSignature(m) => CoreError::BadRequest(format!("beltic webhook sig: {m}")), + B::Verification { reason, detail } => { + CoreError::BadRequest(format!("beltic verify ({reason}): {detail}")) + } + }; + ApiError(core) +} diff --git a/engine/houston-engine-server/src/routes/credentials.rs b/engine/houston-engine-server/src/routes/credentials.rs new file mode 100644 index 000000000..7bfaa012c --- /dev/null +++ b/engine/houston-engine-server/src/routes/credentials.rs @@ -0,0 +1,174 @@ +//! Beltic verifiable-credentials routes. +//! +//! Agent-scoped CRUD over `.houston/credentials/` plus a thin wrapper +//! around `houston_beltic::Issuer`. Handlers persist via +//! `houston_engine_core::credentials::store` and emit +//! `HoustonEvent::Credential*` so the UI invalidates its query keys. +//! +//! Beltic client init + error mapping live in [`super::beltic_shared`]. + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use houston_beltic::issuer::IssueRequest; +use houston_engine_core::credentials::{ + self, CredentialStatus, NewCredential, VerifiableCredential, +}; +use houston_engine_core::CoreError; +use houston_ui_events::HoustonEvent; +use serde::Deserialize; + +use super::beltic_shared::{ctx as beltic_ctx, map_beltic}; +use super::error::ApiError; +use crate::state::ServerState; + +pub fn router() -> Router> { + Router::new() + .route( + "/agents/credentials", + get(list_credentials).post(issue_credential), + ) + .route( + "/agents/credentials/:credential_id/revoke", + post(revoke_credential), + ) + .route( + "/agents/credentials/:credential_id/verify", + post(verify_credential), + ) +} + +#[derive(Deserialize)] +struct AgentQuery { + agent_path: String, +} + +#[derive(Deserialize)] +struct VerifyRequest { + /// Transaction context to evaluate against `claims.permissions[]`. + /// E.g. `{"resource_type":"wallet","action":"checkout","transaction_amount":5000}`. + #[serde(default)] + context: serde_json::Value, +} + +async fn list_credentials( + State(_st): State>, + Query(q): Query, +) -> Result>, ApiError> { + let root = resolve_root(&q.agent_path)?; + Ok(Json(credentials::list(&root)?)) +} + +async fn issue_credential( + State(st): State>, + Query(q): Query, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let root = resolve_root(&q.agent_path)?; + let beltic = beltic_ctx()?; + let credential_type = input.credential_type.clone(); + let issued = beltic.issuer.issue(input).await.map_err(map_beltic)?; + + let row = credentials::save( + &root, + NewCredential { + credential_id: issued.credential_id.clone(), + credential_type, + subject_type: string_field(&issued.subject, "type"), + subject_id: string_field(&issued.subject, "id"), + issuer_did: issued.issuer_did, + kid: issued.kid, + alg: issued.alg, + signed_payload: issued.signed_payload, + claims: issued.claims.clone(), + issued_at: issued.issued_at, + expires_at: issued.expires_at, + delegated_by_subject_id: issued + .claims + .get("delegated_by_subject_id") + .and_then(|v| v.as_str()) + .map(str::to_string), + status_list_index: issued.status_list_index, + }, + )?; + + st.engine.events.emit(HoustonEvent::CredentialIssued { + agent_path: q.agent_path.clone(), + credential_id: row.credential_id.clone(), + }); + Ok((StatusCode::CREATED, Json(row))) +} + +async fn revoke_credential( + State(st): State>, + Path(credential_id): Path, + Query(q): Query, +) -> Result, ApiError> { + let root = resolve_root(&q.agent_path)?; + let beltic = beltic_ctx()?; + let revoked = beltic + .issuer + .revoke(&credential_id, Some("revoked_by_user")) + .await + .map_err(map_beltic)?; + + let row = credentials::update_status( + &root, + &credential_id, + CredentialStatus::Revoked, + revoked.revoked_at, + revoked.revocation_reason, + )?; + + st.engine.events.emit(HoustonEvent::CredentialRevoked { + agent_path: q.agent_path.clone(), + credential_id: row.credential_id.clone(), + }); + Ok(Json(row)) +} + +async fn verify_credential( + State(_st): State>, + Path(credential_id): Path, + Query(q): Query, + Json(req): Json, +) -> Result, ApiError> { + let root = resolve_root(&q.agent_path)?; + let cred = credentials::find_by_credential_id(&root, &credential_id)? + .ok_or_else(|| CoreError::NotFound(format!("credential {credential_id}")))?; + let beltic = beltic_ctx()?; + let result = beltic + .verifier + .verify(&cred.signed_payload, &req.context) + .await + .map_err(map_beltic)?; + Ok(Json(result)) +} + +fn string_field(value: &serde_json::Value, key: &str) -> String { + value + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +fn resolve_root(agent_path: &str) -> Result { + if agent_path.trim().is_empty() { + return Err(CoreError::BadRequest("agent_path is required".into())); + } + Ok(expand_tilde(std::path::Path::new(agent_path))) +} + +fn expand_tilde(p: &std::path::Path) -> PathBuf { + if let Ok(stripped) = p.strip_prefix("~") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(stripped); + } + } + p.to_path_buf() +} diff --git a/engine/houston-engine-server/src/routes/mod.rs b/engine/houston-engine-server/src/routes/mod.rs index 66e2dd5aa..438d7e6d3 100644 --- a/engine/houston-engine-server/src/routes/mod.rs +++ b/engine/houston-engine-server/src/routes/mod.rs @@ -4,9 +4,11 @@ pub mod agent_configs; pub mod agent_files; pub mod agents; pub mod attachments; +pub mod beltic_shared; pub mod claude; pub mod composio; pub mod conversations; +pub mod credentials; pub mod error; pub mod health; pub mod portable; @@ -18,6 +20,7 @@ pub mod skills; pub mod store; pub mod tunnel; pub mod watcher; +pub mod webhooks_beltic; pub mod worktree; pub mod workspaces; diff --git a/engine/houston-engine-server/src/routes/webhooks_beltic.rs b/engine/houston-engine-server/src/routes/webhooks_beltic.rs new file mode 100644 index 000000000..68dc20a44 --- /dev/null +++ b/engine/houston-engine-server/src/routes/webhooks_beltic.rs @@ -0,0 +1,152 @@ +//! Webhook receiver for Beltic credential status events. +//! +//! Beltic POSTs to `/v1/webhooks/beltic` with the Stripe-pattern +//! `Beltic-Signature` + `Beltic-Timestamp` headers. We verify the HMAC +//! before parsing the body, look up the credential locally (the webhook +//! body identifies it by `credential_id`), update its status, and emit +//! the matching `HoustonEvent`. +//! +//! Per CLAUDE.md "no silent failures" rule: bad signatures return 401 so +//! the integrator can see something is wrong upstream. Missing local +//! credential rows return 204 — Beltic may emit events for credentials +//! Houston never issued (different orgs sharing a webhook destination +//! is uncommon but legal), and a 204 lets Beltic stop retrying. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::Router; +use houston_beltic::WebhookVerifier; +use houston_engine_core::{ + credentials::{self, CredentialStatus}, + CoreError, +}; +use houston_ui_events::HoustonEvent; +use serde::Deserialize; + +use super::error::ApiError; +use crate::state::ServerState; + +pub fn router() -> Router> { + Router::new().route("/webhooks/beltic", post(receive)) +} + +/// Beltic webhook event shape (verified against +/// `apps/api/credentials/src/operations/audit/streams` in the Beltic +/// platform repo). +#[derive(Debug, Deserialize)] +struct BelticWebhookEvent { + #[allow(dead_code)] + id: String, + event_type: String, + credential_id: String, + #[allow(dead_code)] + #[serde(default)] + credential_type: Option, + #[serde(default)] + outcome_reason: Option, +} + +async fn receive( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Result { + let secret = std::env::var("BELTIC_WEBHOOK_SECRET").map_err(|_| { + ApiError(CoreError::Unavailable( + "BELTIC_WEBHOOK_SECRET not set — engine cannot verify Beltic webhooks".into(), + )) + })?; + let verifier = WebhookVerifier::new(secret).map_err(|e| { + ApiError(CoreError::Internal(format!( + "could not init beltic webhook verifier: {e}" + ))) + })?; + + let sig = headers + .get(WebhookVerifier::SIGNATURE_HEADER) + .and_then(|v| v.to_str().ok()); + let ts = headers + .get(WebhookVerifier::TIMESTAMP_HEADER) + .and_then(|v| v.to_str().ok()); + let now = chrono::Utc::now().timestamp(); + + verifier.verify(&body, sig, ts, now).map_err(|e| { + ApiError(CoreError::BadRequest(format!( + "beltic webhook signature rejected: {e}" + ))) + })?; + + let event: BelticWebhookEvent = serde_json::from_slice(&body) + .map_err(|e| ApiError(CoreError::BadRequest(format!("webhook body: {e}"))))?; + + apply_event(&state, event)?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Walk every agent under every workspace looking for a credential with +/// the given id. This is O(workspaces × agents × credentials) — fine for +/// the per-machine engine scale (single-digit workspaces, single-digit +/// agents per workspace, single-digit credentials per agent). If we ever +/// host multi-tenant Houston, swap for an index in `houston-db`. +fn apply_event(state: &ServerState, event: BelticWebhookEvent) -> Result<(), ApiError> { + let new_status = match event.event_type.as_str() { + "credential.issued" | "credential.reactivated" => CredentialStatus::Active, + "credential.revoked" | "credential.deleted" => CredentialStatus::Revoked, + "credential.suspended" => CredentialStatus::Suspended, + _ => return Ok(()), // unknown event, ignored + }; + + let workspaces = state.engine.paths.home().join("workspaces"); + let Ok(read_dir) = std::fs::read_dir(&workspaces) else { + return Ok(()); + }; + for ws_entry in read_dir.flatten() { + let ws_path = ws_entry.path(); + if !ws_path.is_dir() { + continue; + } + let Ok(agents) = std::fs::read_dir(&ws_path) else { continue }; + for agent_entry in agents.flatten() { + let agent_root = agent_entry.path(); + if !agent_root.is_dir() { + continue; + } + let Ok(Some(_)) = + credentials::find_by_credential_id(&agent_root, &event.credential_id) + else { + continue; + }; + credentials::update_status( + &agent_root, + &event.credential_id, + new_status, + None, + event.outcome_reason.clone(), + )?; + let agent_path = agent_root.to_string_lossy().to_string(); + let evt = match new_status { + CredentialStatus::Active => HoustonEvent::CredentialIssued { + agent_path, + credential_id: event.credential_id.clone(), + }, + CredentialStatus::Revoked | CredentialStatus::Expired => { + HoustonEvent::CredentialRevoked { + agent_path, + credential_id: event.credential_id.clone(), + } + } + CredentialStatus::Suspended => HoustonEvent::CredentialSuspended { + agent_path, + credential_id: event.credential_id.clone(), + }, + }; + state.engine.events.emit(evt); + } + } + Ok(()) +} diff --git a/engine/houston-ui-events/src/lib.rs b/engine/houston-ui-events/src/lib.rs index 9b0bde3c0..1118f59fd 100644 --- a/engine/houston-ui-events/src/lib.rs +++ b/engine/houston-ui-events/src/lib.rs @@ -180,6 +180,31 @@ pub enum HoustonEvent { success: bool, error: Option, }, + + // ----- Beltic verifiable credentials ----- + // + // Emitted when an agent's Beltic credential changes state — either + // because Houston just issued one, because the user revoked it, or + // because a Beltic webhook flipped its status. UI invalidates the + // matching TanStack Query key (per-agent credentials list) and + // re-renders the badge/card. + + /// A new credential was successfully issued and persisted for an agent. + CredentialIssued { + agent_path: String, + credential_id: String, + }, + /// An existing credential transitioned to `revoked`. + CredentialRevoked { + agent_path: String, + credential_id: String, + }, + /// An existing credential was temporarily suspended (Beltic webhook). + /// The credential is no longer presentable but is not permanently gone. + CredentialSuspended { + agent_path: String, + credential_id: String, + }, } // --------------------------------------------------------------------------- From 850d79c7de234ee47bb949d4b2854af03cd412d6 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 16:57:15 -0400 Subject: [PATCH 04/19] chore: bump Cargo.lock after Beltic-related deps land --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 1bca418e9..93a2e8900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2583,6 +2583,7 @@ dependencies = [ "chrono", "futures-util", "houston-agent-portable", + "houston-beltic", "houston-claude-installer", "houston-cli-bundle", "houston-composio", From 0875eb5b72157ad7b09195abe620457deb2eb7bf Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 17:03:08 -0400 Subject: [PATCH 05/19] feat(ui): TS types + engine-client methods + agent-credentials hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Rust DTOs on the TS side so the desktop app can talk to the new credentials routes. Wire format snake_case (Beltic's convention) — no transform layer. - ui/core/src/types.ts: HoustonEvent union gains CredentialIssued / CredentialRevoked / CredentialSuspended variants (all carry agent_path + credential_id, matching the Rust event payload) - ui/engine-client/src/types.ts: VerifiableCredential, CredentialStatus, IssueCredentialRequest, VerifyCredentialResult - ui/engine-client/src/client.ts: 4 new HoustonClient methods — listAgentCredentials / issueAgentCredential / revokeAgentCredential / verifyAgentCredential — wrapping the engine REST surface - app/src/hooks/queries/use-agent-credentials.ts: TanStack Query hooks (useAgentCredentials, useActiveAgentCredential, useIssueAgentCredential, useRevokeAgentCredential, useVerifyAgentCredential). Mutations surface failures via showErrorToast() with Report-bug action per CLAUDE.md no-silent-failures - app/src/hooks/use-agent-invalidation.ts: wire the 3 new events to invalidate the ["agent-credentials", agentPath] query key --- .../hooks/queries/use-agent-credentials.ts | 66 +++++++++++++++++++ app/src/hooks/use-agent-invalidation.ts | 10 +++ ui/core/src/types.ts | 12 ++++ ui/engine-client/src/client.ts | 38 +++++++++++ ui/engine-client/src/types.ts | 47 +++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 app/src/hooks/queries/use-agent-credentials.ts diff --git a/app/src/hooks/queries/use-agent-credentials.ts b/app/src/hooks/queries/use-agent-credentials.ts new file mode 100644 index 000000000..07d038f61 --- /dev/null +++ b/app/src/hooks/queries/use-agent-credentials.ts @@ -0,0 +1,66 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + IssueCredentialRequest, + VerifiableCredential, + VerifyCredentialResult, +} from "@houston-ai/engine-client"; + +import { getEngine } from "../../lib/engine"; +import { showErrorToast } from "../../lib/error-toast"; + +const KEY = "agent-credentials"; + +/** Fetch the credential history for one agent (newest last). */ +export function useAgentCredentials(agentPath: string | undefined) { + return useQuery({ + queryKey: [KEY, agentPath], + enabled: Boolean(agentPath), + queryFn: () => getEngine().listAgentCredentials(agentPath!), + }); +} + +/** Most recently active credential, or `undefined` if none. */ +export function useActiveAgentCredential( + agentPath: string | undefined, +): VerifiableCredential | undefined { + const list = useAgentCredentials(agentPath).data ?? []; + for (let i = list.length - 1; i >= 0; i--) { + if (list[i].status === "active") return list[i]; + } + return undefined; +} + +export function useIssueAgentCredential(agentPath: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: IssueCredentialRequest) => + getEngine().issueAgentCredential(agentPath, input), + onSuccess: () => qc.invalidateQueries({ queryKey: [KEY, agentPath] }), + onError: (err) => + showErrorToast("issueAgentCredential", (err as Error).message), + }); +} + +export function useRevokeAgentCredential(agentPath: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (credentialId: string) => + getEngine().revokeAgentCredential(agentPath, credentialId), + onSuccess: () => qc.invalidateQueries({ queryKey: [KEY, agentPath] }), + onError: (err) => + showErrorToast("revokeAgentCredential", (err as Error).message), + }); +} + +export function useVerifyAgentCredential(agentPath: string) { + return useMutation< + VerifyCredentialResult, + Error, + { credentialId: string; context: unknown } + >({ + mutationFn: ({ credentialId, context }) => + getEngine().verifyAgentCredential(agentPath, credentialId, context), + onError: (err) => + showErrorToast("verifyAgentCredential", (err as Error).message), + }); +} diff --git a/app/src/hooks/use-agent-invalidation.ts b/app/src/hooks/use-agent-invalidation.ts index dad244a00..e8d644d7b 100644 --- a/app/src/hooks/use-agent-invalidation.ts +++ b/app/src/hooks/use-agent-invalidation.ts @@ -65,6 +65,16 @@ export function useAgentInvalidation() { case "ComposioConnectionAdded": qc.invalidateQueries({ queryKey: queryKeys.connectedToolkits() }); break; + // Beltic credential lifecycle — issued/revoked/suspended all + // flip the per-agent credentials list and the verified-status + // badges that depend on it. + case "CredentialIssued": + case "CredentialRevoked": + case "CredentialSuspended": + qc.invalidateQueries({ + queryKey: ["agent-credentials", p.data.agent_path], + }); + break; } }); diff --git a/ui/core/src/types.ts b/ui/core/src/types.ts index afb3aaefa..883eecf7b 100644 --- a/ui/core/src/types.ts +++ b/ui/core/src/types.ts @@ -139,4 +139,16 @@ export type HoustonEvent = | { type: "ProviderLoginComplete"; data: { provider: string; success: boolean; error: string | null }; + } + | { + type: "CredentialIssued"; + data: { agent_path: string; credential_id: string }; + } + | { + type: "CredentialRevoked"; + data: { agent_path: string; credential_id: string }; + } + | { + type: "CredentialSuspended"; + data: { agent_path: string; credential_id: string }; }; diff --git a/ui/engine-client/src/client.ts b/ui/engine-client/src/client.ts index 22dc86a8a..cd37e3e61 100644 --- a/ui/engine-client/src/client.ts +++ b/ui/engine-client/src/client.ts @@ -79,6 +79,9 @@ import type { PortableScanResponse, PortableInstallRequest, PortableInstalledAgent, + VerifiableCredential, + IssueCredentialRequest, + VerifyCredentialResult, } from "./types"; import { planAttachmentUploadBatches } from "./attachments"; @@ -382,6 +385,41 @@ export class HoustonClient { return this.request("PUT", "/agents/config", config, { agent_path: agentPath }); } + // ---------- agents: Beltic credentials ---------- + + listAgentCredentials(agentPath: string): Promise { + return this.request("GET", "/agents/credentials", undefined, { agent_path: agentPath }); + } + issueAgentCredential( + agentPath: string, + input: IssueCredentialRequest, + ): Promise { + return this.request("POST", "/agents/credentials", input, { agent_path: agentPath }); + } + revokeAgentCredential( + agentPath: string, + credentialId: string, + ): Promise { + return this.request( + "POST", + `/agents/credentials/${this.seg(credentialId)}/revoke`, + undefined, + { agent_path: agentPath }, + ); + } + verifyAgentCredential( + agentPath: string, + credentialId: string, + context: unknown, + ): Promise { + return this.request( + "POST", + `/agents/credentials/${this.seg(credentialId)}/verify`, + { context }, + { agent_path: agentPath }, + ); + } + // ---------- agent configs (installed manifests) ---------- listInstalledConfigs(): Promise { diff --git a/ui/engine-client/src/types.ts b/ui/engine-client/src/types.ts index d65a13b07..a9d1c38df 100644 --- a/ui/engine-client/src/types.ts +++ b/ui/engine-client/src/types.ts @@ -806,3 +806,50 @@ export interface PortableInstalledAgent { workspaceName: string; requiredIntegrations: string[]; } + +// --------------------------------------------------------------------------- +// Beltic verifiable credentials +// --------------------------------------------------------------------------- +// +// Mirrors `houston_engine_core::credentials::types::VerifiableCredential` + +// the request/response shapes for the credentials routes. Wire format is +// snake_case so a round-trip needs no transform. + +export type CredentialStatus = "active" | "suspended" | "revoked" | "expired"; + +export interface VerifiableCredential { + credential_id: string; + credential_type: string; + subject_type: string; + subject_id: string; + status: CredentialStatus; + issuer_did: string; + kid: string; + alg: string; + signed_payload: string; + claims: unknown; + issued_at: string; + expires_at: string; + revoked_at: string | null; + revocation_reason: string | null; + delegated_by_subject_id: string | null; + status_list_index: number; +} + +/** Request body for `POST /v1/agents/credentials?agent_path=...`. */ +export interface IssueCredentialRequest { + credential_type: string; + self_attestation_complete: boolean; + subject: unknown; + claims: unknown; + evidence_refs?: string[]; + ttl?: string; +} + +/** Response from the verify endpoint. */ +export interface VerifyCredentialResult { + valid: boolean; + reason: string; + credential_id: string | null; + detail: string | null; +} From 0bf62e320aa0b4aaf85d0a474de99d8ac2421059 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Fri, 22 May 2026 17:08:10 -0400 Subject: [PATCH 06/19] =?UTF-8?q?feat(app):=20Settings=20=E2=86=92=20Ident?= =?UTF-8?q?ity=20+=20Authorized=20agents=20sub-nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new Settings sub-nav items per the v2 Figma design (which I realised matches the actual 3-pane Settings layout the app already uses, not a card-heavy standalone page). - settings-view.tsx: 2 new SettingsSectionId variants ("identity", "agents"); 2 new nav items (ShieldCheck + Users icons); 2 new render branches for the section components - sections/identity.tsx: empty-state pane with a "Verify identity" CTA. Disabled for now — workspace-identity routes land in a follow-up alongside the consent modal - sections/agents.tsx: lists every agent in the current workspace via useAgentStore, one row per agent - sections/agents-row.tsx: pulls per-agent credential via the chunk-4 useActiveAgentCredential hook, shows status pill, short credential id, delegated_by_subject_id chain, and a revoke action. Confirm prompt before mutate; tone-colored status pill - locales en/es/pt: settings.nav.{identity,agents} + settings.identity.* + settings.agents.* keys. check-locales clean, no em dashes per i18n rule `pnpm tsc --noEmit` clean. Mission Control card variants land in a follow-up; they need extending @houston-ai/board status enum and are a chunkier change. --- .../settings/sections/agents-row.tsx | 93 +++++++++++++++++++ .../components/settings/sections/agents.tsx | 52 +++++++++++ .../components/settings/sections/identity.tsx | 44 +++++++++ app/src/locales/en/settings.json | 35 +++++++ app/src/locales/es/settings.json | 35 +++++++ app/src/locales/pt/settings.json | 35 +++++++ 6 files changed, 294 insertions(+) create mode 100644 app/src/components/settings/sections/agents-row.tsx create mode 100644 app/src/components/settings/sections/agents.tsx create mode 100644 app/src/components/settings/sections/identity.tsx diff --git a/app/src/components/settings/sections/agents-row.tsx b/app/src/components/settings/sections/agents-row.tsx new file mode 100644 index 000000000..6e41562cc --- /dev/null +++ b/app/src/components/settings/sections/agents-row.tsx @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { + useActiveAgentCredential, + useRevokeAgentCredential, +} from "../../../hooks/queries/use-agent-credentials"; + +interface Props { + agentName: string; + agentPath: string; +} + +/** One row in the Authorized agents list. */ +export function AgentCredentialsRow({ agentName, agentPath }: Props) { + const { t } = useTranslation("settings"); + const credential = useActiveAgentCredential(agentPath); + const revoke = useRevokeAgentCredential(agentPath); + + const shortId = useMemo(() => { + if (!credential) return null; + const id = credential.credential_id; + if (id.length <= 18) return id; + return `${id.slice(0, 9)}…${id.slice(-6)}`; + }, [credential]); + + return ( +
  • +
    +
    {agentName}
    +
    + {credential ? ( + <> + {shortId} + {credential.delegated_by_subject_id ? ( + + {" · "} + {t("agents.columnDelegation")} + {": "} + + {credential.delegated_by_subject_id} + + + ) : null} + + ) : ( + {t("agents.noCredential")} + )} +
    +
    + + {credential ? : null} + + {credential ? ( + + ) : null} +
  • + ); +} + +function StatusPill({ status }: { status: string }) { + const { t } = useTranslation("settings"); + const label = + status === "active" + ? t("identity.statusVerified") + : status === "revoked" + ? t("identity.statusRevoked") + : status; + const tone = + status === "active" + ? "bg-emerald-50 text-emerald-700 border-emerald-200" + : status === "revoked" + ? "bg-red-50 text-red-700 border-red-200" + : "bg-gray-100 text-gray-700 border-gray-300"; + return ( + + {label} + + ); +} diff --git a/app/src/components/settings/sections/agents.tsx b/app/src/components/settings/sections/agents.tsx new file mode 100644 index 000000000..0e548f3ee --- /dev/null +++ b/app/src/components/settings/sections/agents.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { useAgentStore } from "../../../stores/agents"; +import { AgentCredentialsRow } from "./agents-row"; + +/** + * Settings → Authorized agents. Lists every agent in the current + * workspace plus its Beltic `agent_authorization` credential status, + * delegation chain (the user credential that authorized it), and a + * revoke action. + * + * Each row fetches its own credentials list via TanStack Query. The WS + * event invalidator (chunk 4) auto-refreshes when the engine emits + * CredentialIssued/Revoked/Suspended. + */ +export function AgentsSection() { + const { t } = useTranslation("settings"); + const agents = useAgentStore((s) => s.agents); + + if (agents.length === 0) { + return ( +
    +
    +

    {t("agents.title")}

    +

    {t("agents.subtitle")}

    +
    +
    +

    {t("agents.emptyTitle")}

    +

    {t("agents.emptyDescription")}

    +
    +
    + ); + } + + return ( +
    +
    +

    {t("agents.title")}

    +

    {t("agents.subtitle")}

    +
    + +
      + {agents.map((agent) => ( + + ))} +
    +
    + ); +} diff --git a/app/src/components/settings/sections/identity.tsx b/app/src/components/settings/sections/identity.tsx new file mode 100644 index 000000000..cfa1e6750 --- /dev/null +++ b/app/src/components/settings/sections/identity.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; + +/** + * Settings → Identity. Renders the user's Beltic-issued user credential + * (status, trust level, issued/expires, credential id) and the actions + * to (re-)verify or revoke it. + * + * For chunk 5 the workspace-level identity routes aren't wired through + * the engine yet — chunk 3 only built the agent_authorization surface. + * So this section renders the unverified empty state. A follow-up adds + * the workspace identity route + connects the verify CTA to it. + */ +export function IdentitySection() { + const { t } = useTranslation("settings"); + + return ( +
    +
    +

    {t("identity.title")}

    +

    {t("identity.subtitle")}

    +
    + +
    +
    +

    + {t("identity.emptyTitle")} +

    +

    + {t("identity.emptyDescription")} +

    +
    + + +
    +
    + ); +} diff --git a/app/src/locales/en/settings.json b/app/src/locales/en/settings.json index db1ec0728..a816eed86 100644 --- a/app/src/locales/en/settings.json +++ b/app/src/locales/en/settings.json @@ -6,9 +6,44 @@ "workspaceContext": "Workspace context", "userContext": "User context", "provider": "AI provider", + "identity": "Identity", + "agents": "Authorized agents", "phone": "Connect phone", "reportBug": "Report bug" }, + "identity": { + "title": "Identity", + "subtitle": "Your Beltic-issued credential, plus options to strengthen it.", + "statusVerified": "Verified", + "statusUnverified": "Not verified", + "statusPending": "Pending", + "statusRevoked": "Revoked", + "trustLevel": "Trust level", + "credentialId": "Credential ID", + "issued": "Issued", + "expires": "Expires", + "emptyTitle": "Verify your identity", + "emptyDescription": "Issue a Beltic credential so your agents can act on your behalf. Takes a minute.", + "verifyCta": "Verify identity", + "reverify": "Re-verify identity", + "revokeCta": "Revoke credential", + "viewOnBeltic": "View on Beltic" + }, + "agents": { + "title": "Authorized agents", + "subtitle": "Agents you've delegated authority to. Each is bound to a Beltic credential that proves the delegation chain.", + "addCta": "Authorize new agent", + "emptyTitle": "No agents authorized yet", + "emptyDescription": "Once an agent has a Beltic credential it can act on your behalf within the limits you set.", + "columnAgent": "Agent", + "columnStatus": "Status", + "columnCredential": "Credential", + "columnDelegation": "Delegated by", + "columnActions": "Actions", + "noCredential": "no credential", + "revoke": "Revoke", + "revokeConfirm": "Revoke this agent's Beltic credential? It can no longer make purchases or act on your behalf until you re-authorize it." + }, "account": { "title": "Account", "signOut": "Sign out", diff --git a/app/src/locales/es/settings.json b/app/src/locales/es/settings.json index 7f2d1aa9e..e0e292b08 100644 --- a/app/src/locales/es/settings.json +++ b/app/src/locales/es/settings.json @@ -6,9 +6,44 @@ "workspaceContext": "Contexto del espacio", "userContext": "Contexto del usuario", "provider": "Proveedor de IA", + "identity": "Identidad", + "agents": "Agentes autorizados", "phone": "Conectar celular", "reportBug": "Reportar bug" }, + "identity": { + "title": "Identidad", + "subtitle": "Tu credencial emitida por Beltic, con opciones para fortalecerla.", + "statusVerified": "Verificada", + "statusUnverified": "Sin verificar", + "statusPending": "Pendiente", + "statusRevoked": "Revocada", + "trustLevel": "Nivel de confianza", + "credentialId": "ID de credencial", + "issued": "Emitida", + "expires": "Vence", + "emptyTitle": "Verifica tu identidad", + "emptyDescription": "Emite una credencial Beltic para que tus agentes actúen en tu nombre. Toma un minuto.", + "verifyCta": "Verificar identidad", + "reverify": "Volver a verificar", + "revokeCta": "Revocar credencial", + "viewOnBeltic": "Ver en Beltic" + }, + "agents": { + "title": "Agentes autorizados", + "subtitle": "Agentes a los que delegaste autoridad. Cada uno tiene una credencial Beltic que prueba la cadena de delegación.", + "addCta": "Autorizar nuevo agente", + "emptyTitle": "Aún no autorizaste ningún agente", + "emptyDescription": "Cuando un agente tiene una credencial Beltic puede actuar en tu nombre dentro de los límites que definas.", + "columnAgent": "Agente", + "columnStatus": "Estado", + "columnCredential": "Credencial", + "columnDelegation": "Delegado por", + "columnActions": "Acciones", + "noCredential": "sin credencial", + "revoke": "Revocar", + "revokeConfirm": "¿Revocar la credencial Beltic de este agente? No podrá comprar ni actuar en tu nombre hasta que lo vuelvas a autorizar." + }, "account": { "title": "Cuenta", "signOut": "Cerrar sesión", diff --git a/app/src/locales/pt/settings.json b/app/src/locales/pt/settings.json index b7632244c..0844d10a4 100644 --- a/app/src/locales/pt/settings.json +++ b/app/src/locales/pt/settings.json @@ -6,9 +6,44 @@ "workspaceContext": "Contexto do espaço", "userContext": "Contexto do usuário", "provider": "Provedor de IA", + "identity": "Identidade", + "agents": "Agentes autorizados", "phone": "Conectar celular", "reportBug": "Reportar bug" }, + "identity": { + "title": "Identidade", + "subtitle": "Sua credencial emitida pela Beltic, com opções para reforçá-la.", + "statusVerified": "Verificada", + "statusUnverified": "Não verificada", + "statusPending": "Pendente", + "statusRevoked": "Revogada", + "trustLevel": "Nível de confiança", + "credentialId": "ID da credencial", + "issued": "Emitida", + "expires": "Expira", + "emptyTitle": "Verifique sua identidade", + "emptyDescription": "Emita uma credencial Beltic para que seus agentes ajam em seu nome. Leva um minuto.", + "verifyCta": "Verificar identidade", + "reverify": "Verificar novamente", + "revokeCta": "Revogar credencial", + "viewOnBeltic": "Ver na Beltic" + }, + "agents": { + "title": "Agentes autorizados", + "subtitle": "Agentes aos quais você delegou autoridade. Cada um tem uma credencial Beltic que prova a cadeia de delegação.", + "addCta": "Autorizar novo agente", + "emptyTitle": "Ainda nenhum agente autorizado", + "emptyDescription": "Quando um agente tem uma credencial Beltic, ele pode agir em seu nome dentro dos limites que você definir.", + "columnAgent": "Agente", + "columnStatus": "Status", + "columnCredential": "Credencial", + "columnDelegation": "Delegado por", + "columnActions": "Ações", + "noCredential": "sem credencial", + "revoke": "Revogar", + "revokeConfirm": "Revogar a credencial Beltic deste agente? Ele não poderá comprar nem agir em seu nome até ser autorizado novamente." + }, "account": { "title": "Conta", "signOut": "Sair", From 1e5856750d2e555675f8ffdd836b3afc3e63a4c0 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Sat, 23 May 2026 19:47:26 -0400 Subject: [PATCH 07/19] feat(app): agent authorization consent dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the Figma "Authorize this agent" screen. Opens from the Authorize button on each agent row in Settings → Authorized agents. Closes on successful issuance; the WS event invalidator (chunk 4) refreshes the row's status pill. - authorize-agent-dialog.tsx: modal with spend-limit fields (daily + per-transaction), currency chips (USD/BRL/EUR/GBP), ISO-8601 idle timeout, 3-mode confirmation rules (always / threshold / never) with inline threshold input, declaration checkbox. Submits via the useIssueAgentCredential mutation from chunk 4. - agents-row.tsx: when an agent has no active credential, shows an Authorize button that launches the modal. When it has one, shows the Revoke button (same behaviour as before). - agents.tsx: passes the new agentId prop down. - locales en/es/pt: settings.agents.consent.* keys covering every string in the modal. check-locales clean. Known placeholders (called out in code comments) — chunk 8 follow-ups: - subject.id is `did:jwk:houston-` for now; a real ES256 keypair (generated server-side, key stored encrypted) lands with the identity flow - claims.delegated_by_subject_id is `usr_houston_local` placeholder; pulls from the workspace identity credential once chunk 8 wires identity routes --- .../settings/sections/agents-row.tsx | 29 +- .../components/settings/sections/agents.tsx | 1 + .../sections/authorize-agent-dialog.tsx | 322 ++++++++++++++++++ app/src/locales/en/settings.json | 26 +- app/src/locales/es/settings.json | 26 +- app/src/locales/pt/settings.json | 26 +- 6 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 app/src/components/settings/sections/authorize-agent-dialog.tsx diff --git a/app/src/components/settings/sections/agents-row.tsx b/app/src/components/settings/sections/agents-row.tsx index 6e41562cc..5cafa787e 100644 --- a/app/src/components/settings/sections/agents-row.tsx +++ b/app/src/components/settings/sections/agents-row.tsx @@ -1,21 +1,24 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useActiveAgentCredential, useRevokeAgentCredential, } from "../../../hooks/queries/use-agent-credentials"; +import { AuthorizeAgentDialog } from "./authorize-agent-dialog"; interface Props { + agentId: string; agentName: string; agentPath: string; } /** One row in the Authorized agents list. */ -export function AgentCredentialsRow({ agentName, agentPath }: Props) { +export function AgentCredentialsRow({ agentId, agentName, agentPath }: Props) { const { t } = useTranslation("settings"); const credential = useActiveAgentCredential(agentPath); const revoke = useRevokeAgentCredential(agentPath); + const [authOpen, setAuthOpen] = useState(false); const shortId = useMemo(() => { if (!credential) return null; @@ -51,11 +54,11 @@ export function AgentCredentialsRow({ agentName, agentPath }: Props) { {credential ? : null} - {credential ? ( + {credential && credential.status === "active" ? ( - ) : null} + ) : ( + + )} + + ); } diff --git a/app/src/components/settings/sections/agents.tsx b/app/src/components/settings/sections/agents.tsx index 0e548f3ee..3d1d1a4bc 100644 --- a/app/src/components/settings/sections/agents.tsx +++ b/app/src/components/settings/sections/agents.tsx @@ -42,6 +42,7 @@ export function AgentsSection() { {agents.map((agent) => ( diff --git a/app/src/components/settings/sections/authorize-agent-dialog.tsx b/app/src/components/settings/sections/authorize-agent-dialog.tsx new file mode 100644 index 000000000..885449442 --- /dev/null +++ b/app/src/components/settings/sections/authorize-agent-dialog.tsx @@ -0,0 +1,322 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@houston-ai/core"; + +import { useIssueAgentCredential } from "../../../hooks/queries/use-agent-credentials"; + +interface Props { + agentId: string; + agentName: string; + agentPath: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type ConfirmationMode = "always" | "threshold" | "never"; + +const CURRENCY_CHOICES = ["USD", "BRL", "EUR", "GBP"] as const; +type Currency = (typeof CURRENCY_CHOICES)[number]; + +/** + * Agent authorization consent modal — maps the Figma "Authorize this + * agent" screen onto an issueAgentCredential mutation. User sets spend + * limits / currencies / confirmation rules, Houston builds the Beltic + * IssueRequest and ships it. + * + * The `subject.id` (did:jwk) is a placeholder for this chunk — a follow-up + * generates a real ES256 keypair server-side. Same for + * `delegated_by_subject_id` which will pull from the workspace identity + * credential once chunk 8 lands. + */ +export function AuthorizeAgentDialog({ + agentId, + agentName, + agentPath, + open, + onOpenChange, +}: Props) { + const { t } = useTranslation("settings"); + const issue = useIssueAgentCredential(agentPath); + + const [dailyLimit, setDailyLimit] = useState("250"); + const [perTxMax, setPerTxMax] = useState("100"); + const [currencies, setCurrencies] = useState(["USD"]); + const [idle, setIdle] = useState("PT4H"); + const [confirmMode, setConfirmMode] = useState("threshold"); + const [threshold, setThreshold] = useState("50"); + const [declarationOk, setDeclarationOk] = useState(false); + + const subjectDid = useMemo( + () => `did:jwk:houston-${agentId.slice(0, 12)}`, + [agentId], + ); + + function buildRequest() { + const dailyCents = Math.round(Number(dailyLimit) * 100); + const perTxCents = Math.round(Number(perTxMax) * 100); + return { + credential_type: "agent_authorization", + self_attestation_complete: true, + subject: { + type: "agent", + id: subjectDid, + agent_external_id: agentId, + }, + claims: { + permissions: [ + { + resource_type: "wallet", + resource_id: "*", + actions: ["checkout", "payment_authorize"], + conditions: [ + { + operator: "lte", + field: "transaction_amount", + value: perTxCents, + }, + ], + }, + ], + spend_limit: { + amount: dailyCents, + currency: currencies[0] ?? "USD", + period: "daily", + }, + authorized_currencies: currencies, + max_idle_duration: idle, + human_present: confirmMode !== "never", + confirmation_threshold_cents: + confirmMode === "threshold" ? Math.round(Number(threshold) * 100) : null, + // Placeholder — chunk 8 wires this to the real user credential id. + delegated_by_subject_id: "usr_houston_local", + }, + evidence_refs: [], + ttl: "P30D" as const, + }; + } + + async function onSubmit() { + if (!declarationOk) return; + try { + await issue.mutateAsync(buildRequest()); + onOpenChange(false); + setDeclarationOk(false); + } catch { + // showErrorToast already fired inside the mutation's onError. + } + } + + return ( + + + + + {t("agents.consent.title")} — {agentName} + + {t("agents.consent.subtitle")} + + +
    +
    +

    {t("agents.consent.spendLimits")}

    + + + + +
    + +
    + {CURRENCY_CHOICES.map((c) => { + const on = currencies.includes(c); + return ( + + ); + })} +
    +
    + + +
    + +
    +

    {t("agents.consent.confirmHeader")}

    + setConfirmMode("always")} + title={t("agents.consent.confirmAlways")} + desc={t("agents.consent.confirmAlwaysDesc")} + /> + setConfirmMode("threshold")} + title={t("agents.consent.confirmThreshold")} + desc={t("agents.consent.confirmThresholdDesc")} + /> + {confirmMode === "threshold" ? ( +
    + +
    + ) : null} + setConfirmMode("never")} + title={t("agents.consent.confirmNever")} + desc={t("agents.consent.confirmNeverDesc")} + /> +
    + + +
    + + + + + +
    +
    + ); +} + +interface LabeledProps { + label: string; + value: string; + onChange: (v: string) => void; + help?: string; + suffix?: string; +} + +function LabeledNumber({ label, value, onChange, suffix }: LabeledProps) { + return ( + + ); +} + +function LabeledText({ label, value, onChange, help }: LabeledProps) { + return ( +
    + + {help ?

    {help}

    : null} +
    + ); +} + +function RadioRow({ + checked, + onSelect, + title, + desc, +}: { + checked: boolean; + onSelect: () => void; + title: string; + desc: string; +}) { + return ( + + ); +} diff --git a/app/src/locales/en/settings.json b/app/src/locales/en/settings.json index a816eed86..8284d8539 100644 --- a/app/src/locales/en/settings.json +++ b/app/src/locales/en/settings.json @@ -33,6 +33,7 @@ "title": "Authorized agents", "subtitle": "Agents you've delegated authority to. Each is bound to a Beltic credential that proves the delegation chain.", "addCta": "Authorize new agent", + "authorize": "Authorize", "emptyTitle": "No agents authorized yet", "emptyDescription": "Once an agent has a Beltic credential it can act on your behalf within the limits you set.", "columnAgent": "Agent", @@ -42,7 +43,30 @@ "columnActions": "Actions", "noCredential": "no credential", "revoke": "Revoke", - "revokeConfirm": "Revoke this agent's Beltic credential? It can no longer make purchases or act on your behalf until you re-authorize it." + "revokeConfirm": "Revoke this agent's Beltic credential? It can no longer make purchases or act on your behalf until you re-authorize it.", + "consent": { + "title": "Authorize this agent", + "subtitle": "Set the limits Beltic will encode into the credential. You can revoke any time.", + "spendLimits": "Spending limits", + "dailyLimit": "Daily limit", + "perTransactionMax": "Per-transaction maximum", + "currencies": "Authorized currencies", + "idleTimeout": "Auto-pause if idle", + "idleHelp": "ISO-8601 duration, e.g. PT4H for four hours.", + "confirmHeader": "Confirm before purchase", + "confirmAlways": "Always confirm with me", + "confirmAlwaysDesc": "Best for a new agent you haven't tested yet.", + "confirmThreshold": "Only purchases above a threshold", + "confirmThresholdDesc": "Agent runs autonomously under the threshold and asks above it.", + "confirmNever": "Never (full autonomy)", + "confirmNeverDesc": "Higher risk: agent can spend up to your daily limit without asking.", + "thresholdLabel": "Threshold", + "declaration": "I understand this agent can spend up to the daily limit on my behalf, and I can revoke it from Settings at any time.", + "submit": "Authorize agent", + "cancel": "Cancel", + "issuing": "Authorizing…", + "missingDeclaration": "Confirm the declaration before authorizing." + } }, "account": { "title": "Account", diff --git a/app/src/locales/es/settings.json b/app/src/locales/es/settings.json index e0e292b08..7e8856779 100644 --- a/app/src/locales/es/settings.json +++ b/app/src/locales/es/settings.json @@ -33,6 +33,7 @@ "title": "Agentes autorizados", "subtitle": "Agentes a los que delegaste autoridad. Cada uno tiene una credencial Beltic que prueba la cadena de delegación.", "addCta": "Autorizar nuevo agente", + "authorize": "Autorizar", "emptyTitle": "Aún no autorizaste ningún agente", "emptyDescription": "Cuando un agente tiene una credencial Beltic puede actuar en tu nombre dentro de los límites que definas.", "columnAgent": "Agente", @@ -42,7 +43,30 @@ "columnActions": "Acciones", "noCredential": "sin credencial", "revoke": "Revocar", - "revokeConfirm": "¿Revocar la credencial Beltic de este agente? No podrá comprar ni actuar en tu nombre hasta que lo vuelvas a autorizar." + "revokeConfirm": "¿Revocar la credencial Beltic de este agente? No podrá comprar ni actuar en tu nombre hasta que lo vuelvas a autorizar.", + "consent": { + "title": "Autorizar este agente", + "subtitle": "Define los límites que Beltic codificará en la credencial. Podés revocar cuando quieras.", + "spendLimits": "Límites de gasto", + "dailyLimit": "Límite diario", + "perTransactionMax": "Máximo por transacción", + "currencies": "Monedas autorizadas", + "idleTimeout": "Pausar si está inactivo", + "idleHelp": "Duración ISO-8601, ej. PT4H para cuatro horas.", + "confirmHeader": "Confirmar antes de comprar", + "confirmAlways": "Confirmar siempre", + "confirmAlwaysDesc": "Mejor para un agente nuevo que aún no probaste.", + "confirmThreshold": "Solo si supera un umbral", + "confirmThresholdDesc": "El agente corre autónomo debajo del umbral y pregunta por encima.", + "confirmNever": "Nunca (autonomía total)", + "confirmNeverDesc": "Mayor riesgo: el agente puede gastar hasta el límite diario sin preguntar.", + "thresholdLabel": "Umbral", + "declaration": "Entiendo que este agente puede gastar hasta el límite diario en mi nombre, y puedo revocarlo desde Configuración cuando quiera.", + "submit": "Autorizar agente", + "cancel": "Cancelar", + "issuing": "Autorizando…", + "missingDeclaration": "Confirmá la declaración antes de autorizar." + } }, "account": { "title": "Cuenta", diff --git a/app/src/locales/pt/settings.json b/app/src/locales/pt/settings.json index 0844d10a4..977eb2340 100644 --- a/app/src/locales/pt/settings.json +++ b/app/src/locales/pt/settings.json @@ -33,6 +33,7 @@ "title": "Agentes autorizados", "subtitle": "Agentes aos quais você delegou autoridade. Cada um tem uma credencial Beltic que prova a cadeia de delegação.", "addCta": "Autorizar novo agente", + "authorize": "Autorizar", "emptyTitle": "Ainda nenhum agente autorizado", "emptyDescription": "Quando um agente tem uma credencial Beltic, ele pode agir em seu nome dentro dos limites que você definir.", "columnAgent": "Agente", @@ -42,7 +43,30 @@ "columnActions": "Ações", "noCredential": "sem credencial", "revoke": "Revogar", - "revokeConfirm": "Revogar a credencial Beltic deste agente? Ele não poderá comprar nem agir em seu nome até ser autorizado novamente." + "revokeConfirm": "Revogar a credencial Beltic deste agente? Ele não poderá comprar nem agir em seu nome até ser autorizado novamente.", + "consent": { + "title": "Autorizar este agente", + "subtitle": "Defina os limites que a Beltic vai gravar na credencial. Você pode revogar a qualquer momento.", + "spendLimits": "Limites de gasto", + "dailyLimit": "Limite diário", + "perTransactionMax": "Máximo por transação", + "currencies": "Moedas autorizadas", + "idleTimeout": "Pausar quando ocioso", + "idleHelp": "Duração ISO-8601, por exemplo PT4H para quatro horas.", + "confirmHeader": "Confirmar antes de comprar", + "confirmAlways": "Sempre confirmar", + "confirmAlwaysDesc": "Ideal para um agente novo que você ainda não testou.", + "confirmThreshold": "Só acima de um limite", + "confirmThresholdDesc": "O agente roda sozinho abaixo do limite e pergunta acima dele.", + "confirmNever": "Nunca (autonomia total)", + "confirmNeverDesc": "Maior risco: o agente pode gastar até o limite diário sem perguntar.", + "thresholdLabel": "Limite", + "declaration": "Eu entendo que este agente pode gastar até o limite diário em meu nome, e posso revogá-lo nas Configurações quando quiser.", + "submit": "Autorizar agente", + "cancel": "Cancelar", + "issuing": "Autorizando…", + "missingDeclaration": "Confirme a declaração antes de autorizar." + } }, "account": { "title": "Conta", From 052ef3e0c889a6edf53cddea8fb94027cbf21371 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Sat, 23 May 2026 19:49:36 -0400 Subject: [PATCH 08/19] feat(app): Verified by Beltic tag on Mission Control cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mission cards now render a "Verified by Beltic" tag when their owning agent has an active agent_authorization credential. Cheapest possible touchpoint: uses the existing KanbanItem.tags array (which the board already renders as gray pills below the card) — no library boundary crossing into @houston-ai/board. - use-verified-agent-paths.ts: cross-agent fan-out via TanStack useQueries. Returns the Set of agent paths whose newest agent_authorization row is active. 30s staleTime since Mission Control isn't the credentials-focused surface (and the WS event invalidator keeps it honest anyway) - use-mission-control.ts: looks up each card's owning agent in the verified set and appends the tag when present A future enhancement is a Beltic-tinted tag tone — that'd take a `tagTone` prop on KanbanItem and is left for a follow-up so this chunk stays inside the app boundary. --- app/src/components/use-mission-control.ts | 8 +++- .../hooks/queries/use-verified-agent-paths.ts | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/src/hooks/queries/use-verified-agent-paths.ts diff --git a/app/src/components/use-mission-control.ts b/app/src/components/use-mission-control.ts index 5f8a27171..b6eb40e8d 100644 --- a/app/src/components/use-mission-control.ts +++ b/app/src/components/use-mission-control.ts @@ -9,6 +9,7 @@ import { useSessionStatusStore, } from "../stores/session-status"; import { useAllConversations } from "../hooks/queries"; +import { useVerifiedAgentPaths } from "../hooks/queries/use-verified-agent-paths"; import { tauriActivity, tauriChat, tauriAttachments } from "../lib/tauri"; import { buildAttachmentPrompt } from "../lib/attachment-message"; import type { Agent } from "../lib/types"; @@ -45,6 +46,7 @@ export function useMissionControl(agents: Agent[]) { ); const { data: convos, isFetched } = useAllConversations(paths); + const verifiedAgentPaths = useVerifiedAgentPaths(paths); const agentColorMap = useMemo(() => { const m: Record = {}; @@ -59,6 +61,9 @@ export function useMissionControl(agents: Agent[]) { .filter((c) => c.type === "activity" && c.status) .map((c) => { map[c.id] = c.agent_path; + const tags = verifiedAgentPaths.has(c.agent_path) + ? ["Verified by Beltic"] + : undefined; return { id: c.id, title: c.title, @@ -67,12 +72,13 @@ export function useMissionControl(agents: Agent[]) { icon: createElement(AgentCardAvatar, { color: agentColorMap[c.agent_path] }), status: c.status!, updatedAt: c.updated_at ?? new Date().toISOString(), + ...(tags ? { tags } : {}), metadata: { agentPath: c.agent_path, sessionKey: c.session_key }, }; }); pathMapRef.current = map; return result; - }, [convos, agentColorMap]); + }, [convos, agentColorMap, verifiedAgentPaths]); const loadHistory = useCallback( async (sessionKey: string): Promise => { diff --git a/app/src/hooks/queries/use-verified-agent-paths.ts b/app/src/hooks/queries/use-verified-agent-paths.ts new file mode 100644 index 000000000..1a7c47c86 --- /dev/null +++ b/app/src/hooks/queries/use-verified-agent-paths.ts @@ -0,0 +1,40 @@ +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { getEngine } from "../../lib/engine"; + +/** + * Cross-agent fan-out helper — returns the set of agent paths whose most + * recent Beltic `agent_authorization` credential is `active`. Used by + * Mission Control to render a "Verified by Beltic" tag on cards from + * authorized agents. + * + * Stays in sync via the same WS event invalidator chunk 4 wired up for + * the per-agent hook — invalidating `["agent-credentials", agentPath]` + * causes the matching query here to refetch. + */ +export function useVerifiedAgentPaths(agentPaths: string[]): Set { + const queries = useQueries({ + queries: agentPaths.map((agentPath) => ({ + queryKey: ["agent-credentials", agentPath], + queryFn: () => getEngine().listAgentCredentials(agentPath), + enabled: Boolean(agentPath), + // Mission Control isn't credentials-focused — soft refresh policy. + staleTime: 30_000, + })), + }); + + return useMemo(() => { + const out = new Set(); + queries.forEach((q, i) => { + const list = q.data ?? []; + for (let j = list.length - 1; j >= 0; j--) { + if (list[j].status === "active") { + out.add(agentPaths[i]); + break; + } + } + }); + return out; + }, [queries, agentPaths]); +} From 7e2e6ec9af0cac6d0fc674f7601883c00998d4b9 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Sat, 23 May 2026 19:53:14 -0400 Subject: [PATCH 09/19] feat: identity issuance route + verify modal + populated Identity pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end Beltic user-credential flow: route → hooks → modal → wired Identity Settings pane. - engine-server/src/routes/identity.rs: GET /v1/identity current Beltic user credential or null POST /v1/identity issue (build Beltic IssueRequest from nationality, dob, id document fields and persist via houston_engine_core::credentials::identity) POST /v1/identity/revoke call Beltic revoke + update_status Auto-promotes trust_level to "idv_verified" when an id_document_type is set; validates that an ID type without a country is rejected client-side (matches Beltic schema constraint). - ui/engine-client: IssueIdentityRequest type + 3 HoustonClient methods - app/src/hooks/queries/use-identity.ts: useIdentity / useIssueIdentity / useRevokeIdentity TanStack hooks. Mutations surface failures via showErrorToast() with Report-bug button per CLAUDE.md. - app/src/hooks/use-agent-invalidation.ts: CredentialIssued/Revoked/ Suspended events with agent_path prefixed "identity:" route to the workspace-identity query key instead of the per-agent one. - app/src/components/settings/sections/identity.tsx: real state. Empty → shows verify CTA + opens the modal. Verified → renders status pill, trust level, credential id, issued/expires, and the Re-verify / Revoke buttons. Revoke uses window.confirm with the cascading-warning copy. - verify-identity-dialog.tsx: matches the Figma "Verify Your Identity" fields (nationality, DOB, optional document type+country) with the three Beltic declarations folded into one consent checkbox. Submits via useIssueIdentity. - locales en/es/pt: settings.identity.verify.* + new identity.revokeConfirm key. No em dashes. The placeholder user_id from chunk 6 (delegated_by_subject_id = "usr_houston_local") will be replaced in a follow-up that wires the real user subject_id from this identity credential. Both routes work independently for now — agents authorize fine without an identity, and identity issues fine without any agents. --- .../components/settings/sections/identity.tsx | 147 ++++++++++++-- .../sections/verify-identity-dialog.tsx | 189 ++++++++++++++++++ app/src/hooks/queries/use-identity.ts | 35 ++++ app/src/hooks/use-agent-invalidation.ts | 14 +- app/src/locales/en/settings.json | 22 +- app/src/locales/es/settings.json | 22 +- app/src/locales/pt/settings.json | 22 +- engine/houston-engine-server/src/lib.rs | 1 + .../src/routes/identity.rs | 167 ++++++++++++++++ .../houston-engine-server/src/routes/mod.rs | 1 + ui/engine-client/src/client.ts | 13 ++ ui/engine-client/src/types.ts | 9 + 12 files changed, 614 insertions(+), 28 deletions(-) create mode 100644 app/src/components/settings/sections/verify-identity-dialog.tsx create mode 100644 app/src/hooks/queries/use-identity.ts create mode 100644 engine/houston-engine-server/src/routes/identity.rs diff --git a/app/src/components/settings/sections/identity.tsx b/app/src/components/settings/sections/identity.tsx index cfa1e6750..45cdebbaa 100644 --- a/app/src/components/settings/sections/identity.tsx +++ b/app/src/components/settings/sections/identity.tsx @@ -1,44 +1,147 @@ +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + useIdentity, + useRevokeIdentity, +} from "../../../hooks/queries/use-identity"; +import { VerifyIdentityDialog } from "./verify-identity-dialog"; + /** * Settings → Identity. Renders the user's Beltic-issued user credential * (status, trust level, issued/expires, credential id) and the actions - * to (re-)verify or revoke it. - * - * For chunk 5 the workspace-level identity routes aren't wired through - * the engine yet — chunk 3 only built the agent_authorization surface. - * So this section renders the unverified empty state. A follow-up adds - * the workspace identity route + connects the verify CTA to it. + * to (re-)verify or revoke. Launches the verify modal on the empty CTA. */ export function IdentitySection() { const { t } = useTranslation("settings"); + const { data: identity, isLoading } = useIdentity(); + const revoke = useRevokeIdentity(); + const [verifyOpen, setVerifyOpen] = useState(false); - return ( -
    -
    -

    {t("identity.title")}

    -

    {t("identity.subtitle")}

    -
    + const shortId = useMemo(() => { + if (!identity) return null; + const id = identity.credential_id; + if (id.length <= 18) return id; + return `${id.slice(0, 9)}…${id.slice(-6)}`; + }, [identity]); + + if (isLoading) { + return ( +
    +

    + {t("identity.title")} +

    +

    +
    + ); + } -
    -
    + if (!identity || identity.status !== "active") { + return ( +
    +
    +

    {t("identity.title")}

    +

    {t("identity.subtitle")}

    +
    + +

    {t("identity.emptyTitle")}

    {t("identity.emptyDescription")}

    +
    - + +
    + ); + } + + const trustLevel = + (identity.claims as { trust_level?: string } | null)?.trust_level ?? + "self_attested"; + + return ( +
    +
    +

    {t("identity.title")}

    +

    {t("identity.subtitle")}

    +
    + +
    +
    + + {t("identity.statusVerified")} + + + {trustLevel} + +
    + +
    + + {shortId} + + {trustLevel} + + {formatDate(identity.issued_at)} + + + {formatDate(identity.expires_at)} + +
    + +
    + + +
    + +
    ); } + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    +
    + {label} +
    +
    {children}
    +
    + ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} diff --git a/app/src/components/settings/sections/verify-identity-dialog.tsx b/app/src/components/settings/sections/verify-identity-dialog.tsx new file mode 100644 index 000000000..e98676bb2 --- /dev/null +++ b/app/src/components/settings/sections/verify-identity-dialog.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@houston-ai/core"; + +import { useIssueIdentity } from "../../../hooks/queries/use-identity"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const DOC_OPTIONS = [ + { value: "", labelKey: "verify.documentNone" }, + { value: "passport", labelKey: "verify.documentPassport" }, + { value: "drivers_license", labelKey: "verify.documentDriversLicense" }, + { value: "national_id", labelKey: "verify.documentNationalId" }, + { value: "residence_permit", labelKey: "verify.documentResidencePermit" }, +] as const; + +/** + * Identity self-attestation modal — maps the Figma "Verify Your Identity" + * screen onto an issueIdentity mutation. Required fields: nationality, + * DOB. Optional: ID document type + country (unlocks idv_verified). All + * fields tagged self_attested per Beltic schema. + */ +export function VerifyIdentityDialog({ open, onOpenChange }: Props) { + const { t } = useTranslation("settings"); + const issue = useIssueIdentity(); + + const [nationality, setNationality] = useState(""); + const [dob, setDob] = useState(""); + const [docType, setDocType] = useState(""); + const [docCountry, setDocCountry] = useState(""); + const [declarationOk, setDeclarationOk] = useState(false); + + const canSubmit = + declarationOk && + nationality.trim() && + dob.trim() && + (!docType || docCountry); + + async function onSubmit() { + if (!canSubmit) return; + try { + await issue.mutateAsync({ + nationality: nationality.trim(), + date_of_birth: dob.trim(), + id_document_type: docType || undefined, + id_document_country: docCountry || undefined, + self_attestation_complete: true, + }); + onOpenChange(false); + setNationality(""); + setDob(""); + setDocType(""); + setDocCountry(""); + setDeclarationOk(false); + } catch { + // showErrorToast fired inside the mutation's onError + } + } + + return ( + + + + {t("verify.title")} + {t("verify.subtitle")} + + +
    + + + +
    + + +

    + {t("verify.documentTypeOptional")} +

    +
    + + {docType ? ( + + ) : null} + + +
    + + + + + +
    +
    + ); +} + +function LabeledText({ + label, + value, + onChange, + placeholder, + maxLength, + required, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + maxLength?: number; + required?: boolean; +}) { + return ( + + ); +} diff --git a/app/src/hooks/queries/use-identity.ts b/app/src/hooks/queries/use-identity.ts new file mode 100644 index 000000000..ccdc5e2d1 --- /dev/null +++ b/app/src/hooks/queries/use-identity.ts @@ -0,0 +1,35 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + IssueIdentityRequest, + VerifiableCredential, +} from "@houston-ai/engine-client"; + +import { getEngine } from "../../lib/engine"; +import { showErrorToast } from "../../lib/error-toast"; + +const KEY = ["workspace-identity"] as const; + +export function useIdentity() { + return useQuery({ + queryKey: KEY, + queryFn: () => getEngine().getIdentity(), + }); +} + +export function useIssueIdentity() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: IssueIdentityRequest) => getEngine().issueIdentity(input), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + onError: (err) => showErrorToast("issueIdentity", (err as Error).message), + }); +} + +export function useRevokeIdentity() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => getEngine().revokeIdentity(), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + onError: (err) => showErrorToast("revokeIdentity", (err as Error).message), + }); +} diff --git a/app/src/hooks/use-agent-invalidation.ts b/app/src/hooks/use-agent-invalidation.ts index e8d644d7b..c70eed8fa 100644 --- a/app/src/hooks/use-agent-invalidation.ts +++ b/app/src/hooks/use-agent-invalidation.ts @@ -71,9 +71,17 @@ export function useAgentInvalidation() { case "CredentialIssued": case "CredentialRevoked": case "CredentialSuspended": - qc.invalidateQueries({ - queryKey: ["agent-credentials", p.data.agent_path], - }); + // Identity events use a synthetic "identity:" agent_path + // (see engine routes/identity.rs). Invalidate both the per-agent + // and the workspace-identity caches; whichever doesn't apply is a + // no-op. + if (p.data.agent_path.startsWith("identity:")) { + qc.invalidateQueries({ queryKey: ["workspace-identity"] }); + } else { + qc.invalidateQueries({ + queryKey: ["agent-credentials", p.data.agent_path], + }); + } break; } }); diff --git a/app/src/locales/en/settings.json b/app/src/locales/en/settings.json index 8284d8539..1df19c0bf 100644 --- a/app/src/locales/en/settings.json +++ b/app/src/locales/en/settings.json @@ -27,7 +27,27 @@ "verifyCta": "Verify identity", "reverify": "Re-verify identity", "revokeCta": "Revoke credential", - "viewOnBeltic": "View on Beltic" + "revokeConfirm": "Revoke your Beltic identity credential? Every agent credential delegated by it will stop verifying until you re-issue.", + "viewOnBeltic": "View on Beltic", + "verify": { + "title": "Verify your identity", + "subtitle": "Beltic stores only what's needed to issue your credential. Cancel any time.", + "nationality": "Nationality", + "dob": "Date of birth", + "documentType": "Government ID type", + "documentTypeOptional": "Optional. Unlocks the idv_verified trust level.", + "documentCountry": "ID document country", + "documentNone": "None", + "documentPassport": "Passport", + "documentDriversLicense": "Driver's license", + "documentNationalId": "National ID", + "documentResidencePermit": "Residence permit", + "declaration": "I confirm the information above is accurate. I consent to Beltic issuing a Verifiable Credential. I understand it may be revoked if found inaccurate.", + "submit": "Issue credential", + "cancel": "Cancel", + "issuing": "Issuing…", + "missingFields": "Nationality and date of birth are required." + } }, "agents": { "title": "Authorized agents", diff --git a/app/src/locales/es/settings.json b/app/src/locales/es/settings.json index 7e8856779..6bd74ccc1 100644 --- a/app/src/locales/es/settings.json +++ b/app/src/locales/es/settings.json @@ -27,7 +27,27 @@ "verifyCta": "Verificar identidad", "reverify": "Volver a verificar", "revokeCta": "Revocar credencial", - "viewOnBeltic": "Ver en Beltic" + "revokeConfirm": "¿Revocar tu credencial de identidad Beltic? Toda credencial de agente que delegó dejará de verificarse hasta que la emitas de nuevo.", + "viewOnBeltic": "Ver en Beltic", + "verify": { + "title": "Verificá tu identidad", + "subtitle": "Beltic guarda solo lo necesario para emitir tu credencial. Podés cancelar cuando quieras.", + "nationality": "Nacionalidad", + "dob": "Fecha de nacimiento", + "documentType": "Tipo de documento oficial", + "documentTypeOptional": "Opcional. Desbloquea el nivel idv_verified.", + "documentCountry": "País del documento", + "documentNone": "Ninguno", + "documentPassport": "Pasaporte", + "documentDriversLicense": "Licencia de conducir", + "documentNationalId": "DNI / cédula nacional", + "documentResidencePermit": "Residencia", + "declaration": "Confirmo que la información es correcta. Acepto que Beltic emita una Credencial Verificable. Entiendo que puede revocarse si no es exacta.", + "submit": "Emitir credencial", + "cancel": "Cancelar", + "issuing": "Emitiendo…", + "missingFields": "La nacionalidad y la fecha de nacimiento son obligatorias." + } }, "agents": { "title": "Agentes autorizados", diff --git a/app/src/locales/pt/settings.json b/app/src/locales/pt/settings.json index 977eb2340..b1a73f137 100644 --- a/app/src/locales/pt/settings.json +++ b/app/src/locales/pt/settings.json @@ -27,7 +27,27 @@ "verifyCta": "Verificar identidade", "reverify": "Verificar novamente", "revokeCta": "Revogar credencial", - "viewOnBeltic": "Ver na Beltic" + "revokeConfirm": "Revogar sua credencial de identidade Beltic? Toda credencial de agente delegada por ela vai parar de verificar até você emitir novamente.", + "viewOnBeltic": "Ver na Beltic", + "verify": { + "title": "Verifique sua identidade", + "subtitle": "A Beltic guarda só o necessário para emitir sua credencial. Você pode cancelar a qualquer momento.", + "nationality": "Nacionalidade", + "dob": "Data de nascimento", + "documentType": "Tipo de documento oficial", + "documentTypeOptional": "Opcional. Desbloqueia o nível idv_verified.", + "documentCountry": "País do documento", + "documentNone": "Nenhum", + "documentPassport": "Passaporte", + "documentDriversLicense": "Carteira de motorista", + "documentNationalId": "RG / documento nacional", + "documentResidencePermit": "Visto de residência", + "declaration": "Confirmo que a informação está correta. Aceito que a Beltic emita uma Credencial Verificável. Entendo que ela pode ser revogada se for imprecisa.", + "submit": "Emitir credencial", + "cancel": "Cancelar", + "issuing": "Emitindo…", + "missingFields": "Nacionalidade e data de nascimento são obrigatórias." + } }, "agents": { "title": "Agentes autorizados", diff --git a/engine/houston-engine-server/src/lib.rs b/engine/houston-engine-server/src/lib.rs index 9a2d58e44..cb2d99d05 100644 --- a/engine/houston-engine-server/src/lib.rs +++ b/engine/houston-engine-server/src/lib.rs @@ -39,6 +39,7 @@ pub fn build_router(state: Arc) -> Router { .merge(routes::agent_files::router()) .merge(routes::composio::router()) .merge(routes::credentials::router()) + .merge(routes::identity::router()) .merge(routes::webhooks_beltic::router()) .merge(routes::claude::router()) .merge(routes::tunnel::router()) diff --git a/engine/houston-engine-server/src/routes/identity.rs b/engine/houston-engine-server/src/routes/identity.rs new file mode 100644 index 000000000..92b6bfa35 --- /dev/null +++ b/engine/houston-engine-server/src/routes/identity.rs @@ -0,0 +1,167 @@ +//! Workspace-scoped user identity credential routes. +//! +//! One Beltic `user` credential per Houston user (per OS user), stored at +//! `/.houston/identity/identity.json` via +//! `houston_engine_core::credentials::identity`. The Beltic Issuer + +//! Verifier come from [`super::beltic_shared`] (lazy env-driven). +//! +//! Routes: +//! GET /v1/identity — return current identity credential or null +//! POST /v1/identity — issue via Beltic + persist + emit event +//! POST /v1/identity/revoke — revoke via Beltic + update status + emit + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use houston_engine_core::credentials::{ + identity, CredentialStatus, NewCredential, VerifiableCredential, +}; +use houston_engine_core::CoreError; +use houston_ui_events::HoustonEvent; +use serde::Deserialize; + +use super::beltic_shared::{ctx as beltic_ctx, map_beltic}; +use super::error::ApiError; +use crate::state::ServerState; + +pub fn router() -> Router> { + Router::new() + .route("/identity", get(get_identity).post(issue_identity)) + .route("/identity/revoke", post(revoke_identity)) +} + +/// What the verify modal sends. Houston's route constructs the full +/// Beltic IssueRequest from these fields — UI doesn't need to know the +/// subject/claims schema. +#[derive(Debug, Clone, Deserialize)] +struct IssueIdentityRequest { + pub nationality: Option, + pub date_of_birth: Option, + pub id_document_type: Option, + pub id_document_country: Option, + #[serde(default)] + pub self_attestation_complete: bool, +} + +async fn get_identity( + State(st): State>, +) -> Result>, ApiError> { + let root = st.engine.paths.home().to_path_buf(); + Ok(Json(identity::active(&root)?)) +} + +async fn issue_identity( + State(st): State>, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + if !input.self_attestation_complete { + return Err(ApiError(CoreError::BadRequest( + "self_attestation_complete must be true".into(), + ))); + } + if input.id_document_type.is_some() && input.id_document_country.is_none() { + return Err(ApiError(CoreError::BadRequest( + "id_document_country is required when id_document_type is set".into(), + ))); + } + + let user_id = std::env::var("HOUSTON_APP_USER_ID").unwrap_or_else(|_| "local".into()); + let subject_id = format!("usr_{user_id}"); + let trust_level = if input.id_document_type.is_some() { + "idv_verified" + } else { + "self_attested" + }; + + let mut claims = serde_json::json!({ + "kyc_status": "approved", + "trust_level": trust_level, + }); + if let Some(n) = &input.nationality { + claims["nationality"] = serde_json::Value::String(n.clone()); + } + if let Some(d) = &input.date_of_birth { + claims["date_of_birth"] = serde_json::Value::String(d.clone()); + } + if let Some(t) = &input.id_document_type { + claims["id_document_type"] = serde_json::Value::String(t.clone()); + } + if let Some(c) = &input.id_document_country { + claims["id_document_country"] = serde_json::Value::String(c.clone()); + } + + let issue_req = houston_beltic::issuer::IssueRequest { + credential_type: "user".into(), + self_attestation_complete: true, + subject: serde_json::json!({"type": "person", "id": subject_id}), + claims, + evidence_refs: vec![], + ttl: Some("P1Y".into()), + }; + + let beltic = beltic_ctx()?; + let issued = beltic.issuer.issue(issue_req).await.map_err(map_beltic)?; + + let root = st.engine.paths.home().to_path_buf(); + let row = identity::save( + &root, + NewCredential { + credential_id: issued.credential_id.clone(), + credential_type: issued.credential_type, + subject_type: "person".into(), + subject_id: issued + .subject + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + issuer_did: issued.issuer_did, + kid: issued.kid, + alg: issued.alg, + signed_payload: issued.signed_payload, + claims: issued.claims, + issued_at: issued.issued_at, + expires_at: issued.expires_at, + delegated_by_subject_id: None, + status_list_index: issued.status_list_index, + }, + )?; + + st.engine.events.emit(HoustonEvent::CredentialIssued { + agent_path: format!("identity:{}", row.subject_id), + credential_id: row.credential_id.clone(), + }); + Ok((StatusCode::CREATED, Json(row))) +} + +async fn revoke_identity( + State(st): State>, +) -> Result>, ApiError> { + let root = st.engine.paths.home().to_path_buf(); + let current = identity::active(&root)?; + let Some(cred) = current else { + return Ok(Json(None)); + }; + + let beltic = beltic_ctx()?; + let revoked = beltic + .issuer + .revoke(&cred.credential_id, Some("revoked_by_user")) + .await + .map_err(map_beltic)?; + let row = identity::update_status( + &root, + &cred.credential_id, + CredentialStatus::Revoked, + revoked.revoked_at, + revoked.revocation_reason, + )?; + st.engine.events.emit(HoustonEvent::CredentialRevoked { + agent_path: format!("identity:{}", row.subject_id), + credential_id: row.credential_id.clone(), + }); + Ok(Json(Some(row))) +} diff --git a/engine/houston-engine-server/src/routes/mod.rs b/engine/houston-engine-server/src/routes/mod.rs index 438d7e6d3..73b287b83 100644 --- a/engine/houston-engine-server/src/routes/mod.rs +++ b/engine/houston-engine-server/src/routes/mod.rs @@ -11,6 +11,7 @@ pub mod conversations; pub mod credentials; pub mod error; pub mod health; +pub mod identity; pub mod portable; pub mod preferences; pub mod providers; diff --git a/ui/engine-client/src/client.ts b/ui/engine-client/src/client.ts index cd37e3e61..934800190 100644 --- a/ui/engine-client/src/client.ts +++ b/ui/engine-client/src/client.ts @@ -81,6 +81,7 @@ import type { PortableInstalledAgent, VerifiableCredential, IssueCredentialRequest, + IssueIdentityRequest, VerifyCredentialResult, } from "./types"; import { planAttachmentUploadBatches } from "./attachments"; @@ -385,6 +386,18 @@ export class HoustonClient { return this.request("PUT", "/agents/config", config, { agent_path: agentPath }); } + // ---------- workspace identity (Beltic user credential) ---------- + + getIdentity(): Promise { + return this.request("GET", "/identity"); + } + issueIdentity(input: IssueIdentityRequest): Promise { + return this.request("POST", "/identity", input); + } + revokeIdentity(): Promise { + return this.request("POST", "/identity/revoke"); + } + // ---------- agents: Beltic credentials ---------- listAgentCredentials(agentPath: string): Promise { diff --git a/ui/engine-client/src/types.ts b/ui/engine-client/src/types.ts index a9d1c38df..28e7760a6 100644 --- a/ui/engine-client/src/types.ts +++ b/ui/engine-client/src/types.ts @@ -853,3 +853,12 @@ export interface VerifyCredentialResult { credential_id: string | null; detail: string | null; } + +/** Request body for `POST /v1/identity` (user identity issuance). */ +export interface IssueIdentityRequest { + nationality?: string; + date_of_birth?: string; + id_document_type?: string; + id_document_country?: string; + self_attestation_complete: boolean; +} From 4a14874ce0bacfbef07f78b1bbbd660bc2245708 Mon Sep 17 00:00:00 2001 From: Alivia <117310329+sajc11@users.noreply.github.com> Date: Sun, 24 May 2026 19:10:01 -0400 Subject: [PATCH 10/19] feat(app): wire delegated_by_subject_id to live identity; bake staging Beltic key for dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that finalise the agent_authorization flow for the dev demo. 1. authorize-agent-dialog reads useIdentity and uses the active identity credential's subject_id as delegated_by_subject_id. If no identity is active, the modal shows an amber warning pointing to Settings → Identity → Verify, and the submit button is disabled. Previously the modal sent a "usr_houston_local" placeholder, which is the kind of thing Beltic's FinCEN AML check is meant to catch. 2. app/src-tauri/src/lib.rs now seeds BELTIC_API_KEY + BELTIC_BASE_URL into the engine sidecar env at spawn time, but ONLY in debug builds (cfg!(debug_assertions)). Release builds are stripped by the compiler — no literal staging key ships to users. A parent- shell `export BELTIC_API_KEY=…` still wins via a follow-up env pass-through block. Key has read/write/revoke/verify scopes, no delete; staging only. locales en/es/pt: settings.agents.consent.identityRequired{Title,Body} + delegatingFrom. check-locales clean. --- app/src-tauri/src/lib.rs | 32 ++++++++++++ .../sections/authorize-agent-dialog.tsx | 49 +++++++++++++++---- app/src/locales/en/settings.json | 5 +- app/src/locales/es/settings.json | 5 +- app/src/locales/pt/settings.json | 5 +- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 450eed8e2..f397a3d85 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -240,6 +240,38 @@ pub fn run() { engine_env.push(("HOUSTON_TUNNEL_URL".into(), v)); } } + + // Beltic VC integration — debug-only staging defaults so + // `pnpm tauri dev` lights up the credentials flow without the + // dev needing to remember `export BELTIC_API_KEY=...` every + // session. Release builds NEVER see these literals (the + // cfg!(debug_assertions) gate strips them at compile time). + // Either env var, if set in the parent shell, wins over the + // default. Permissions on this key: credentials:read / + // :write / :revoke / :verify (no :delete) — matches what + // Houston's integration actually uses. + #[cfg(debug_assertions)] + { + if std::env::var("BELTIC_API_KEY").is_err() { + engine_env.push(( + "BELTIC_API_KEY".into(), + "sk_staging_9YCYmYYTf0CEGz0zgCDxXiP4yIlLNiUw".into(), + )); + } + if std::env::var("BELTIC_BASE_URL").is_err() { + engine_env.push(( + "BELTIC_BASE_URL".into(), + "https://api.staging.beltic.com/v1".into(), + )); + } + } + for var in ["BELTIC_API_KEY", "BELTIC_BASE_URL", "BELTIC_WEBHOOK_SECRET"] { + if let Ok(v) = std::env::var(var) { + if !v.is_empty() { + engine_env.push((var.into(), v)); + } + } + } // 30s banner timeout: first-run Gatekeeper scan on a notarized // sidecar can take 15–20s on slow machines. let slot = spawn_supervisor(binary, Duration::from_secs(30), engine_env, cb) diff --git a/app/src/components/settings/sections/authorize-agent-dialog.tsx b/app/src/components/settings/sections/authorize-agent-dialog.tsx index 885449442..269e82195 100644 --- a/app/src/components/settings/sections/authorize-agent-dialog.tsx +++ b/app/src/components/settings/sections/authorize-agent-dialog.tsx @@ -9,8 +9,10 @@ import { DialogHeader, DialogTitle, } from "@houston-ai/core"; +import { AlertTriangle } from "lucide-react"; import { useIssueAgentCredential } from "../../../hooks/queries/use-agent-credentials"; +import { useIdentity } from "../../../hooks/queries/use-identity"; interface Props { agentId: string; @@ -31,10 +33,14 @@ type Currency = (typeof CURRENCY_CHOICES)[number]; * limits / currencies / confirmation rules, Houston builds the Beltic * IssueRequest and ships it. * - * The `subject.id` (did:jwk) is a placeholder for this chunk — a follow-up - * generates a real ES256 keypair server-side. Same for - * `delegated_by_subject_id` which will pull from the workspace identity - * credential once chunk 8 lands. + * Reads the user's active identity credential to fill + * `delegated_by_subject_id` per Beltic's FinCEN AML constraint — refuses + * to submit if there is no active identity (the modal directs the user + * to Settings → Identity → Verify instead). + * + * The agent's `subject.id` is sent as a `did:jwk:houston-` + * placeholder; the engine route replaces it with a real ES256-derived + * did:jwk + persists the agent keypair (chunk 10). */ export function AuthorizeAgentDialog({ agentId, @@ -45,6 +51,10 @@ export function AuthorizeAgentDialog({ }: Props) { const { t } = useTranslation("settings"); const issue = useIssueAgentCredential(agentPath); + const { data: identity } = useIdentity(); + + const activeIdentitySubjectId = + identity && identity.status === "active" ? identity.subject_id : null; const [dailyLimit, setDailyLimit] = useState("250"); const [perTxMax, setPerTxMax] = useState("100"); @@ -59,7 +69,7 @@ export function AuthorizeAgentDialog({ [agentId], ); - function buildRequest() { + function buildRequest(delegatedBySubjectId: string) { const dailyCents = Math.round(Number(dailyLimit) * 100); const perTxCents = Math.round(Number(perTxMax) * 100); return { @@ -95,8 +105,7 @@ export function AuthorizeAgentDialog({ human_present: confirmMode !== "never", confirmation_threshold_cents: confirmMode === "threshold" ? Math.round(Number(threshold) * 100) : null, - // Placeholder — chunk 8 wires this to the real user credential id. - delegated_by_subject_id: "usr_houston_local", + delegated_by_subject_id: delegatedBySubjectId, }, evidence_refs: [], ttl: "P30D" as const, @@ -105,8 +114,9 @@ export function AuthorizeAgentDialog({ async function onSubmit() { if (!declarationOk) return; + if (!activeIdentitySubjectId) return; try { - await issue.mutateAsync(buildRequest()); + await issue.mutateAsync(buildRequest(activeIdentitySubjectId)); onOpenChange(false); setDeclarationOk(false); } catch { @@ -124,6 +134,25 @@ export function AuthorizeAgentDialog({ {t("agents.consent.subtitle")} + {!activeIdentitySubjectId ? ( +
    + +
    +

    + {t("agents.consent.identityRequiredTitle")} +

    +

    + {t("agents.consent.identityRequiredBody")} +

    +
    +
    + ) : ( +

    + {t("agents.consent.delegatingFrom")}{" "} + {activeIdentitySubjectId} +

    + )} +

    {t("agents.consent.spendLimits")}

    @@ -233,7 +262,9 @@ export function AuthorizeAgentDialog({ + + {attachments.length > 0 && ( +
      + {attachments.map((a) => ( + + setAttachments((cur) => + cur.map((x) => (x.id === a.id ? { ...x, docType } : x)), + ) + } + onRemove={() => + setAttachments((cur) => cur.filter((x) => x.id !== a.id)) + } + t={t} + /> + ))} +
    + )} +