use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct ChallengeRequest {
#[serde(rename = "subject", alias = "did")]
pub did: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChallengeResponse {
pub challenge: String,
pub session_id: String,
pub expires_at: String,
}
#[derive(Serialize, Deserialize)]
pub struct AuthenticatePayload {
pub id_token: String,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_pubkey_b58btc: Option<String>,
}
impl std::fmt::Debug for AuthenticatePayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthenticatePayload")
.field("id_token", &"<redacted>")
.field("session_id", &self.session_id)
.field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub subject: String,
pub issued_at: String,
pub expires_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub amr: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub acr: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenBundle {
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
pub token_type: String,
pub expires_in: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_expires_in: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scope: Vec<String>,
}
impl std::fmt::Debug for TokenBundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenBundle")
.field("access_token", &"<redacted>")
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field("refresh_expires_in", &self.refresh_expires_in)
.field("scope", &self.scope)
.finish()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticateResponse {
pub session: Session,
pub tokens: TokenBundle,
}
#[cfg(feature = "server-core")]
impl AuthenticateResponse {
pub fn access_expires_at_epoch(&self) -> Option<u64> {
let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
Some(issued_epoch.saturating_add(self.tokens.expires_in))
}
pub fn refresh_expires_at_epoch(&self) -> Option<u64> {
let refresh_secs = self.tokens.refresh_expires_in?;
let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
Some(issued_epoch.saturating_add(refresh_secs))
}
}
pub type RefreshResponse = AuthenticateResponse;
#[cfg(feature = "server-core")]
pub fn epoch_to_rfc3339(epoch_secs: u64) -> String {
let secs = i64::try_from(epoch_secs).unwrap_or(0);
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateDidRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CheckNameRequest {
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CheckNameResponse {
pub available: bool,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestUriResponse {
pub mnemonic: String,
pub did_url: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DidRegisterRequest {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, alias = "didData", skip_serializing_if = "Option::is_none")]
pub did_data: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub did_log: Option<String>,
#[serde(default)]
pub force: bool,
}
impl DidRegisterRequest {
pub fn resolve(&self) -> Result<(String, Vec<u8>), String> {
if self.did_data.is_some() && self.did_log.is_some() {
return Err("request carries both `did_data` and `did_log`; supply exactly one".into());
}
if let Some(did_log) = &self.did_log {
let method = self.method.as_deref().unwrap_or("webvh");
if method != "webvh" {
return Err(format!(
"`did_log` legacy field is webvh-only; received method = '{method}'. \
Use `did_data` for non-webvh methods.",
));
}
return Ok(("webvh".to_string(), did_log.clone().into_bytes()));
}
let did_data = self
.did_data
.as_ref()
.ok_or_else(|| "request requires either `did_data` or `did_log`".to_string())?;
let derived_method = derive_method_from_did_data(did_data);
let method = match (&self.method, &derived_method) {
(Some(m), Some(d)) if m != d => {
return Err(format!(
"method mismatch: request declares `method = \"{m}\"` but \
`did_data.id` resolves to method `\"{d}\"`. \
Either remove `method` (it will be derived) or fix `did_data.id`.",
));
}
(Some(m), _) => m.clone(),
(None, Some(d)) => d.clone(),
(None, None) => {
return Err(
"request requires `method` (or a `did_data.id` from which it can \
be derived)"
.into(),
);
}
};
let bytes = match method.as_str() {
"webvh" => {
if let Some(s) = did_data.as_str() {
s.as_bytes().to_vec()
} else if let Some(arr) = did_data.as_array() {
let mut buf = String::new();
for (i, entry) in arr.iter().enumerate() {
if i > 0 {
buf.push('\n');
}
buf.push_str(
&serde_json::to_string(entry).map_err(|e| format!("entry {i}: {e}"))?,
);
}
buf.into_bytes()
} else {
return Err(
"webvh `did_data` must be a jsonl string or an array of log entries".into(),
);
}
}
"web" => {
if did_data.is_object() {
serde_json::to_vec(did_data).map_err(|e| e.to_string())?
} else {
return Err("web `did_data` must be a did.json object".into());
}
}
"webs" => {
if let Some(s) = did_data.as_str() {
s.as_bytes().to_vec()
} else {
return Err("webs `did_data` must be the keri.cesr stream as a string".into());
}
}
other => {
return Err(format!(
"unknown or unsupported method `{other}`; known methods: webvh, web, webs",
));
}
};
Ok((method, bytes))
}
}
fn derive_method_from_did_data(did_data: &serde_json::Value) -> Option<String> {
let id = did_data.get("id")?.as_str()?;
let mut parts = id.splitn(3, ':');
let prefix = parts.next()?;
let method = parts.next()?;
let _rest = parts.next()?;
if prefix != "did" || method.is_empty() {
return None;
}
Some(method.to_string())
}
#[cfg(test)]
mod did_register_request_tests {
use super::*;
use serde_json::json;
fn req() -> DidRegisterRequest {
DidRegisterRequest {
path: "alpha".into(),
..Default::default()
}
}
#[test]
fn camelcase_did_data_wire_field_deserializes() {
let r: DidRegisterRequest = serde_json::from_value(json!({
"path": "alpha",
"method": "webvh",
"didData": "line1\nline2",
}))
.expect("didData must deserialize");
assert_eq!(r.did_data, Some(json!("line1\nline2")));
assert!(r.did_log.is_none());
}
#[test]
fn snake_case_did_data_alias_still_deserializes() {
let r: DidRegisterRequest = serde_json::from_value(json!({
"path": "alpha",
"method": "webvh",
"did_data": "line1",
}))
.expect("did_data must deserialize");
assert_eq!(r.did_data, Some(json!("line1")));
}
#[test]
fn legacy_did_log_resolves_as_webvh() {
let r = DidRegisterRequest {
did_log: Some("line1\nline2".into()),
..req()
};
let (method, payload) = r.resolve().unwrap();
assert_eq!(method, "webvh");
assert_eq!(payload, b"line1\nline2");
}
#[test]
fn legacy_did_log_with_explicit_webvh_method_ok() {
let r = DidRegisterRequest {
method: Some("webvh".into()),
did_log: Some("line".into()),
..req()
};
let (method, _) = r.resolve().unwrap();
assert_eq!(method, "webvh");
}
#[test]
fn legacy_did_log_with_non_webvh_method_rejected() {
let r = DidRegisterRequest {
method: Some("web".into()),
did_log: Some("line".into()),
..req()
};
let err = r.resolve().expect_err("conflict must reject");
assert!(err.contains("webvh-only"), "got: {err}");
}
#[test]
fn both_did_data_and_did_log_rejected() {
let r = DidRegisterRequest {
did_data: Some(json!("line")),
did_log: Some("line".into()),
..req()
};
let err = r.resolve().expect_err("ambiguous must reject");
assert!(err.contains("both"), "got: {err}");
}
#[test]
fn neither_did_data_nor_did_log_rejected() {
let err = req().resolve().expect_err("missing payload must reject");
assert!(err.contains("either"), "got: {err}");
}
#[test]
fn did_data_webvh_string_passes_through() {
let r = DidRegisterRequest {
method: Some("webvh".into()),
did_data: Some(json!("the\njsonl\nbytes")),
..req()
};
let (method, payload) = r.resolve().unwrap();
assert_eq!(method, "webvh");
assert_eq!(payload, b"the\njsonl\nbytes");
}
#[test]
fn did_data_webvh_array_serialises_to_jsonl() {
let r = DidRegisterRequest {
method: Some("webvh".into()),
did_data: Some(json!([{"v": 1}, {"v": 2}])),
..req()
};
let (_, payload) = r.resolve().unwrap();
let text = String::from_utf8(payload).unwrap();
assert_eq!(text, "{\"v\":1}\n{\"v\":2}");
}
#[test]
fn did_data_web_object_serialises() {
let r = DidRegisterRequest {
method: Some("web".into()),
did_data: Some(json!({ "id": "did:web:example.com:alice" })),
..req()
};
let (method, payload) = r.resolve().unwrap();
assert_eq!(method, "web");
let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(
parsed.get("id").and_then(|v| v.as_str()).unwrap(),
"did:web:example.com:alice"
);
}
#[test]
fn method_mismatch_explicit_vs_did_data_id_rejected() {
let r = DidRegisterRequest {
method: Some("webvh".into()),
did_data: Some(json!({ "id": "did:web:example.com:alice" })),
..req()
};
let err = r.resolve().expect_err("method mismatch must reject");
assert!(err.contains("mismatch"), "got: {err}");
}
#[test]
fn method_derived_from_did_data_id_when_not_explicit() {
let r = DidRegisterRequest {
did_data: Some(json!({ "id": "did:web:example.com:bob" })),
..req()
};
let (method, _) = r.resolve().unwrap();
assert_eq!(method, "web");
}
#[test]
fn unknown_method_rejected() {
let r = DidRegisterRequest {
method: Some("webxyz".into()),
did_data: Some(json!("some-bytes")),
..req()
};
let err = r.resolve().expect_err("unknown method must reject");
assert!(err.contains("unsupported"), "got: {err}");
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DidRegisterResponse {
pub mnemonic: String,
pub did_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DidListEntry {
pub mnemonic: String,
pub owner: String,
pub created_at: u64,
pub updated_at: u64,
pub version_count: u64,
pub did_id: Option<String>,
pub total_resolves: u64,
#[serde(default)]
pub disabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub services: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agent_names: Vec<crate::did_ops::AgentNameEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DidStats {
#[serde(alias = "total_resolves")]
pub total_resolves: u64,
#[serde(alias = "total_updates")]
pub total_updates: u64,
#[serde(alias = "last_resolved_at")]
pub last_resolved_at: Option<u64>,
#[serde(alias = "last_updated_at")]
pub last_updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DidStatsDelta {
pub mnemonic: String,
pub resolve_delta: u64,
pub update_delta: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_resolved_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsSyncPayload {
pub server_did: String,
pub seq: u64,
pub did_deltas: Vec<DidStatsDelta>,
}
#[derive(Debug)]
pub struct CreateDidResult {
pub mnemonic: String,
pub did_url: String,
pub scid: String,
pub did: String,
pub public_key_multibase: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DidSyncUpdate {
pub mnemonic: String,
pub did_id: String,
pub log_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub witness_content: Option<String>,
pub version_count: u64,
pub disabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn did_list_entry_serializes_camel_case() {
let entry = DidListEntry {
mnemonic: "test".to_string(),
owner: "did:example:owner".to_string(),
created_at: 1000,
updated_at: 2000,
version_count: 1,
did_id: Some("did:webvh:abc:host:path".to_string()),
total_resolves: 42,
disabled: false,
method: None,
domain: None,
services: None,
agent_names: Vec::new(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"createdAt\""));
assert!(json.contains("\"updatedAt\""));
assert!(json.contains("\"versionCount\""));
assert!(json.contains("\"didId\""));
assert!(json.contains("\"totalResolves\""));
assert!(!json.contains("\"created_at\""));
assert!(!json.contains("\"updated_at\""));
assert!(!json.contains("\"version_count\""));
assert!(!json.contains("\"did_id\""));
assert!(!json.contains("\"total_resolves\""));
}
#[test]
fn did_list_entry_did_id_none_serializes_as_null() {
let entry = DidListEntry {
mnemonic: "test".to_string(),
owner: "did:example:owner".to_string(),
created_at: 0,
updated_at: 0,
version_count: 0,
did_id: None,
total_resolves: 0,
disabled: false,
method: None,
domain: None,
services: None,
agent_names: Vec::new(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"didId\":null"));
}
#[test]
fn did_list_entry_roundtrip() {
let entry = DidListEntry {
mnemonic: "test".to_string(),
owner: "did:example:owner".to_string(),
created_at: 1000,
updated_at: 2000,
version_count: 3,
did_id: Some("did:webvh:abc:host:path".to_string()),
total_resolves: 99,
disabled: false,
method: Some("webvh".to_string()),
domain: Some("host.example".to_string()),
services: Some(vec![
"WebVHHosting".to_string(),
"TSPTransport".to_string(),
"DIDCommMessaging".to_string(),
]),
agent_names: vec![crate::did_ops::AgentNameEntry {
name: "alice".to_string(),
enabled: true,
created_at: 5,
}],
};
let json = serde_json::to_string(&entry).unwrap();
let back: DidListEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.mnemonic, "test");
assert_eq!(back.version_count, 3);
assert_eq!(back.did_id, Some("did:webvh:abc:host:path".to_string()));
assert_eq!(back.total_resolves, 99);
assert_eq!(
back.services,
Some(vec![
"WebVHHosting".to_string(),
"TSPTransport".to_string(),
"DIDCommMessaging".to_string(),
])
);
assert!(json.contains(r#""agentNames":[{"name":"alice","enabled":true,"createdAt":5}]"#));
assert_eq!(back.agent_names.len(), 1);
assert_eq!(back.agent_names[0].name, "alice");
}
#[test]
fn did_list_entry_legacy_json_has_no_services() {
let legacy = r#"{
"mnemonic": "test", "owner": "did:example:owner",
"createdAt": 0, "updatedAt": 0, "versionCount": 1,
"didId": null, "totalResolves": 0
}"#;
let back: DidListEntry = serde_json::from_str(legacy).unwrap();
assert_eq!(back.services, None);
assert!(back.agent_names.is_empty());
}
#[test]
fn did_stats_default_values() {
let stats = DidStats::default();
assert_eq!(stats.total_resolves, 0);
assert_eq!(stats.total_updates, 0);
assert_eq!(stats.last_resolved_at, None);
assert_eq!(stats.last_updated_at, None);
}
#[test]
fn did_stats_serializes_camel_case() {
let stats = DidStats {
total_resolves: 10,
total_updates: 5,
last_resolved_at: Some(1000),
last_updated_at: Some(2000),
};
let json = serde_json::to_string(&stats).unwrap();
assert!(json.contains("\"totalResolves\""));
assert!(json.contains("\"totalUpdates\""));
assert!(json.contains("\"lastResolvedAt\""));
assert!(json.contains("\"lastUpdatedAt\""));
assert!(!json.contains("\"total_resolves\""));
}
#[test]
fn request_uri_response_camel_case() {
let resp = RequestUriResponse {
mnemonic: "test".to_string(),
did_url: "http://example.com/test/did.jsonl".to_string(),
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"didUrl\""));
assert!(!json.contains("\"did_url\""));
}
}