Skip to main content

clawdentity_core/registry/
admin.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::{CliConfig, ConfigPathOptions, read_config, resolve_config, write_config};
4use crate::error::{CoreError, Result};
5use crate::http::blocking_client;
6
7const ADMIN_BOOTSTRAP_PATH: &str = "/v1/admin/bootstrap";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct AdminHuman {
12    pub id: String,
13    pub did: String,
14    pub display_name: String,
15    pub role: String,
16    pub status: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct AdminApiKey {
22    pub id: String,
23    pub name: String,
24    pub token: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct AdminInternalService {
30    pub id: String,
31    pub name: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct AdminBootstrapResult {
37    pub human: AdminHuman,
38    pub api_key: AdminApiKey,
39    pub internal_service: AdminInternalService,
40    pub registry_url: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AdminBootstrapInput {
45    pub bootstrap_secret: String,
46    pub display_name: Option<String>,
47    pub api_key_name: Option<String>,
48    pub registry_url: Option<String>,
49}
50
51#[derive(Debug, Deserialize)]
52#[serde(rename_all = "camelCase")]
53struct AdminBootstrapPayload {
54    human: AdminHumanPayload,
55    api_key: AdminApiKeyPayload,
56    internal_service: AdminInternalServicePayload,
57}
58
59#[derive(Debug, Deserialize)]
60#[serde(rename_all = "camelCase")]
61struct AdminHumanPayload {
62    id: String,
63    did: String,
64    display_name: String,
65    role: String,
66    status: String,
67}
68
69#[derive(Debug, Deserialize)]
70#[serde(rename_all = "camelCase")]
71struct AdminApiKeyPayload {
72    id: String,
73    name: String,
74    token: String,
75}
76
77#[derive(Debug, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct AdminInternalServicePayload {
80    id: String,
81    name: String,
82}
83
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86struct ErrorEnvelope {
87    error: Option<RegistryError>,
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(rename_all = "camelCase")]
92struct RegistryError {
93    message: Option<String>,
94}
95
96fn parse_non_empty(value: &str, field: &str) -> Result<String> {
97    let trimmed = value.trim();
98    if trimmed.is_empty() {
99        return Err(CoreError::InvalidInput(format!("{field} is required")));
100    }
101    Ok(trimmed.to_string())
102}
103
104fn normalize_registry_url(value: &str) -> Result<String> {
105    url::Url::parse(value.trim())
106        .map(|url| url.to_string())
107        .map_err(|_| CoreError::InvalidUrl {
108            context: "registryUrl",
109            value: value.to_string(),
110        })
111}
112
113fn to_request_url(registry_url: &str) -> Result<String> {
114    let base = if registry_url.ends_with('/') {
115        registry_url.to_string()
116    } else {
117        format!("{registry_url}/")
118    };
119    let joined = url::Url::parse(&base)
120        .map_err(|_| CoreError::InvalidUrl {
121            context: "registryUrl",
122            value: registry_url.to_string(),
123        })?
124        .join(ADMIN_BOOTSTRAP_PATH.trim_start_matches('/'))
125        .map_err(|_| CoreError::InvalidUrl {
126            context: "registryUrl",
127            value: registry_url.to_string(),
128        })?;
129    Ok(joined.to_string())
130}
131
132fn parse_error_message(response_body: &str) -> String {
133    match serde_json::from_str::<ErrorEnvelope>(response_body) {
134        Ok(envelope) => envelope
135            .error
136            .and_then(|error| error.message)
137            .unwrap_or_else(|| response_body.to_string()),
138        Err(_) => response_body.to_string(),
139    }
140}
141
142fn parse_bootstrap_payload(
143    payload: AdminBootstrapPayload,
144    registry_url: String,
145) -> Result<AdminBootstrapResult> {
146    Ok(AdminBootstrapResult {
147        human: AdminHuman {
148            id: parse_non_empty(&payload.human.id, "human.id")?,
149            did: parse_non_empty(&payload.human.did, "human.did")?,
150            display_name: parse_non_empty(&payload.human.display_name, "human.displayName")?,
151            role: parse_non_empty(&payload.human.role, "human.role")?,
152            status: parse_non_empty(&payload.human.status, "human.status")?,
153        },
154        api_key: AdminApiKey {
155            id: parse_non_empty(&payload.api_key.id, "apiKey.id")?,
156            name: parse_non_empty(&payload.api_key.name, "apiKey.name")?,
157            token: parse_non_empty(&payload.api_key.token, "apiKey.token")?,
158        },
159        internal_service: AdminInternalService {
160            id: parse_non_empty(&payload.internal_service.id, "internalService.id")?,
161            name: parse_non_empty(&payload.internal_service.name, "internalService.name")?,
162        },
163        registry_url,
164    })
165}
166
167/// TODO(clawdentity): document `bootstrap_admin`.
168pub fn bootstrap_admin(
169    options: &ConfigPathOptions,
170    input: AdminBootstrapInput,
171) -> Result<AdminBootstrapResult> {
172    let config = resolve_config(options)?;
173    let registry_url = normalize_registry_url(
174        input
175            .registry_url
176            .as_deref()
177            .unwrap_or(config.registry_url.as_str()),
178    )?;
179    let bootstrap_secret = parse_non_empty(&input.bootstrap_secret, "bootstrapSecret")?;
180
181    let response = blocking_client()?
182        .post(to_request_url(&registry_url)?)
183        .header("x-bootstrap-secret", bootstrap_secret)
184        .header("content-type", "application/json")
185        .json(&serde_json::json!({
186            "displayName": input
187                .display_name
188                .as_deref()
189                .map(str::trim)
190                .filter(|value| !value.is_empty()),
191            "apiKeyName": input
192                .api_key_name
193                .as_deref()
194                .map(str::trim)
195                .filter(|value| !value.is_empty()),
196        }))
197        .send()
198        .map_err(|error| CoreError::Http(error.to_string()))?;
199
200    if !response.status().is_success() {
201        let status = response.status().as_u16();
202        let response_body = response.text().unwrap_or_default();
203        return Err(CoreError::HttpStatus {
204            status,
205            message: parse_error_message(&response_body),
206        });
207    }
208
209    let payload = response
210        .json::<AdminBootstrapPayload>()
211        .map_err(|error| CoreError::Http(error.to_string()))?;
212    parse_bootstrap_payload(payload, registry_url)
213}
214
215/// TODO(clawdentity): document `persist_bootstrap_config`.
216pub fn persist_bootstrap_config(
217    options: &ConfigPathOptions,
218    bootstrap: &AdminBootstrapResult,
219) -> Result<CliConfig> {
220    let mut config = read_config(options)?;
221    config.registry_url = normalize_registry_url(&bootstrap.registry_url)?;
222    config.api_key = Some(parse_non_empty(&bootstrap.api_key.token, "apiKey.token")?);
223    if !bootstrap.human.display_name.trim().is_empty() {
224        config.human_name = Some(bootstrap.human.display_name.trim().to_string());
225    }
226    let _ = write_config(&config, options)?;
227    Ok(config)
228}
229
230#[cfg(test)]
231mod tests {
232    use std::path::Path;
233
234    use tempfile::TempDir;
235    use wiremock::matchers::{header, method, path};
236    use wiremock::{Mock, MockServer, ResponseTemplate};
237
238    use crate::config::{CliConfig, ConfigPathOptions, read_config, write_config};
239
240    use super::{AdminBootstrapInput, bootstrap_admin, persist_bootstrap_config};
241
242    fn options(home: &Path) -> ConfigPathOptions {
243        ConfigPathOptions {
244            home_dir: Some(home.to_path_buf()),
245            registry_url_hint: None,
246        }
247    }
248
249    fn seed_config(home: &Path, registry_url: &str) {
250        let options = options(home);
251        let config = CliConfig {
252            registry_url: registry_url.to_string(),
253            proxy_url: None,
254            api_key: None,
255            human_name: None,
256        };
257        let _ = write_config(&config, &options).expect("write config");
258    }
259
260    #[tokio::test]
261    async fn bootstrap_and_persist_admin_config() {
262        let server = MockServer::start().await;
263        Mock::given(method("POST"))
264            .and(path("/v1/admin/bootstrap"))
265            .and(header("x-bootstrap-secret", "secret"))
266            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
267                "human": {
268                    "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
269                    "did": "did:cdi:registry.clawdentity.com:human:01HF7YAT31JZHSMW1CG6Q6MHB7",
270                    "displayName": "Alice",
271                    "role": "admin",
272                    "status": "active"
273                },
274                "apiKey": {
275                    "id": "01HF7YAT00W6W7CM7N3W5FDXT5",
276                    "name": "admin-cli",
277                    "token": "pat_admin"
278                },
279                "internalService": {
280                    "id": "01HF7YAT00W6W7CM7N3W5FDXT6",
281                    "name": "bootstrap-internal"
282                }
283            })))
284            .mount(&server)
285            .await;
286
287        let temp = TempDir::new().expect("temp dir");
288        seed_config(temp.path(), &server.uri());
289        let options = options(temp.path());
290
291        let bootstrap_options = options.clone();
292        let bootstrap = tokio::task::spawn_blocking(move || {
293            bootstrap_admin(
294                &bootstrap_options,
295                AdminBootstrapInput {
296                    bootstrap_secret: "secret".to_string(),
297                    display_name: Some("Alice".to_string()),
298                    api_key_name: Some("admin-cli".to_string()),
299                    registry_url: None,
300                },
301            )
302        })
303        .await
304        .expect("join")
305        .expect("bootstrap");
306        assert_eq!(bootstrap.api_key.token, "pat_admin");
307
308        let _ = persist_bootstrap_config(&options, &bootstrap).expect("persist");
309        let config = read_config(&options).expect("read");
310        assert_eq!(config.api_key.as_deref(), Some("pat_admin"));
311        assert_eq!(config.human_name.as_deref(), Some("Alice"));
312    }
313}