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
338pub 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
377pub 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
423pub 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}