use serde_json::Value;
use super::TrustTaskOutcome;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WireVersion {
V0_1,
V0_2,
}
tokio::task_local! {
pub(crate) static WIRE_VERSION: WireVersion;
}
pub(crate) fn current_wire_version() -> WireVersion {
WIRE_VERSION.try_with(|v| *v).unwrap_or(WireVersion::V0_1)
}
pub(super) struct WireSpecV02 {
pub uri_0_1: &'static str,
pub uri_0_2: &'static str,
pub request_paths: &'static [&'static str],
pub response_paths: &'static [&'static str],
}
pub(super) const WIRE_SPECS_V0_2: &[WireSpecV02] = &[
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/device/register/0.1",
uri_0_2: "https://trusttasks.org/spec/device/register/0.2",
request_paths: &["consumerKind.serviceKind", "attestation.kind"],
response_paths: &["binding.consumerKind.serviceKind", "binding.capabilities.*"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/device/heartbeat/0.1",
uri_0_2: "https://trusttasks.org/spec/device/heartbeat/0.2",
request_paths: &[],
response_paths: &["queuedOperations.*.kind", "syncHint"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/device/list/0.1",
uri_0_2: "https://trusttasks.org/spec/device/list/0.2",
request_paths: &[
"capabilityFilter",
"consumerKindFilter",
"formFactorFilter",
"serviceKindFilter",
],
response_paths: &[
"devices.*.consumerKind.serviceKind",
"devices.*.capabilities.*",
],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/device/set-wake/0.1",
uri_0_2: "https://trusttasks.org/spec/device/set-wake/0.2",
request_paths: &[],
response_paths: &[],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/list/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/list/0.2",
request_paths: &["secretKind"],
response_paths: &["entries.*.secretKind", "entries.*.targets.*.kind"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/get/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/get/0.2",
request_paths: &[],
response_paths: &["entry.secretKind", "entry.targets.*.kind"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/upsert/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/upsert/0.2",
request_paths: &["secretKind", "targets.*.kind", "sealedSecret.envelope"],
response_paths: &["entry.secretKind", "entry.targets.*.kind"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/release/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/release/0.2",
request_paths: &["target.kind"],
response_paths: &["secretKind", "sealedSecret.envelope"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/proxy-login/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/proxy-login/0.2",
request_paths: &["target.kind"],
response_paths: &["sealedSessionBlob.envelope"],
},
WireSpecV02 {
uri_0_1: "https://trusttasks.org/spec/vault/sign-trust-task/0.1",
uri_0_2: "https://trusttasks.org/spec/vault/sign-trust-task/0.2",
request_paths: &[],
response_paths: &[],
},
];
#[allow(dead_code)] pub(super) const WIRE_V0_2_URIS: &[&str] = &[
"https://trusttasks.org/spec/device/register/0.2",
"https://trusttasks.org/spec/device/heartbeat/0.2",
"https://trusttasks.org/spec/device/list/0.2",
"https://trusttasks.org/spec/device/set-wake/0.2",
"https://trusttasks.org/spec/vault/list/0.2",
"https://trusttasks.org/spec/vault/get/0.2",
"https://trusttasks.org/spec/vault/upsert/0.2",
"https://trusttasks.org/spec/vault/release/0.2",
"https://trusttasks.org/spec/vault/proxy-login/0.2",
"https://trusttasks.org/spec/vault/sign-trust-task/0.2",
];
pub(super) fn lookup_0_2(type_uri: &str) -> Option<&'static WireSpecV02> {
WIRE_SPECS_V0_2.iter().find(|s| s.uri_0_2 == type_uri)
}
fn camelize(s: &str) -> String {
let mut parts = s.split('-');
let mut out = String::new();
if let Some(first) = parts.next() {
out.push_str(first);
}
for p in parts {
let mut chars = p.chars();
if let Some(f) = chars.next() {
out.extend(f.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
fn kebabize(s: &str) -> String {
let mut out = String::new();
for ch in s.chars() {
if ch.is_ascii_uppercase() {
out.push('-');
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
fn apply_at_path(v: &mut Value, segments: &[&str], f: fn(&str) -> String) {
match segments.split_first() {
None => {
if let Value::String(s) = v {
*s = f(s);
}
}
Some((&"*", rest)) => match v {
Value::Array(items) => {
for it in items.iter_mut() {
apply_at_path(it, rest, f);
}
}
Value::Object(map) => {
for val in map.values_mut() {
apply_at_path(val, rest, f);
}
}
_ => {}
},
Some((seg, rest)) => {
if let Value::Object(map) = v
&& let Some(child) = map.get_mut(*seg)
{
apply_at_path(child, rest, f);
}
}
}
}
fn apply_paths(payload: &mut Value, paths: &[&str], f: fn(&str) -> String) {
for path in paths {
let segments: Vec<&str> = path.split('.').collect();
apply_at_path(payload, &segments, f);
}
}
pub(super) fn downconvert_request(payload: &mut Value, spec: &WireSpecV02) {
apply_paths(payload, spec.request_paths, kebabize);
}
pub(super) fn kebabize_paths(payload: &mut Value, paths: &[&str]) {
apply_paths(payload, paths, kebabize);
}
pub(crate) fn camelize_paths(payload: &mut Value, paths: &[&str]) {
apply_paths(payload, paths, camelize);
}
pub(super) fn upconvert_response(
outcome: TrustTaskOutcome,
spec: &WireSpecV02,
) -> TrustTaskOutcome {
let TrustTaskOutcome { status, body } = outcome;
let mut doc: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => return TrustTaskOutcome { status, body },
};
let mut is_success_response = false;
if let Some(Value::String(t)) = doc.get_mut("type")
&& let Some(fragment) = t.strip_prefix(spec.uri_0_1)
{
is_success_response = fragment == "#response";
*t = format!("{}{}", spec.uri_0_2, fragment);
}
if is_success_response && let Some(payload) = doc.get_mut("payload") {
apply_paths(payload, spec.response_paths, camelize);
}
let new_body = serde_json::to_vec(&doc).unwrap_or(body);
TrustTaskOutcome {
status,
body: new_body,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn casing_transforms_are_inverse_and_idempotent() {
for (kebab, camel) in [
("vault-read", "vaultRead"),
("apple-app-attest", "appleAppAttest"),
("did-self-issued", "didSelfIssued"),
("full-resync-required", "fullResyncRequired"),
("webauthn-uv", "webauthnUv"),
] {
assert_eq!(camelize(kebab), camel, "camelize({kebab})");
assert_eq!(kebabize(camel), kebab, "kebabize({camel})");
assert_eq!(camelize(camel), camel);
assert_eq!(kebabize(kebab), kebab);
}
for w in ["mediator", "companion", "sign", "browser"] {
assert_eq!(camelize(w), w);
assert_eq!(kebabize(w), w);
}
}
#[test]
fn apply_at_path_fans_out_over_arrays_and_objects() {
let mut v = serde_json::json!({
"devices": [
{ "consumerKind": { "serviceKind": "ai-agent" }, "capabilities": ["vault-read", "sign"] },
{ "consumerKind": { "serviceKind": "mediator" }, "capabilities": ["proxy-login"] }
]
});
apply_paths(
&mut v,
&[
"devices.*.consumerKind.serviceKind",
"devices.*.capabilities.*",
],
camelize,
);
assert_eq!(v["devices"][0]["consumerKind"]["serviceKind"], "aiAgent");
assert_eq!(v["devices"][0]["capabilities"][0], "vaultRead");
assert_eq!(v["devices"][0]["capabilities"][1], "sign");
assert_eq!(v["devices"][1]["consumerKind"]["serviceKind"], "mediator");
assert_eq!(v["devices"][1]["capabilities"][0], "proxyLogin");
}
#[test]
fn free_text_at_non_enum_path_is_untouched() {
let mut v = serde_json::json!({
"binding": { "displayName": "vault-read", "capabilities": ["vault-read"] }
});
apply_paths(&mut v, &["binding.capabilities.*"], camelize);
assert_eq!(
v["binding"]["displayName"], "vault-read",
"free text untouched"
);
assert_eq!(
v["binding"]["capabilities"][0], "vaultRead",
"enum value upcased"
);
}
#[test]
fn device_list_request_downconverts_enums() {
let spec = lookup_0_2("https://trusttasks.org/spec/device/list/0.2").unwrap();
let mut payload = serde_json::json!({
"capabilityFilter": "vaultRead",
"serviceKindFilter": "aiAgent",
"consumerKindFilter": "service",
"includeDisabled": false
});
downconvert_request(&mut payload, spec);
assert_eq!(payload["capabilityFilter"], "vault-read");
assert_eq!(payload["serviceKindFilter"], "ai-agent");
assert_eq!(payload["consumerKindFilter"], "service"); assert_eq!(payload["includeDisabled"], false); }
#[tokio::test]
async fn device_list_response_upconverts_and_retypes() {
let spec = lookup_0_2("https://trusttasks.org/spec/device/list/0.2").unwrap();
let doc = serde_json::json!({
"id": "urn:uuid:1",
"type": "https://trusttasks.org/spec/device/list/0.1#response",
"issuer": "did:web:vta",
"recipient": "did:key:zClient",
"payload": {
"devices": [
{ "deviceId": "d1", "displayName": "vault-read",
"consumerKind": { "kind": "service", "serviceKind": "ai-agent" },
"capabilities": ["vault-read", "sign"] }
],
"truncated": false
}
});
let outcome = TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body: serde_json::to_vec(&doc).unwrap(),
};
let out = upconvert_response(outcome, spec);
let v: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
v["type"],
"https://trusttasks.org/spec/device/list/0.2#response"
);
assert_eq!(
v["payload"]["devices"][0]["consumerKind"]["serviceKind"],
"aiAgent"
);
assert_eq!(v["payload"]["devices"][0]["capabilities"][0], "vaultRead");
assert_eq!(v["payload"]["devices"][0]["capabilities"][1], "sign");
assert_eq!(v["payload"]["devices"][0]["displayName"], "vault-read");
}
#[tokio::test]
async fn upconvert_passes_through_reject_documents() {
let spec = lookup_0_2("https://trusttasks.org/spec/device/list/0.2").unwrap();
let doc = serde_json::json!({
"id": "urn:uuid:2",
"type": "https://trusttasks.org/spec/trust-task-error/0.1",
"payload": { "code": "permission_denied", "reason": "nope" }
});
let outcome = TrustTaskOutcome {
status: axum::http::StatusCode::FORBIDDEN,
body: serde_json::to_vec(&doc).unwrap(),
};
let out = upconvert_response(outcome, spec);
let v: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
v["type"],
"https://trusttasks.org/spec/trust-task-error/0.1"
);
assert_eq!(v["payload"]["code"], "permission_denied");
}
#[test]
fn vault_upsert_request_downconverts_secretkind_and_target() {
let spec = lookup_0_2("https://trusttasks.org/spec/vault/upsert/0.2").unwrap();
let mut payload = serde_json::json!({
"contextId": "personal",
"label": "did-self-issued", "secretKind": "didSelfIssued",
"targets": [
{ "kind": "webOrigin", "origin": "https://example.com" },
{ "kind": "iosApp", "bundleId": "com.example.app" },
{ "kind": "did", "did": "did:web:rp.example" }
]
});
downconvert_request(&mut payload, spec);
assert_eq!(payload["secretKind"], "did-self-issued");
assert_eq!(payload["targets"][0]["kind"], "web-origin");
assert_eq!(payload["targets"][1]["kind"], "ios-app");
assert_eq!(payload["targets"][2]["kind"], "did"); assert_eq!(payload["targets"][1]["bundleId"], "com.example.app");
assert_eq!(payload["label"], "did-self-issued");
}
#[tokio::test]
async fn vault_list_response_upconverts_nested_enums() {
let spec = lookup_0_2("https://trusttasks.org/spec/vault/list/0.2").unwrap();
let doc = serde_json::json!({
"id": "urn:uuid:9",
"type": "https://trusttasks.org/spec/vault/list/0.1#response",
"payload": {
"entries": [
{ "id": "v1", "label": "oauth-tokens", "secretKind": "oauth-tokens",
"targets": [ { "kind": "ios-app", "bundleId": "x" } ] }
],
"truncated": false
}
});
let outcome = TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body: serde_json::to_vec(&doc).unwrap(),
};
let out = upconvert_response(outcome, spec);
let v: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
v["type"],
"https://trusttasks.org/spec/vault/list/0.2#response"
);
assert_eq!(v["payload"]["entries"][0]["secretKind"], "oauthTokens");
assert_eq!(v["payload"]["entries"][0]["targets"][0]["kind"], "iosApp");
assert_eq!(v["payload"]["entries"][0]["label"], "oauth-tokens");
}
#[tokio::test]
async fn vault_release_response_camelizes_sealed_secret_envelope_tag() {
let spec = lookup_0_2("https://trusttasks.org/spec/vault/release/0.2").unwrap();
let doc = serde_json::json!({
"id": "urn:uuid:7",
"type": "https://trusttasks.org/spec/vault/release/0.1#response",
"payload": {
"sealedSecret": { "envelope": "didcomm-authcrypt", "jwe": "opaque.jwe.bytes" },
"secretKind": "oauth-tokens",
"ttlSeconds": 60
}
});
let outcome = TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body: serde_json::to_vec(&doc).unwrap(),
};
let out = upconvert_response(outcome, spec);
let v: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
v["type"],
"https://trusttasks.org/spec/vault/release/0.2#response"
);
assert_eq!(
v["payload"]["sealedSecret"]["envelope"], "didcommAuthcrypt",
"envelope tag up-converted"
);
assert_eq!(v["payload"]["secretKind"], "oauthTokens");
assert_eq!(v["payload"]["sealedSecret"]["jwe"], "opaque.jwe.bytes");
}
#[tokio::test]
async fn vault_proxy_login_response_camelizes_sealed_session_blob_envelope_tag() {
let spec = lookup_0_2("https://trusttasks.org/spec/vault/proxy-login/0.2").unwrap();
let doc = serde_json::json!({
"id": "urn:uuid:8",
"type": "https://trusttasks.org/spec/vault/proxy-login/0.1#response",
"payload": {
"sealedSessionBlob": { "envelope": "didcomm-authcrypt", "jwe": "opaque" },
"sessionId": "s1",
"expiresAt": "2026-06-17T00:00:00Z"
}
});
let outcome = TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body: serde_json::to_vec(&doc).unwrap(),
};
let out = upconvert_response(outcome, spec);
let v: Value = serde_json::from_slice(&out.body).unwrap();
assert_eq!(
v["payload"]["sealedSessionBlob"]["envelope"],
"didcommAuthcrypt"
);
}
#[test]
fn vault_upsert_request_downconverts_sealed_secret_envelope_tag() {
let spec = lookup_0_2("https://trusttasks.org/spec/vault/upsert/0.2").unwrap();
let mut payload = serde_json::json!({
"contextId": "personal",
"secretKind": "oauthTokens",
"targets": [],
"label": "x",
"sealedSecret": { "envelope": "didcommAuthcrypt", "jwe": "opaque" }
});
downconvert_request(&mut payload, spec);
assert_eq!(payload["sealedSecret"]["envelope"], "didcomm-authcrypt");
assert_eq!(payload["secretKind"], "oauth-tokens");
}
#[test]
fn camelize_paths_is_a_noop_on_kebab_for_v0_1_seal() {
let mut secret = serde_json::json!({
"kind": "oauth-tokens",
"loginConfig": { "format": "form-urlencoded" }
});
camelize_paths(&mut secret, &["kind", "loginConfig.format"]);
assert_eq!(secret["kind"], "oauthTokens");
assert_eq!(secret["loginConfig"]["format"], "formUrlencoded");
}
#[tokio::test]
async fn current_wire_version_reads_scoped_value_and_defaults_to_v0_1() {
assert_eq!(
current_wire_version(),
WireVersion::V0_1,
"default outside scope"
);
WIRE_VERSION
.scope(WireVersion::V0_2, async {
assert_eq!(current_wire_version(), WireVersion::V0_2);
})
.await;
}
#[test]
fn registry_uris_are_consistent() {
for spec in WIRE_SPECS_V0_2 {
assert!(WIRE_V0_2_URIS.contains(&spec.uri_0_2), "{}", spec.uri_0_2);
assert_eq!(spec.uri_0_1.replace("/0.1", "/0.2"), spec.uri_0_2);
assert!(lookup_0_2(spec.uri_0_2).is_some());
}
}
}