Skip to main content

clawdentity_core/registry/
invite.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 INVITES_PATH: &str = "/v1/invites";
8const INVITES_REDEEM_PATH: &str = "/v1/invites/redeem";
9const METADATA_PATH: &str = "/v1/metadata";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct InviteRecord {
14    pub code: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub id: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub created_at: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub expires_at: Option<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct InviteCreateResult {
26    pub invite: InviteRecord,
27    pub registry_url: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct InviteRedeemResult {
33    pub api_key_token: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub api_key_id: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub api_key_name: Option<String>,
38    pub human_name: String,
39    pub proxy_url: String,
40    pub registry_url: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct InviteCreateInput {
45    pub expires_at: Option<String>,
46    pub registry_url: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct InviteRedeemInput {
51    pub code: String,
52    pub display_name: String,
53    pub api_key_name: Option<String>,
54    pub registry_url: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58#[serde(rename_all = "camelCase")]
59struct InviteEnvelope {
60    invite: Option<InvitePayload>,
61    code: Option<String>,
62    id: Option<String>,
63    created_at: Option<String>,
64    expires_at: Option<String>,
65}
66
67#[derive(Debug, Deserialize)]
68#[serde(rename_all = "camelCase")]
69struct InvitePayload {
70    code: String,
71    id: Option<String>,
72    created_at: Option<String>,
73    expires_at: Option<String>,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct InviteRedeemResponse {
79    api_key: Option<InviteRedeemApiKey>,
80    token: Option<String>,
81    human: Option<InviteRedeemHuman>,
82    proxy_url: Option<String>,
83}
84
85#[derive(Debug, Deserialize)]
86#[serde(rename_all = "camelCase")]
87struct InviteRedeemApiKey {
88    id: Option<String>,
89    name: Option<String>,
90    token: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct InviteRedeemHuman {
96    display_name: Option<String>,
97}
98
99#[derive(Debug, Deserialize)]
100#[serde(rename_all = "camelCase")]
101struct RegistryMetadata {
102    proxy_url: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(rename_all = "camelCase")]
107struct ErrorEnvelope {
108    error: Option<RegistryError>,
109}
110
111#[derive(Debug, Deserialize)]
112#[serde(rename_all = "camelCase")]
113struct RegistryError {
114    message: Option<String>,
115}
116
117#[derive(Debug, Clone)]
118struct InviteRuntime {
119    registry_url: String,
120    config: CliConfig,
121}
122
123fn parse_non_empty(value: &str, field: &str) -> Result<String> {
124    let trimmed = value.trim();
125    if trimmed.is_empty() {
126        return Err(CoreError::InvalidInput(format!("{field} is required")));
127    }
128    Ok(trimmed.to_string())
129}
130
131fn normalize_registry_url(value: &str) -> Result<String> {
132    url::Url::parse(value.trim())
133        .map(|url| url.to_string())
134        .map_err(|_| CoreError::InvalidUrl {
135            context: "registryUrl",
136            value: value.to_string(),
137        })
138}
139
140fn normalize_proxy_url(value: &str) -> Result<String> {
141    let parsed = url::Url::parse(value.trim()).map_err(|_| {
142        CoreError::InvalidInput("invite redeem response proxyUrl is invalid".to_string())
143    })?;
144    if parsed.scheme() != "https" && parsed.scheme() != "http" {
145        return Err(CoreError::InvalidInput(
146            "invite redeem response proxyUrl is invalid".to_string(),
147        ));
148    }
149    Ok(parsed.to_string())
150}
151
152fn parse_error_message(response_body: &str) -> String {
153    match serde_json::from_str::<ErrorEnvelope>(response_body) {
154        Ok(envelope) => envelope
155            .error
156            .and_then(|error| error.message)
157            .unwrap_or_else(|| response_body.to_string()),
158        Err(_) => response_body.to_string(),
159    }
160}
161
162fn to_request_url(registry_url: &str, path: &str) -> Result<String> {
163    let base = if registry_url.ends_with('/') {
164        registry_url.to_string()
165    } else {
166        format!("{registry_url}/")
167    };
168    let joined = url::Url::parse(&base)
169        .map_err(|_| CoreError::InvalidUrl {
170            context: "registryUrl",
171            value: registry_url.to_string(),
172        })?
173        .join(path.trim_start_matches('/'))
174        .map_err(|_| CoreError::InvalidUrl {
175            context: "registryUrl",
176            value: registry_url.to_string(),
177        })?;
178    Ok(joined.to_string())
179}
180
181fn resolve_runtime(
182    options: &ConfigPathOptions,
183    override_registry_url: Option<String>,
184) -> Result<InviteRuntime> {
185    let config = resolve_config(options)?;
186    let registry_url = normalize_registry_url(
187        override_registry_url
188            .as_deref()
189            .unwrap_or(config.registry_url.as_str()),
190    )?;
191    Ok(InviteRuntime {
192        registry_url,
193        config,
194    })
195}
196
197fn require_api_key(config: &CliConfig) -> Result<String> {
198    config
199        .api_key
200        .as_deref()
201        .map(str::trim)
202        .filter(|value| !value.is_empty())
203        .map(ToOwned::to_owned)
204        .ok_or_else(|| {
205            CoreError::InvalidInput(
206                "API key is not configured. Use `config set apiKey <token>` first.".to_string(),
207            )
208        })
209}
210
211fn parse_invite_record(envelope: InviteEnvelope) -> Result<InviteRecord> {
212    if let Some(invite) = envelope.invite {
213        return Ok(InviteRecord {
214            code: parse_non_empty(&invite.code, "invite.code")?,
215            id: invite
216                .id
217                .map(|value| value.trim().to_string())
218                .filter(|value| !value.is_empty()),
219            created_at: invite
220                .created_at
221                .map(|value| value.trim().to_string())
222                .filter(|value| !value.is_empty()),
223            expires_at: invite
224                .expires_at
225                .map(|value| value.trim().to_string())
226                .filter(|value| !value.is_empty()),
227        });
228    }
229
230    Ok(InviteRecord {
231        code: parse_non_empty(envelope.code.as_deref().unwrap_or_default(), "invite.code")?,
232        id: envelope
233            .id
234            .map(|value| value.trim().to_string())
235            .filter(|value| !value.is_empty()),
236        created_at: envelope
237            .created_at
238            .map(|value| value.trim().to_string())
239            .filter(|value| !value.is_empty()),
240        expires_at: envelope
241            .expires_at
242            .map(|value| value.trim().to_string())
243            .filter(|value| !value.is_empty()),
244    })
245}
246
247#[allow(clippy::too_many_lines)]
248fn parse_redeem_result(
249    registry_url: &str,
250    payload: InviteRedeemResponse,
251    fallback_proxy_url: Option<String>,
252) -> Result<InviteRedeemResult> {
253    let token = payload
254        .api_key
255        .as_ref()
256        .and_then(|api_key| api_key.token.as_ref())
257        .map(|value| value.trim().to_string())
258        .filter(|value| !value.is_empty())
259        .or_else(|| {
260            payload
261                .token
262                .as_ref()
263                .map(|value| value.trim().to_string())
264                .filter(|value| !value.is_empty())
265        })
266        .ok_or_else(|| CoreError::InvalidInput("invite redeem response is invalid".to_string()))?;
267
268    let human_name = payload
269        .human
270        .as_ref()
271        .and_then(|human| human.display_name.as_ref())
272        .map(|value| value.trim().to_string())
273        .filter(|value| !value.is_empty())
274        .ok_or_else(|| CoreError::InvalidInput("invite redeem response is invalid".to_string()))?;
275
276    let proxy_url = payload
277        .proxy_url
278        .map(|value| value.trim().to_string())
279        .filter(|value| !value.is_empty())
280        .or(fallback_proxy_url)
281        .ok_or_else(|| CoreError::InvalidInput("invite redeem response is invalid".to_string()))?;
282
283    Ok(InviteRedeemResult {
284        api_key_token: token,
285        api_key_id: payload
286            .api_key
287            .as_ref()
288            .and_then(|api_key| api_key.id.as_ref())
289            .map(|value| value.trim().to_string())
290            .filter(|value| !value.is_empty()),
291        api_key_name: payload
292            .api_key
293            .as_ref()
294            .and_then(|api_key| api_key.name.as_ref())
295            .map(|value| value.trim().to_string())
296            .filter(|value| !value.is_empty()),
297        human_name,
298        proxy_url: normalize_proxy_url(&proxy_url)?,
299        registry_url: registry_url.to_string(),
300    })
301}
302
303fn fetch_proxy_url_from_metadata(registry_url: &str) -> Result<Option<String>> {
304    let request_url = to_request_url(registry_url, METADATA_PATH)?;
305    let client = match blocking_client() {
306        Ok(client) => client,
307        Err(error) => {
308            tracing::warn!(%registry_url, error = %error, "invite metadata client setup failed");
309            return Ok(None);
310        }
311    };
312    let response = match client.get(&request_url).send() {
313        Ok(response) => response,
314        Err(error) => {
315            tracing::warn!(%request_url, error = %error, "invite metadata request failed");
316            return Ok(None);
317        }
318    };
319
320    if !response.status().is_success() {
321        return Ok(None);
322    }
323
324    let payload = match response.json::<RegistryMetadata>() {
325        Ok(payload) => payload,
326        Err(error) => {
327            tracing::warn!(%request_url, error = %error, "invite metadata parse failed");
328            return Ok(None);
329        }
330    };
331    Ok(payload
332        .proxy_url
333        .map(|value| value.trim().to_string())
334        .filter(|value| !value.is_empty()))
335}
336
337/// TODO(clawdentity): document `create_invite`.
338pub fn create_invite(
339    options: &ConfigPathOptions,
340    input: InviteCreateInput,
341) -> Result<InviteCreateResult> {
342    let runtime = resolve_runtime(options, input.registry_url)?;
343    let api_key = require_api_key(&runtime.config)?;
344    let response = blocking_client()?
345        .post(to_request_url(&runtime.registry_url, INVITES_PATH)?)
346        .header("authorization", format!("Bearer {api_key}"))
347        .header("content-type", "application/json")
348        .json(&serde_json::json!({
349            "expiresAt": input
350                .expires_at
351                .as_deref()
352                .map(str::trim)
353                .filter(|value| !value.is_empty()),
354        }))
355        .send()
356        .map_err(|error| CoreError::Http(error.to_string()))?;
357
358    if !response.status().is_success() {
359        let status = response.status().as_u16();
360        let response_body = response.text().unwrap_or_default();
361        return Err(CoreError::HttpStatus {
362            status,
363            message: parse_error_message(&response_body),
364        });
365    }
366
367    let payload = response
368        .json::<InviteEnvelope>()
369        .map_err(|error| CoreError::Http(error.to_string()))?;
370    Ok(InviteCreateResult {
371        invite: parse_invite_record(payload)?,
372        registry_url: runtime.registry_url,
373    })
374}
375
376/// TODO(clawdentity): document `redeem_invite`.
377pub fn redeem_invite(
378    options: &ConfigPathOptions,
379    input: InviteRedeemInput,
380) -> Result<InviteRedeemResult> {
381    let runtime = resolve_runtime(options, input.registry_url)?;
382    let invite_code = parse_non_empty(&input.code, "code")?;
383    let display_name = parse_non_empty(&input.display_name, "displayName")?;
384
385    let response = blocking_client()?
386        .post(to_request_url(&runtime.registry_url, INVITES_REDEEM_PATH)?)
387        .header("content-type", "application/json")
388        .json(&serde_json::json!({
389            "code": invite_code,
390            "displayName": display_name,
391            "apiKeyName": input
392                .api_key_name
393                .as_deref()
394                .map(str::trim)
395                .filter(|value| !value.is_empty()),
396        }))
397        .send()
398        .map_err(|error| CoreError::Http(error.to_string()))?;
399
400    if !response.status().is_success() {
401        let status = response.status().as_u16();
402        let response_body = response.text().unwrap_or_default();
403        return Err(CoreError::HttpStatus {
404            status,
405            message: parse_error_message(&response_body),
406        });
407    }
408
409    let payload = response
410        .json::<InviteRedeemResponse>()
411        .map_err(|error| CoreError::Http(error.to_string()))?;
412    let fallback_proxy = fetch_proxy_url_from_metadata(&runtime.registry_url)?;
413    parse_redeem_result(&runtime.registry_url, payload, fallback_proxy)
414}
415
416/// TODO(clawdentity): document `persist_redeem_config`.
417pub fn persist_redeem_config(
418    options: &ConfigPathOptions,
419    redeem: &InviteRedeemResult,
420) -> Result<CliConfig> {
421    let mut config = read_config(options)?;
422    config.registry_url = normalize_registry_url(&redeem.registry_url)?;
423    config.api_key = Some(parse_non_empty(&redeem.api_key_token, "apiKeyToken")?);
424    config.proxy_url = Some(normalize_proxy_url(&redeem.proxy_url)?);
425    config.human_name = Some(parse_non_empty(&redeem.human_name, "humanName")?);
426    let _ = write_config(&config, options)?;
427    Ok(config)
428}
429
430#[cfg(test)]
431mod tests {
432    use std::path::Path;
433
434    use tempfile::TempDir;
435    use wiremock::matchers::{header, method, path};
436    use wiremock::{Mock, MockServer, ResponseTemplate};
437
438    use crate::config::{CliConfig, ConfigPathOptions, read_config, write_config};
439
440    use super::{
441        InviteCreateInput, InviteRedeemInput, create_invite, persist_redeem_config, redeem_invite,
442    };
443
444    fn options(home: &Path) -> ConfigPathOptions {
445        ConfigPathOptions {
446            home_dir: Some(home.to_path_buf()),
447            registry_url_hint: None,
448        }
449    }
450
451    fn seed_config(home: &Path, registry_url: &str, api_key: Option<&str>) {
452        let options = options(home);
453        let config = CliConfig {
454            registry_url: registry_url.to_string(),
455            proxy_url: None,
456            api_key: api_key.map(ToOwned::to_owned),
457            human_name: None,
458        };
459        let _ = write_config(&config, &options).expect("write config");
460    }
461
462    #[tokio::test]
463    async fn create_invite_uses_local_api_key() {
464        let server = MockServer::start().await;
465        Mock::given(method("POST"))
466            .and(path("/v1/invites"))
467            .and(header("authorization", "Bearer pat_local"))
468            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
469                "invite": {
470                    "code": "invite_123",
471                    "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
472                    "createdAt": "2030-01-01T00:00:00.000Z",
473                    "expiresAt": null
474                }
475            })))
476            .mount(&server)
477            .await;
478
479        let temp = TempDir::new().expect("temp dir");
480        seed_config(temp.path(), &server.uri(), Some("pat_local"));
481
482        let create_options = options(temp.path());
483        let created = tokio::task::spawn_blocking(move || {
484            create_invite(
485                &create_options,
486                InviteCreateInput {
487                    expires_at: None,
488                    registry_url: None,
489                },
490            )
491        })
492        .await
493        .expect("join")
494        .expect("create invite");
495        assert_eq!(created.invite.code, "invite_123");
496    }
497
498    #[tokio::test]
499    async fn redeem_invite_uses_registry_metadata_proxy_and_persists_config() {
500        let server = MockServer::start().await;
501        Mock::given(method("POST"))
502            .and(path("/v1/invites/redeem"))
503            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
504                "apiKey": {
505                    "id": "01HF7YAT00W6W7CM7N3W5FDXT5",
506                    "name": "cli-onboard",
507                    "token": "pat_onboard"
508                },
509                "human": {
510                    "displayName": "Alice"
511                }
512            })))
513            .mount(&server)
514            .await;
515        Mock::given(method("GET"))
516            .and(path("/v1/metadata"))
517            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
518                "proxyUrl": "https://proxy.example"
519            })))
520            .mount(&server)
521            .await;
522
523        let temp = TempDir::new().expect("temp dir");
524        seed_config(temp.path(), &server.uri(), None);
525        let options = options(temp.path());
526
527        let redeem_options = options.clone();
528        let redeemed = tokio::task::spawn_blocking(move || {
529            redeem_invite(
530                &redeem_options,
531                InviteRedeemInput {
532                    code: "invite_123".to_string(),
533                    display_name: "Alice".to_string(),
534                    api_key_name: Some("cli-onboard".to_string()),
535                    registry_url: None,
536                },
537            )
538        })
539        .await
540        .expect("join")
541        .expect("redeem invite");
542        assert_eq!(redeemed.api_key_token, "pat_onboard");
543        assert_eq!(redeemed.proxy_url, "https://proxy.example/");
544
545        let _ = persist_redeem_config(&options, &redeemed).expect("persist");
546        let config = read_config(&options).expect("config");
547        assert_eq!(config.api_key.as_deref(), Some("pat_onboard"));
548        assert_eq!(config.human_name.as_deref(), Some("Alice"));
549    }
550}