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#[serde(rename_all = "camelCase")]
90#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
91pub struct CreateDidWebvhBody {
92    #[serde(alias = "context_id")]
93    pub context_id: String,
94    #[serde(default, skip_serializing_if = "Option::is_none", alias = "server_id")]
95    pub server_id: Option<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub url: Option<String>,
98    /// Legacy path selector. Prefer [`path_mode`](Self::path_mode) for
99    /// new callers — it distinguishes the `.well-known` root, an explicit
100    /// label, and server-side auto-assignment. Kept for wire back-compat:
101    /// when `path_mode` is absent, this is interpreted as `None`/empty →
102    /// auto-assign, `".well-known"` → root, else explicit (see
103    /// [`WebvhPathMode::resolve`]).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub path: Option<String>,
106    /// Explicit path-selection mode for server-managed DIDs. When set it
107    /// overrides [`path`](Self::path). Absent → fall back to `path`.
108    #[serde(default, skip_serializing_if = "Option::is_none", alias = "path_mode")]
109    pub path_mode: Option<WebvhPathMode>,
110    /// Optional explicit hosting domain on the target server. When
111    /// the server hosts multiple tenant domains, the caller may
112    /// supply this to direct the new DID at a specific one;
113    /// otherwise the server resolves via caller's ACL default →
114    /// system default. An unknown domain on the server is rejected
115    /// with `did-management:unknown_domain`. Ignored in serverless
116    /// mode.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub domain: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub label: Option<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub portable: Option<bool>,
123    #[serde(
124        default,
125        skip_serializing_if = "Option::is_none",
126        alias = "add_mediator_service"
127    )]
128    pub add_mediator_service: Option<bool>,
129    #[serde(
130        default,
131        skip_serializing_if = "Option::is_none",
132        alias = "additional_services"
133    )]
134    pub additional_services: Option<Vec<serde_json::Value>>,
135    #[serde(
136        default,
137        skip_serializing_if = "Option::is_none",
138        alias = "pre_rotation_count"
139    )]
140    pub pre_rotation_count: Option<u32>,
141    /// Client-provided DID Document template. When set, the VTA uses this
142    /// instead of building the document internally. `{DID}` placeholders are
143    /// resolved by `didwebvh-rs`. Mutually exclusive with `did_log`.
144    #[serde(
145        default,
146        skip_serializing_if = "Option::is_none",
147        alias = "did_document"
148    )]
149    pub did_document: Option<serde_json::Value>,
150    /// Complete, pre-signed did.jsonl log entry. When set, the VTA publishes
151    /// it as-is without deriving keys or creating a log entry. Mutually
152    /// exclusive with `did_document`.
153    #[serde(default, skip_serializing_if = "Option::is_none", alias = "did_log")]
154    pub did_log: Option<String>,
155    /// Whether to set this DID as the primary DID for the context.
156    /// Defaults to `true` for backwards compatibility.
157    #[serde(
158        default,
159        skip_serializing_if = "Option::is_none",
160        alias = "set_primary"
161    )]
162    pub set_primary: Option<bool>,
163    /// Use an existing key as the signing (Ed25519) verification method.
164    /// When set, the VTA skips key derivation and uses this key instead.
165    #[serde(
166        default,
167        skip_serializing_if = "Option::is_none",
168        alias = "signing_key_id"
169    )]
170    pub signing_key_id: Option<String>,
171    /// Use an existing key as the key-agreement (X25519) verification method.
172    /// Required when the DID document includes DIDCommMessaging services.
173    /// Requires `signing_key_id` to also be set.
174    #[serde(default, skip_serializing_if = "Option::is_none", alias = "ka_key_id")]
175    pub ka_key_id: Option<String>,
176    /// Stored DID template name to render as the DID document. Mutually
177    /// exclusive with `did_document` and `did_log`.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub template: Option<String>,
180    /// Scope to look the template up in. `None` means "global only"; `Some(ctx)`
181    /// means "this context first, then global, then builtin".
182    #[serde(
183        default,
184        skip_serializing_if = "Option::is_none",
185        alias = "template_context"
186    )]
187    pub template_context: Option<String>,
188    /// Caller-supplied template variables. Server injects `DID`,
189    /// `SIGNING_KEY_MB`, `KA_KEY_MB`, `VTA_DID`, `VTA_URL`, `CONTEXT_ID`,
190    /// `CONTEXT_DID`, `NOW` automatically.
191    #[serde(
192        default,
193        skip_serializing_if = "Option::is_none",
194        alias = "template_vars"
195    )]
196    pub template_vars: Option<std::collections::HashMap<String, serde_json::Value>>,
197}
198
199#[derive(Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
201pub struct CreateDidWebvhResultBody {
202    pub did: String,
203    pub context_id: String,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub server_id: Option<String>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub mnemonic: Option<String>,
208    pub scid: String,
209    pub portable: bool,
210    pub signing_key_id: String,
211    pub ka_key_id: String,
212    pub pre_rotation_key_count: u32,
213    pub created_at: DateTime<Utc>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub did_document: Option<serde_json::Value>,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub log_entry: Option<String>,
218}
219
220// Manual Debug — `mnemonic` is a 24-word BIP-39 phrase that recovers
221// the entire key hierarchy under the DID. Logging it via `{:?}` is a
222// total compromise. Serialize is unchanged so the wire shape and
223// sealed-transfer payload still round-trip.
224impl std::fmt::Debug for CreateDidWebvhResultBody {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("CreateDidWebvhResultBody")
227            .field("did", &self.did)
228            .field("context_id", &self.context_id)
229            .field("server_id", &self.server_id)
230            .field("mnemonic", &self.mnemonic.as_ref().map(|_| "<redacted>"))
231            .field("scid", &self.scid)
232            .field("portable", &self.portable)
233            .field("signing_key_id", &self.signing_key_id)
234            .field("ka_key_id", &self.ka_key_id)
235            .field("pre_rotation_key_count", &self.pre_rotation_key_count)
236            .field("created_at", &self.created_at)
237            .field("did_document", &self.did_document)
238            .field("log_entry", &self.log_entry)
239            .finish()
240    }
241}
242
243#[cfg(test)]
244mod webvh_path_mode_tests {
245    use super::WebvhPathMode;
246
247    /// The wire path the host's `check-name`/`create_did` receives.
248    /// `AutoAssign → None` is load-bearing: the clients omit the field,
249    /// which is the only form the host treats as "allocate one for me".
250    #[test]
251    fn to_request_path_maps_each_mode() {
252        assert_eq!(
253            WebvhPathMode::WellKnown.to_request_path(),
254            Some(".well-known")
255        );
256        assert_eq!(
257            WebvhPathMode::Explicit("alice".into()).to_request_path(),
258            Some("alice")
259        );
260        assert_eq!(WebvhPathMode::AutoAssign.to_request_path(), None);
261    }
262
263    /// Default is auto-assign — the long-standing "no path → server
264    /// assigns one" contract. An absent path has never meant `.well-known`.
265    #[test]
266    fn default_is_auto_assign() {
267        assert_eq!(WebvhPathMode::default(), WebvhPathMode::AutoAssign);
268    }
269
270    /// Legacy `path: Option<String>` interpretation: None/empty →
271    /// auto-assign, `.well-known` → root, else explicit (trimmed).
272    #[test]
273    fn from_legacy_path() {
274        assert_eq!(WebvhPathMode::from(None), WebvhPathMode::AutoAssign);
275        assert_eq!(
276            WebvhPathMode::from(Some("   ".to_string())),
277            WebvhPathMode::AutoAssign
278        );
279        assert_eq!(
280            WebvhPathMode::from(Some(".well-known".to_string())),
281            WebvhPathMode::WellKnown
282        );
283        assert_eq!(
284            WebvhPathMode::from(Some("  alice ".to_string())),
285            WebvhPathMode::Explicit("alice".into())
286        );
287    }
288
289    /// `resolve`: explicit `path_mode` wins; otherwise fall back to the
290    /// legacy `path`.
291    #[test]
292    fn resolve_prefers_explicit_mode() {
293        // Explicit mode set → legacy path ignored.
294        assert_eq!(
295            WebvhPathMode::resolve(Some(WebvhPathMode::AutoAssign), Some("alice".into())),
296            WebvhPathMode::AutoAssign
297        );
298        // No mode → interpret legacy path.
299        assert_eq!(
300            WebvhPathMode::resolve(None, Some("alice".into())),
301            WebvhPathMode::Explicit("alice".into())
302        );
303        assert_eq!(
304            WebvhPathMode::resolve(None, None),
305            WebvhPathMode::AutoAssign
306        );
307    }
308
309    /// Adjacently-tagged serde shape, and that it round-trips.
310    #[test]
311    fn serde_round_trips() {
312        for mode in [
313            WebvhPathMode::WellKnown,
314            WebvhPathMode::Explicit("alice".into()),
315            WebvhPathMode::AutoAssign,
316        ] {
317            let json = serde_json::to_value(&mode).unwrap();
318            let back: WebvhPathMode = serde_json::from_value(json).unwrap();
319            assert_eq!(mode, back);
320        }
321        // Pin the explicit wire shape.
322        assert_eq!(
323            serde_json::to_value(WebvhPathMode::Explicit("alice".into())).unwrap(),
324            serde_json::json!({ "mode": "explicit", "path": "alice" })
325        );
326        assert_eq!(
327            serde_json::to_value(WebvhPathMode::AutoAssign).unwrap(),
328            serde_json::json!({ "mode": "auto_assign" })
329        );
330    }
331}
332
333#[cfg(test)]
334mod casing_tests {
335    use super::*;
336
337    /// camelCase is the framework wire convention; snake_case stays accepted as
338    /// an alias so callers written against the old shape keep working.
339    ///
340    /// The bug this closes is the one an end-to-end harness caught the first time
341    /// it posted a real create over the wire: the body expected `context_id`, a
342    /// framework-conventional client sent `contextId`, and with no
343    /// `deny_unknown_fields` the required field simply read as missing.
344    #[test]
345    fn create_body_reads_both_casings() {
346        let camel: CreateDidWebvhBody =
347            serde_json::from_str(r#"{"contextId":"default","preRotationCount":2}"#).unwrap();
348        assert_eq!(camel.context_id, "default");
349        assert_eq!(camel.pre_rotation_count, Some(2));
350
351        let snake: CreateDidWebvhBody =
352            serde_json::from_str(r#"{"context_id":"default","pre_rotation_count":2}"#).unwrap();
353        assert_eq!(snake.context_id, "default");
354        assert_eq!(snake.pre_rotation_count, Some(2));
355    }
356}