vta_sdk/protocols/did_management/create.rs
1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// How the `<path>` segment of a server-managed `did:webvh` is chosen.
5///
6/// Only meaningful when a hosting server is selected (`server_id` is
7/// set). A serverless DID always resolves at
8/// `<host>/.well-known/did.jsonl` and ignores this — serverless mode is
9/// selected by the *absence* of `server_id`, not by this enum. The three
10/// variants map onto the hosting server's `check-name` / `create_did`
11/// path contract:
12///
13/// - [`WebvhPathMode::WellKnown`] → the reserved `.well-known` root slot
14/// (`<host>/.well-known/did.jsonl`). Admin-gated on the host.
15/// - [`WebvhPathMode::Explicit`] → an operator-chosen label
16/// (`<host>/<path>/did.jsonl`).
17/// - [`WebvhPathMode::AutoAssign`] → the host allocates a path (it mints
18/// a fresh mnemonic). This is the default.
19///
20/// `AutoAssign` is the default because that is the long-standing
21/// "no path given → the server assigns one" contract: the setup wizard's
22/// "leave blank → server-assigned" prompt maps a blank path here. An
23/// absent path has never meant the `.well-known` root on a hosting
24/// server — that is the serverless case (selected by the absence of a
25/// `server_id`, where the DID location comes from the `URL` itself).
26#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
27#[serde(tag = "mode", content = "path", rename_all = "snake_case")]
28#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
29pub enum WebvhPathMode {
30 /// Root DID at the host: resolves at `<host>/.well-known/did.jsonl`.
31 WellKnown,
32 /// Operator-chosen path label: `<host>/<path>/did.jsonl`.
33 Explicit(String),
34 /// Let the hosting server allocate the path.
35 #[default]
36 AutoAssign,
37}
38
39impl WebvhPathMode {
40 /// The path string to hand the hosting server's `check-name` /
41 /// `create_did` call. `Some(".well-known")` / `Some(label)` reserve a
42 /// specific slot; `None` tells the host to allocate one (the
43 /// auto-assign contract — the host mints a mnemonic).
44 ///
45 /// Returning `None` for [`AutoAssign`](WebvhPathMode::AutoAssign) is
46 /// load-bearing: the DIDComm/REST clients must *omit* the `path` wire
47 /// field for auto-assign. Sending an empty string instead makes the
48 /// host reject it with `e.p.did.path-invalid` ("path must not be
49 /// empty"), since the host validates a present-but-empty path.
50 pub fn to_request_path(&self) -> Option<&str> {
51 match self {
52 WebvhPathMode::WellKnown => Some(".well-known"),
53 WebvhPathMode::Explicit(p) => Some(p),
54 WebvhPathMode::AutoAssign => None,
55 }
56 }
57
58 /// Resolve the effective mode from the new explicit `path_mode` field
59 /// and the legacy `path: Option<String>` field. `path_mode` wins when
60 /// present; otherwise the legacy `path` is interpreted (via
61 /// `From<Option<String>>`) so pre-enum callers keep working.
62 pub fn resolve(path_mode: Option<WebvhPathMode>, legacy_path: Option<String>) -> Self {
63 path_mode.unwrap_or_else(|| WebvhPathMode::from(legacy_path))
64 }
65}
66
67impl From<Option<String>> for WebvhPathMode {
68 fn from(path: Option<String>) -> Self {
69 match path {
70 None => WebvhPathMode::AutoAssign,
71 Some(p) => WebvhPathMode::from(p),
72 }
73 }
74}
75
76impl From<String> for WebvhPathMode {
77 fn from(path: String) -> Self {
78 match path.trim() {
79 // Empty / whitespace-only is auto-assign, not an explicit
80 // empty path — an explicit "" would be rejected by the host.
81 "" => WebvhPathMode::AutoAssign,
82 ".well-known" => WebvhPathMode::WellKnown,
83 trimmed => WebvhPathMode::Explicit(trimmed.to_string()),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
90pub struct CreateDidWebvhBody {
91 pub context_id: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub server_id: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub url: Option<String>,
96 /// Legacy path selector. Prefer [`path_mode`](Self::path_mode) for
97 /// new callers — it distinguishes the `.well-known` root, an explicit
98 /// label, and server-side auto-assignment. Kept for wire back-compat:
99 /// when `path_mode` is absent, this is interpreted as `None`/empty →
100 /// auto-assign, `".well-known"` → root, else explicit (see
101 /// [`WebvhPathMode::resolve`]).
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub path: Option<String>,
104 /// Explicit path-selection mode for server-managed DIDs. When set it
105 /// overrides [`path`](Self::path). Absent → fall back to `path`.
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub path_mode: Option<WebvhPathMode>,
108 /// Optional explicit hosting domain on the target server. When
109 /// the server hosts multiple tenant domains, the caller may
110 /// supply this to direct the new DID at a specific one;
111 /// otherwise the server resolves via caller's ACL default →
112 /// system default. An unknown domain on the server is rejected
113 /// with `did-management:unknown_domain`. Ignored in serverless
114 /// mode.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub domain: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub label: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub portable: Option<bool>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub add_mediator_service: Option<bool>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub additional_services: Option<Vec<serde_json::Value>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub pre_rotation_count: Option<u32>,
127 /// Client-provided DID Document template. When set, the VTA uses this
128 /// instead of building the document internally. `{DID}` placeholders are
129 /// resolved by `didwebvh-rs`. Mutually exclusive with `did_log`.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub did_document: Option<serde_json::Value>,
132 /// Complete, pre-signed did.jsonl log entry. When set, the VTA publishes
133 /// it as-is without deriving keys or creating a log entry. Mutually
134 /// exclusive with `did_document`.
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub did_log: Option<String>,
137 /// Whether to set this DID as the primary DID for the context.
138 /// Defaults to `true` for backwards compatibility.
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub set_primary: Option<bool>,
141 /// Use an existing key as the signing (Ed25519) verification method.
142 /// When set, the VTA skips key derivation and uses this key instead.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub signing_key_id: Option<String>,
145 /// Use an existing key as the key-agreement (X25519) verification method.
146 /// Required when the DID document includes DIDCommMessaging services.
147 /// Requires `signing_key_id` to also be set.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub ka_key_id: Option<String>,
150 /// Stored DID template name to render as the DID document. Mutually
151 /// exclusive with `did_document` and `did_log`.
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub template: Option<String>,
154 /// Scope to look the template up in. `None` means "global only"; `Some(ctx)`
155 /// means "this context first, then global, then builtin".
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub template_context: Option<String>,
158 /// Caller-supplied template variables. Server injects `DID`,
159 /// `SIGNING_KEY_MB`, `KA_KEY_MB`, `VTA_DID`, `VTA_URL`, `CONTEXT_ID`,
160 /// `CONTEXT_DID`, `NOW` automatically.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub template_vars: Option<std::collections::HashMap<String, serde_json::Value>>,
163}
164
165#[derive(Clone, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
167pub struct CreateDidWebvhResultBody {
168 pub did: String,
169 pub context_id: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub server_id: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub mnemonic: Option<String>,
174 pub scid: String,
175 pub portable: bool,
176 pub signing_key_id: String,
177 pub ka_key_id: String,
178 pub pre_rotation_key_count: u32,
179 pub created_at: DateTime<Utc>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub did_document: Option<serde_json::Value>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub log_entry: Option<String>,
184}
185
186// Manual Debug — `mnemonic` is a 24-word BIP-39 phrase that recovers
187// the entire key hierarchy under the DID. Logging it via `{:?}` is a
188// total compromise. Serialize is unchanged so the wire shape and
189// sealed-transfer payload still round-trip.
190impl std::fmt::Debug for CreateDidWebvhResultBody {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.debug_struct("CreateDidWebvhResultBody")
193 .field("did", &self.did)
194 .field("context_id", &self.context_id)
195 .field("server_id", &self.server_id)
196 .field("mnemonic", &self.mnemonic.as_ref().map(|_| "<redacted>"))
197 .field("scid", &self.scid)
198 .field("portable", &self.portable)
199 .field("signing_key_id", &self.signing_key_id)
200 .field("ka_key_id", &self.ka_key_id)
201 .field("pre_rotation_key_count", &self.pre_rotation_key_count)
202 .field("created_at", &self.created_at)
203 .field("did_document", &self.did_document)
204 .field("log_entry", &self.log_entry)
205 .finish()
206 }
207}
208
209#[cfg(test)]
210mod webvh_path_mode_tests {
211 use super::WebvhPathMode;
212
213 /// The wire path the host's `check-name`/`create_did` receives.
214 /// `AutoAssign → None` is load-bearing: the clients omit the field,
215 /// which is the only form the host treats as "allocate one for me".
216 #[test]
217 fn to_request_path_maps_each_mode() {
218 assert_eq!(
219 WebvhPathMode::WellKnown.to_request_path(),
220 Some(".well-known")
221 );
222 assert_eq!(
223 WebvhPathMode::Explicit("alice".into()).to_request_path(),
224 Some("alice")
225 );
226 assert_eq!(WebvhPathMode::AutoAssign.to_request_path(), None);
227 }
228
229 /// Default is auto-assign — the long-standing "no path → server
230 /// assigns one" contract. An absent path has never meant `.well-known`.
231 #[test]
232 fn default_is_auto_assign() {
233 assert_eq!(WebvhPathMode::default(), WebvhPathMode::AutoAssign);
234 }
235
236 /// Legacy `path: Option<String>` interpretation: None/empty →
237 /// auto-assign, `.well-known` → root, else explicit (trimmed).
238 #[test]
239 fn from_legacy_path() {
240 assert_eq!(WebvhPathMode::from(None), WebvhPathMode::AutoAssign);
241 assert_eq!(
242 WebvhPathMode::from(Some(" ".to_string())),
243 WebvhPathMode::AutoAssign
244 );
245 assert_eq!(
246 WebvhPathMode::from(Some(".well-known".to_string())),
247 WebvhPathMode::WellKnown
248 );
249 assert_eq!(
250 WebvhPathMode::from(Some(" alice ".to_string())),
251 WebvhPathMode::Explicit("alice".into())
252 );
253 }
254
255 /// `resolve`: explicit `path_mode` wins; otherwise fall back to the
256 /// legacy `path`.
257 #[test]
258 fn resolve_prefers_explicit_mode() {
259 // Explicit mode set → legacy path ignored.
260 assert_eq!(
261 WebvhPathMode::resolve(Some(WebvhPathMode::AutoAssign), Some("alice".into())),
262 WebvhPathMode::AutoAssign
263 );
264 // No mode → interpret legacy path.
265 assert_eq!(
266 WebvhPathMode::resolve(None, Some("alice".into())),
267 WebvhPathMode::Explicit("alice".into())
268 );
269 assert_eq!(
270 WebvhPathMode::resolve(None, None),
271 WebvhPathMode::AutoAssign
272 );
273 }
274
275 /// Adjacently-tagged serde shape, and that it round-trips.
276 #[test]
277 fn serde_round_trips() {
278 for mode in [
279 WebvhPathMode::WellKnown,
280 WebvhPathMode::Explicit("alice".into()),
281 WebvhPathMode::AutoAssign,
282 ] {
283 let json = serde_json::to_value(&mode).unwrap();
284 let back: WebvhPathMode = serde_json::from_value(json).unwrap();
285 assert_eq!(mode, back);
286 }
287 // Pin the explicit wire shape.
288 assert_eq!(
289 serde_json::to_value(WebvhPathMode::Explicit("alice".into())).unwrap(),
290 serde_json::json!({ "mode": "explicit", "path": "alice" })
291 );
292 assert_eq!(
293 serde_json::to_value(WebvhPathMode::AutoAssign).unwrap(),
294 serde_json::json!({ "mode": "auto_assign" })
295 );
296 }
297}