Skip to content

Commit 0e7df8b

Browse files
authored
feat(a2a-bridge): introduce crate with caller identity and error surface (#379)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 7daf7c4 commit 0e7df8b

7 files changed

Lines changed: 296 additions & 0 deletions

File tree

rsworkspace/Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ unwrap_used = "deny"
1414

1515
[workspace.dependencies]
1616
# Internal crates
17+
a2a-auth-callout = { path = "crates/a2a-auth-callout" }
18+
a2a-bridge = { path = "crates/a2a-bridge" }
1719
a2a-identity-types = { path = "crates/a2a-identity-types" }
1820
a2a-nats = { path = "crates/a2a-nats" }
1921
a2a-pack = { path = "crates/a2a-pack" }
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "a2a-bridge"
3+
version = "0.1.0"
4+
edition = "2024"
5+
license = "Apache-2.0"
6+
description = "HTTPS ↔ NATS A2A sidecar: caller identity, error surface, and routing scaffolds"
7+
publish = false
8+
9+
[lib]
10+
name = "a2a_bridge"
11+
path = "src/lib.rs"
12+
13+
[lints]
14+
workspace = true
15+
16+
[dependencies]
17+
a2a-auth-callout = { workspace = true }
18+
a2a-nats = { workspace = true }
19+
serde_json = { workspace = true }
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# a2a-bridge
2+
3+
HTTPS ↔ NATS A2A sidecar foundation: typed caller identity value objects and the unified `BridgeError` surface that the inbound (HTTPS) and outbound (NATS / upstream HTTPS) wiring will build on in the next slices.
4+
5+
## Related
6+
7+
- [A2A bridge sketch (`docs/a2a/explanation/bridge-sketch.md`)](../../../docs/a2a/explanation/bridge-sketch.md)
8+
- [Gateway roadmap — bridge identity & policy](../../../docs/a2a/explanation/gateway-roadmap.md)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use std::fmt;
2+
3+
#[allow(dead_code)]
4+
#[derive(Debug)]
5+
pub enum BridgeError {
6+
MissingAuthorization,
7+
Utf8Body(std::str::Utf8Error),
8+
Deserialize(serde_json::Error),
9+
Serialize(serde_json::Error),
10+
MissingAgentHeader,
11+
MissingJsonRpcMethod,
12+
Mint(String),
13+
NatsPublish(String),
14+
JetStreamConsume(String),
15+
UpstreamHttps(String),
16+
MissingJsonRpcId,
17+
StreamingParams(String),
18+
JsonRpcUpstream(String),
19+
CatalogRegistration(String),
20+
InvalidAgent(String),
21+
}
22+
23+
impl fmt::Display for BridgeError {
24+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25+
match self {
26+
Self::MissingAuthorization => write!(f, "missing Authorization header"),
27+
Self::Utf8Body(e) => write!(f, "request body was not UTF-8: {e}"),
28+
Self::Deserialize(e) => write!(f, "failed to deserialize JSON-RPC body: {e}"),
29+
Self::Serialize(e) => write!(f, "failed to serialize bridge response: {e}"),
30+
Self::MissingAgentHeader => write!(f, "missing X-A2A-Agent-Id header"),
31+
Self::MissingJsonRpcMethod => write!(f, "JSON-RPC body missing method"),
32+
Self::Mint(msg) => write!(f, "auth callout mint failed: {msg}"),
33+
Self::NatsPublish(msg) => write!(f, "NATS gateway publish failed: {msg}"),
34+
Self::JetStreamConsume(msg) => write!(f, "JetStream SSE consumer attach failed: {msg}"),
35+
Self::UpstreamHttps(msg) => write!(f, "HTTPS upstream forward failed: {msg}"),
36+
Self::MissingJsonRpcId => write!(f, "JSON-RPC streaming request missing usable id"),
37+
Self::StreamingParams(msg) => write!(f, "invalid streaming RPC params: {msg}"),
38+
Self::JsonRpcUpstream(msg) => write!(f, "gateway unary returned JSON-RPC error: {msg}"),
39+
Self::CatalogRegistration(msg) => write!(f, "catalog registration publish failed: {msg}"),
40+
Self::InvalidAgent(msg) => write!(f, "invalid agent identifier: {msg}"),
41+
}
42+
}
43+
}
44+
45+
impl std::error::Error for BridgeError {
46+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
47+
match self {
48+
Self::Utf8Body(e) => Some(e),
49+
Self::Deserialize(e) | Self::Serialize(e) => Some(e),
50+
_ => None,
51+
}
52+
}
53+
}
54+
55+
#[cfg(test)]
56+
mod tests {
57+
use std::error::Error;
58+
59+
use super::*;
60+
61+
#[test]
62+
fn display_mint() {
63+
assert!(
64+
BridgeError::Mint("unavailable".into())
65+
.to_string()
66+
.contains("auth callout mint")
67+
);
68+
}
69+
70+
#[test]
71+
fn source_for_deserialize() {
72+
let e = BridgeError::Deserialize(serde_json::from_str::<serde_json::Value>("]").unwrap_err());
73+
assert!(e.source().is_some());
74+
}
75+
76+
#[test]
77+
fn source_for_missing_authorization_none() {
78+
assert!(BridgeError::MissingAuthorization.source().is_none());
79+
}
80+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
use std::fmt;
2+
3+
use a2a_auth_callout::CallerId;
4+
use a2a_nats::{A2aAgentId, AgentIdError};
5+
6+
use crate::error::BridgeError;
7+
8+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9+
pub struct MintedCallerId(String);
10+
11+
impl MintedCallerId {
12+
#[must_use]
13+
pub fn from_caller_id(id: CallerId) -> Self {
14+
Self(id.as_str().to_owned())
15+
}
16+
17+
pub fn as_str(&self) -> &str {
18+
&self.0
19+
}
20+
}
21+
22+
#[derive(Clone, PartialEq, Eq, Hash)]
23+
pub struct CallerHttpsAuth(String);
24+
25+
impl fmt::Debug for CallerHttpsAuth {
26+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27+
// Debug must not leak the raw Authorization value either —
28+
// tracing/log macros use the `?` field syntax which routes
29+
// through Debug, not Display.
30+
f.debug_tuple("CallerHttpsAuth").field(&"<redacted>").finish()
31+
}
32+
}
33+
34+
impl CallerHttpsAuth {
35+
pub fn new(raw: impl Into<String>) -> Self {
36+
Self(raw.into())
37+
}
38+
39+
pub fn as_str(&self) -> &str {
40+
&self.0
41+
}
42+
43+
pub fn into_inner(self) -> String {
44+
self.0
45+
}
46+
}
47+
48+
impl fmt::Display for CallerHttpsAuth {
49+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50+
// The wrapped value carries the raw `Authorization` header — Bearer
51+
// tokens, Basic credentials, etc. Redact in Display so accidental
52+
// tracing/log interpolation can't leak the secret. Callers that
53+
// genuinely need the value go through `as_str` / `into_inner`.
54+
f.pad("<redacted>")
55+
}
56+
}
57+
58+
#[derive(Clone, PartialEq, Eq, Hash)]
59+
pub struct BridgeUserJwt(String);
60+
61+
impl fmt::Debug for BridgeUserJwt {
62+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63+
f.debug_tuple("BridgeUserJwt").field(&"<redacted>").finish()
64+
}
65+
}
66+
67+
impl BridgeUserJwt {
68+
/// Wrap a minted user JWT after validating compact-JWT shape. Mirrors
69+
/// the gate in `a2a_auth_callout::MintedUserJwt::new` so a malformed
70+
/// value can't be smuggled past this boundary and only fail later when
71+
/// the bridge tries to decode it.
72+
pub fn new(token: impl Into<String>) -> Result<Self, BridgeError> {
73+
let token = token.into().trim().to_owned();
74+
if token.is_empty() {
75+
return Err(BridgeError::Mint("minted user JWT must be non-empty".into()));
76+
}
77+
let parts: Vec<&str> = token.split('.').collect();
78+
if parts.len() != 3 || parts.iter().any(|p| p.is_empty()) {
79+
return Err(BridgeError::Mint(
80+
"minted user JWT must be a compact 3-segment JWT".into(),
81+
));
82+
}
83+
Ok(Self(token))
84+
}
85+
86+
pub fn as_str(&self) -> &str {
87+
&self.0
88+
}
89+
90+
pub fn into_inner(self) -> String {
91+
self.0
92+
}
93+
}
94+
95+
impl fmt::Display for BridgeUserJwt {
96+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97+
f.pad("<redacted>")
98+
}
99+
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn caller_https_auth_display_redacts() {
107+
let auth = CallerHttpsAuth::new("Bearer secret-token");
108+
assert_eq!(format!("{auth}"), "<redacted>");
109+
assert_eq!(auth.as_str(), "Bearer secret-token");
110+
}
111+
112+
#[test]
113+
fn bridge_user_jwt_rejects_empty_and_non_three_segment() {
114+
assert!(BridgeUserJwt::new("").is_err());
115+
assert!(BridgeUserJwt::new("a.b").is_err());
116+
assert!(BridgeUserJwt::new("a..c").is_err());
117+
assert!(BridgeUserJwt::new("a.b.c").is_ok());
118+
}
119+
120+
#[test]
121+
fn bridge_user_jwt_display_redacts() {
122+
let jwt = BridgeUserJwt::new("h.p.s").expect("valid shape");
123+
assert_eq!(format!("{jwt}"), "<redacted>");
124+
}
125+
126+
#[test]
127+
fn debug_does_not_leak_secrets() {
128+
let auth = CallerHttpsAuth::new("Bearer secret-token");
129+
let auth_dbg = format!("{auth:?}");
130+
assert!(!auth_dbg.contains("secret-token"), "{auth_dbg}");
131+
assert!(auth_dbg.contains("<redacted>"), "{auth_dbg}");
132+
133+
let jwt = BridgeUserJwt::new("hhh.ppp.sss").expect("valid shape");
134+
let jwt_dbg = format!("{jwt:?}");
135+
assert!(!jwt_dbg.contains("hhh"), "{jwt_dbg}");
136+
assert!(jwt_dbg.contains("<redacted>"), "{jwt_dbg}");
137+
}
138+
}
139+
140+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
141+
pub struct BridgeAgentId(A2aAgentId);
142+
143+
impl BridgeAgentId {
144+
pub fn parse(raw: &str) -> Result<Self, BridgeError> {
145+
A2aAgentId::new(raw)
146+
.map(Self)
147+
.map_err(|e: AgentIdError| BridgeError::InvalidAgent(e.to_string()))
148+
}
149+
150+
#[must_use]
151+
pub fn as_str(&self) -> &str {
152+
self.0.as_str()
153+
}
154+
155+
#[must_use]
156+
pub fn as_agent_id(&self) -> &A2aAgentId {
157+
&self.0
158+
}
159+
160+
#[must_use]
161+
pub fn into_agent_id(self) -> A2aAgentId {
162+
self.0
163+
}
164+
}
165+
166+
impl fmt::Display for BridgeAgentId {
167+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168+
self.0.fmt(f)
169+
}
170+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#![doc = include_str!("../README.md")]
2+
#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
3+
4+
pub mod error;
5+
pub mod identity;
6+
7+
pub use error::BridgeError;
8+
pub use identity::{BridgeAgentId, BridgeUserJwt, CallerHttpsAuth, MintedCallerId};

0 commit comments

Comments
 (0)