Skip to main content

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}