Skip to main content

did_hosting_server/
watcher_push.rs

1//! Fire-and-forget push of DID state changes to registered watcher instances.
2//!
3//! When a DID's log declares watchers in its parameters, only the configured
4//! watchers whose URLs match the DID's list receive pushes.  After each push
5//! round the per-watcher sync status is persisted so the API can report it.
6
7use crate::auth::session::now_epoch;
8use crate::config::AppConfig;
9use crate::did_ops;
10use crate::store::KeyspaceHandle;
11use did_hosting_common::{SyncDeleteRequest, SyncDidRequest};
12use serde::{Deserialize, Serialize};
13use std::sync::Arc;
14use tracing::warn;
15
16// ---------------------------------------------------------------------------
17// Data types
18// ---------------------------------------------------------------------------
19
20/// Per-watcher sync status stored alongside the DID record.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct WatcherSyncStatus {
24    pub watcher_url: String,
25    pub last_synced_version_id: Option<String>,
26    pub last_synced_at: Option<u64>,
27    pub last_error: Option<String>,
28    pub ok: bool,
29}
30
31// ---------------------------------------------------------------------------
32// Helpers
33// ---------------------------------------------------------------------------
34
35/// Normalize a URL for comparison by trimming trailing slashes.
36fn normalize_url(url: &str) -> String {
37    url.trim_end_matches('/').to_string()
38}
39
40// ---------------------------------------------------------------------------
41// Push logic
42// ---------------------------------------------------------------------------
43
44/// Push the current state of a DID to watchers that the DID declares.
45///
46/// The DID's log parameters may contain a `watchers` array of URLs.  Only
47/// configured watchers (`config.watchers`) whose normalized URL appears in
48/// that list are pushed to.  Watchers declared by the DID but not configured
49/// on this server are recorded with an error status.
50///
51/// After pushing, the sync status for every matched watcher is written to
52/// the store under `watcher_sync:{mnemonic}`.
53pub fn notify_watchers_did(
54    config: &Arc<AppConfig>,
55    http: &reqwest::Client,
56    dids_ks: &KeyspaceHandle,
57    mnemonic: String,
58) {
59    let http = http.clone();
60    let config = config.clone();
61    let dids_ks = dids_ks.clone();
62
63    tokio::spawn(async move {
64        let record = match dids_ks
65            .get::<did_ops::DidRecord>(did_ops::did_key(&mnemonic))
66            .await
67        {
68            Ok(Some(r)) => r,
69            Ok(None) => return,
70            Err(e) => {
71                warn!(mnemonic = %mnemonic, error = %e, "watcher push: failed to read record");
72                return;
73            }
74        };
75
76        let log_content = match dids_ks.get_raw(did_ops::content_log_key(&mnemonic)).await {
77            Ok(Some(bytes)) => match String::from_utf8(bytes) {
78                Ok(s) => s,
79                Err(_) => {
80                    warn!(mnemonic = %mnemonic, "watcher push: invalid UTF-8 in log content");
81                    return;
82                }
83            },
84            Ok(None) => return,
85            Err(e) => {
86                warn!(mnemonic = %mnemonic, error = %e, "watcher push: failed to read log");
87                return;
88            }
89        };
90
91        let witness_content = match dids_ks
92            .get_raw(did_ops::content_witness_key(&mnemonic))
93            .await
94        {
95            Ok(Some(bytes)) => String::from_utf8(bytes).ok(),
96            _ => None,
97        };
98
99        // Extract metadata to get the DID's declared watcher URLs and version.
100        let meta = did_ops::extract_log_metadata(&log_content);
101
102        // If the DID declares no watchers and no watchers are configured, nothing to do.
103        if meta.watcher_urls.is_empty() && config.watchers.is_empty() {
104            return;
105        }
106
107        let payload = SyncDidRequest {
108            mnemonic: mnemonic.clone(),
109            did_id: record.did_id,
110            log_content,
111            witness_content,
112            source_url: config.public_base_url(),
113            updated_at: record.updated_at,
114            disabled: record.disabled,
115        };
116
117        let did_watcher_urls: Vec<String> =
118            meta.watcher_urls.iter().map(|u| normalize_url(u)).collect();
119
120        let mut statuses: Vec<WatcherSyncStatus> = Vec::new();
121
122        // Build a set of normalized configured watcher URLs for quick lookup.
123        let configured_urls: Vec<String> = config
124            .watchers
125            .iter()
126            .map(|w| normalize_url(&w.url))
127            .collect();
128
129        // Push to each configured watcher whose URL is declared by the DID.
130        for watcher in &config.watchers {
131            let norm = normalize_url(&watcher.url);
132            if !did_watcher_urls.is_empty() && !did_watcher_urls.contains(&norm) {
133                continue;
134            }
135
136            let url = format!("{}/api/sync/did", norm);
137            let mut req = http.post(&url).json(&payload);
138            if let Some(token) = &watcher.token {
139                req = req.bearer_auth(token);
140            }
141
142            let (ok, last_error) = match req.send().await {
143                Ok(resp) if resp.status().is_success() => (true, None),
144                Ok(resp) => (false, Some(format!("HTTP {}", resp.status()))),
145                Err(e) => {
146                    warn!(url = %url, error = %e, "failed to push DID to watcher");
147                    (false, Some(e.to_string()))
148                }
149            };
150
151            statuses.push(WatcherSyncStatus {
152                watcher_url: watcher.url.clone(),
153                last_synced_version_id: if ok {
154                    meta.latest_version_id.clone()
155                } else {
156                    None
157                },
158                last_synced_at: if ok { Some(now_epoch()) } else { None },
159                last_error,
160                ok,
161            });
162        }
163
164        // Record DID-declared watchers that are NOT configured on this server.
165        for did_url in &meta.watcher_urls {
166            let norm = normalize_url(did_url);
167            if !configured_urls.contains(&norm) {
168                statuses.push(WatcherSyncStatus {
169                    watcher_url: did_url.clone(),
170                    last_synced_version_id: None,
171                    last_synced_at: None,
172                    last_error: Some("watcher not configured on this server".into()),
173                    ok: false,
174                });
175            }
176        }
177
178        // Persist sync statuses.
179        if !statuses.is_empty()
180            && let Err(e) = dids_ks
181                .insert(did_ops::watcher_sync_key(&mnemonic), &statuses)
182                .await
183        {
184            warn!(mnemonic = %mnemonic, error = %e, "failed to persist watcher sync status");
185        }
186    });
187}
188
189/// Notify all configured watchers that a DID has been deleted, and remove
190/// the persisted sync status.
191pub fn notify_watchers_delete(
192    config: &Arc<AppConfig>,
193    http: &reqwest::Client,
194    dids_ks: &KeyspaceHandle,
195    mnemonic: String,
196) {
197    let http = http.clone();
198    let config = config.clone();
199    let dids_ks = dids_ks.clone();
200
201    tokio::spawn(async move {
202        // Clean up persisted sync status.
203        let _ = dids_ks.remove(did_ops::watcher_sync_key(&mnemonic)).await;
204
205        if config.watchers.is_empty() {
206            return;
207        }
208
209        let payload = SyncDeleteRequest {
210            mnemonic: mnemonic.clone(),
211            source_url: config.public_base_url(),
212        };
213
214        for watcher in &config.watchers {
215            let url = format!("{}/api/sync/delete", watcher.url.trim_end_matches('/'));
216            let mut req = http.post(&url).json(&payload);
217            if let Some(token) = &watcher.token {
218                req = req.bearer_auth(token);
219            }
220            if let Err(e) = req.send().await {
221                warn!(url = %url, error = %e, "failed to push DID delete to watcher");
222            }
223        }
224    });
225}