did_hosting_server/
watcher_push.rs1use 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#[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
31fn normalize_url(url: &str) -> String {
37 url.trim_end_matches('/').to_string()
38}
39
40pub 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 let meta = did_ops::extract_log_metadata(&log_content);
101
102 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 let configured_urls: Vec<String> = config
124 .watchers
125 .iter()
126 .map(|w| normalize_url(&w.url))
127 .collect();
128
129 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 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 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
189pub 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 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}