Skip to main content

did_hosting_server/
bootstrap.rs

1//! Server DID bootstrap — creates DID log entries for hosted DIDs.
2//!
3//! Shared logic used by both the `bootstrap-did` CLI subcommand and the
4//! auto-bootstrap path on server startup.
5
6use affinidi_tdk::secrets_resolver::secrets::Secret;
7use did_hosting_common::did::{
8    DidDocumentOptions, build_did_document, create_log_entry, encode_host,
9};
10use tracing::info;
11
12use crate::auth::session::now_epoch;
13use crate::did_ops::{
14    DidRecord, content_log_key, content_witness_key, did_key, extract_did_id,
15    extract_service_types, owner_key, validate_did_jsonl,
16};
17use crate::error::AppError;
18use crate::store::{KeyspaceHandle, Store};
19
20/// Result of bootstrapping the root DID.
21pub struct BootstrapResult {
22    pub scid: String,
23    pub did_id: String,
24    pub jsonl: String,
25    pub mnemonic: String,
26}
27
28/// Check whether the `.well-known` root DID already exists.
29pub async fn root_did_exists(dids_ks: &KeyspaceHandle) -> Result<bool, AppError> {
30    dids_ks.contains_key(did_key(".well-known")).await
31}
32
33/// Create the `.well-known` root DID log entry and store it atomically.
34///
35/// Convenience wrapper around [`bootstrap_did`] for the root DID path.
36pub async fn bootstrap_root_did(
37    store: &Store,
38    dids_ks: &KeyspaceHandle,
39    signing_secret: &Secret,
40    ka_secret: Option<&Secret>,
41    mediator_did: Option<&str>,
42    public_url: &str,
43) -> Result<BootstrapResult, AppError> {
44    bootstrap_did(
45        store,
46        dids_ks,
47        signing_secret,
48        ka_secret,
49        mediator_did,
50        // Convenience wrapper: advertise both transports at the mediator
51        // (callers that need a specific transport call `bootstrap_did`).
52        true,
53        true,
54        public_url,
55        ".well-known",
56    )
57    .await
58}
59
60/// Create a DID log entry at the given path and store it atomically.
61///
62/// The signing secret's public key is embedded in the DID document. If
63/// `ka_secret` is provided, an X25519 key agreement key is also added.
64/// If `mediator_did` is provided, a `DIDCommMessaging` service is added
65/// when `advertise_didcomm` is set and a `TSPTransport` service when
66/// `advertise_tsp` is set — both point at the same mediator (they share
67/// its socket). Gating these on the node's transport selection lets a
68/// self-managed node advertise DIDComm-only, TSP-only, or both.
69/// The resulting log entry is stored alongside a `DidRecord` with owner `"system"`.
70#[allow(clippy::too_many_arguments)]
71pub async fn bootstrap_did(
72    store: &Store,
73    dids_ks: &KeyspaceHandle,
74    signing_secret: &Secret,
75    ka_secret: Option<&Secret>,
76    mediator_did: Option<&str>,
77    advertise_didcomm: bool,
78    advertise_tsp: bool,
79    public_url: &str,
80    mnemonic: &str,
81) -> Result<BootstrapResult, AppError> {
82    // Guard: must not already exist
83    if dids_ks.contains_key(did_key(mnemonic)).await? {
84        return Err(AppError::Conflict(format!(
85            "DID at path '{mnemonic}' already exists"
86        )));
87    }
88
89    let host = encode_host(public_url)
90        .map_err(|e| AppError::Config(format!("failed to encode host from public_url: {e}")))?;
91
92    let public_key = signing_secret
93        .get_public_keymultibase()
94        .map_err(|e| AppError::Internal(format!("failed to get public key multibase: {e}")))?;
95
96    let ka_public_key = ka_secret
97        .map(|s| s.get_public_keymultibase())
98        .transpose()
99        .map_err(|e| AppError::Internal(format!("failed to get KA public key multibase: {e}")))?;
100
101    let doc = build_did_document(
102        &host,
103        mnemonic,
104        &public_key,
105        &DidDocumentOptions {
106            key_agreement_multibase: ka_public_key.as_deref(),
107            // Advertise each transport's service entry only when selected;
108            // both point at the same mediator (shared socket).
109            mediator_endpoint: if advertise_didcomm {
110                mediator_did
111            } else {
112                None
113            },
114            tsp_endpoint: if advertise_tsp { mediator_did } else { None },
115        },
116    );
117
118    let (scid, jsonl) = create_log_entry(&doc, signing_secret)
119        .await
120        .map_err(|e| AppError::Internal(format!("failed to create log entry: {e}")))?;
121
122    let did_id = extract_did_id(&jsonl)
123        .ok_or_else(|| AppError::Internal("failed to extract DID id from log entry".into()))?;
124
125    let mnemonic = mnemonic.to_string();
126    let now = now_epoch();
127
128    let record = DidRecord {
129        owner: "system".to_string(),
130        mnemonic: mnemonic.clone(),
131        created_at: now,
132        updated_at: now,
133        version_count: 1,
134        did_id: Some(did_id.clone()),
135        content_size: jsonl.len() as u64,
136        disabled: false,
137        deleted_at: None,
138
139        // T12: legacy construction site; T13 migration fills `domain`.
140        method: "webvh".to_string(),
141        domain: String::new(),
142
143        // The server's own DID — its badges (WebVHHosting, and TSP and/or
144        // DIDComm per `advertise_tsp` / `mediator_did` above) are exactly
145        // what `build_did_document` just wrote into the doc.
146        services: extract_service_types(&jsonl),
147        agent_names: Vec::new(),
148    };
149
150    let mut batch = store.batch();
151    batch.insert(dids_ks, did_key(&mnemonic), &record)?;
152    batch.insert_raw(
153        dids_ks,
154        content_log_key(&mnemonic),
155        jsonl.as_bytes().to_vec(),
156    );
157    batch.insert_raw(
158        dids_ks,
159        owner_key("system", &mnemonic),
160        mnemonic.as_bytes().to_vec(),
161    );
162    batch.commit().await?;
163
164    info!(did = %did_id, scid = %scid, path = %mnemonic, "DID bootstrapped");
165
166    Ok(BootstrapResult {
167        scid,
168        did_id,
169        jsonl,
170        mnemonic,
171    })
172}
173
174/// Import an existing `.well-known` root DID from provided JSONL content.
175///
176/// Validates the JSONL, extracts the DID id and SCID, and stores everything
177/// atomically. Optionally stores witness content alongside the log.
178pub async fn import_root_did(
179    store: &Store,
180    dids_ks: &KeyspaceHandle,
181    jsonl: &str,
182    witness_content: Option<&str>,
183) -> Result<BootstrapResult, AppError> {
184    // Guard: must not already exist
185    if root_did_exists(dids_ks).await? {
186        return Err(AppError::Conflict(
187            "root DID (.well-known) already exists".into(),
188        ));
189    }
190
191    import_did_at_path(store, dids_ks, ".well-known", jsonl, witness_content).await
192}
193
194/// Import an existing DID at an arbitrary path (mnemonic).
195///
196/// Generalisation of `import_root_did` — works for any mnemonic, not just
197/// `.well-known`. Guards against overwriting an existing entry at the same
198/// path. The DID is stored with owner `"system"`.
199pub async fn import_did_at_path(
200    store: &Store,
201    dids_ks: &KeyspaceHandle,
202    mnemonic: &str,
203    jsonl: &str,
204    witness_content: Option<&str>,
205) -> Result<BootstrapResult, AppError> {
206    // Guard: must not already exist at this path
207    if dids_ks.contains_key(did_key(mnemonic)).await? {
208        return Err(AppError::Conflict(format!(
209            "DID at path '{mnemonic}' already exists"
210        )));
211    }
212
213    // Validate the JSONL content
214    validate_did_jsonl(jsonl)?;
215
216    let did_id = extract_did_id(jsonl)
217        .ok_or_else(|| AppError::Validation("could not extract DID id from did.jsonl".into()))?;
218
219    // Extract SCID from the DID id (did:webvh:<scid>:host:...)
220    let scid = did_id
221        .strip_prefix("did:webvh:")
222        .and_then(|rest| rest.split(':').next())
223        .ok_or_else(|| AppError::Validation("could not extract SCID from DID id".into()))?
224        .to_string();
225
226    // Validate witness content is valid JSON if provided
227    if let Some(witness) = witness_content {
228        serde_json::from_str::<serde_json::Value>(witness).map_err(|e| {
229            AppError::Validation(format!("did-witness.json must be valid JSON: {e}"))
230        })?;
231    }
232
233    let mnemonic_str = mnemonic.to_string();
234    let now = now_epoch();
235    let version_count = jsonl.lines().filter(|l| !l.trim().is_empty()).count() as u64;
236
237    let record = DidRecord {
238        owner: "system".to_string(),
239        mnemonic: mnemonic_str.clone(),
240        created_at: now,
241        updated_at: now,
242        version_count,
243        did_id: Some(did_id.clone()),
244        content_size: jsonl.len() as u64,
245        disabled: false,
246        deleted_at: None,
247
248        // T12: legacy construction site; T13 migration fills `domain`.
249        method: "webvh".to_string(),
250        domain: String::new(),
251
252        services: extract_service_types(jsonl),
253
254        agent_names: Vec::new(),
255    };
256
257    let mut batch = store.batch();
258    batch.insert(dids_ks, did_key(&mnemonic_str), &record)?;
259    batch.insert_raw(
260        dids_ks,
261        content_log_key(&mnemonic_str),
262        jsonl.as_bytes().to_vec(),
263    );
264    batch.insert_raw(
265        dids_ks,
266        owner_key("system", &mnemonic_str),
267        mnemonic_str.as_bytes().to_vec(),
268    );
269    if let Some(witness) = witness_content {
270        batch.insert_raw(
271            dids_ks,
272            content_witness_key(&mnemonic_str),
273            witness.as_bytes().to_vec(),
274        );
275    }
276    batch.commit().await?;
277
278    info!(did = %did_id, scid = %scid, path = %mnemonic_str, "DID imported from files");
279
280    Ok(BootstrapResult {
281        scid,
282        did_id,
283        jsonl: jsonl.to_string(),
284        mnemonic: mnemonic_str,
285    })
286}