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
337pub 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
376pub 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
416pub 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}