Skip to main content

clawdentity_core/registry/
invite.rs

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