Skip to main content

clawdentity_core/registry/
api_key.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::{ConfigPathOptions, resolve_config};
4use crate::error::{CoreError, Result};
5use crate::http::blocking_client;
6
7const ME_API_KEYS_PATH: &str = "/v1/me/api-keys";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ApiKeyMetadata {
12    pub id: String,
13    pub name: String,
14    pub status: String,
15    pub created_at: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub last_used_at: Option<String>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct ApiKeyWithToken {
23    pub id: String,
24    pub name: String,
25    pub status: String,
26    pub created_at: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub last_used_at: Option<String>,
29    pub token: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct ApiKeyCreateResult {
35    pub api_key: ApiKeyWithToken,
36    pub registry_url: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ApiKeyListResult {
42    pub api_keys: Vec<ApiKeyMetadata>,
43    pub registry_url: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ApiKeyRevokeResult {
49    pub api_key_id: String,
50    pub registry_url: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ApiKeyCreateInput {
55    pub name: Option<String>,
56    pub registry_url: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ApiKeyListInput {
61    pub registry_url: Option<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ApiKeyRevokeInput {
66    pub id: String,
67    pub registry_url: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct ApiKeyCreateResponse {
73    api_key: ApiKeyWithTokenPayload,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct ApiKeyListResponse {
79    api_keys: Vec<ApiKeyMetadataPayload>,
80}
81
82#[derive(Debug, Deserialize)]
83#[serde(rename_all = "camelCase")]
84struct ApiKeyWithTokenPayload {
85    id: String,
86    name: String,
87    status: String,
88    created_at: String,
89    last_used_at: Option<String>,
90    token: String,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(rename_all = "camelCase")]
95struct ApiKeyMetadataPayload {
96    id: String,
97    name: String,
98    status: String,
99    created_at: String,
100    last_used_at: Option<String>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct ErrorEnvelope {
106    error: Option<RegistryError>,
107}
108
109#[derive(Debug, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct RegistryError {
112    message: Option<String>,
113}
114
115#[derive(Debug, Clone)]
116struct ApiKeyRuntime {
117    registry_url: String,
118    api_key: String,
119}
120
121fn parse_non_empty(value: &str, field: &str) -> Result<String> {
122    let trimmed = value.trim();
123    if trimmed.is_empty() {
124        return Err(CoreError::InvalidInput(format!(
125            "{field} in API key response is invalid"
126        )));
127    }
128    Ok(trimmed.to_string())
129}
130
131fn parse_api_key_status(status: &str) -> Result<String> {
132    let normalized = status.trim();
133    if normalized.is_empty() {
134        return Err(CoreError::InvalidInput(
135            "status in API key response is invalid".to_string(),
136        ));
137    }
138    Ok(normalized.to_string())
139}
140
141fn normalize_registry_url(value: &str) -> Result<String> {
142    url::Url::parse(value.trim())
143        .map(|url| url.to_string())
144        .map_err(|_| CoreError::InvalidUrl {
145            context: "registryUrl",
146            value: value.to_string(),
147        })
148}
149
150fn resolve_runtime(
151    options: &ConfigPathOptions,
152    override_registry_url: Option<String>,
153) -> Result<ApiKeyRuntime> {
154    let config = resolve_config(options)?;
155    let registry_url = normalize_registry_url(
156        override_registry_url
157            .as_deref()
158            .unwrap_or(config.registry_url.as_str()),
159    )?;
160    let api_key = config
161        .api_key
162        .map(|value| value.trim().to_string())
163        .filter(|value| !value.is_empty())
164        .ok_or_else(|| {
165            CoreError::InvalidInput(
166                "API key is not configured. Use `config set apiKey <token>` first.".to_string(),
167            )
168        })?;
169
170    Ok(ApiKeyRuntime {
171        registry_url,
172        api_key,
173    })
174}
175
176fn to_api_key_request_url(registry_url: &str, api_key_id: Option<&str>) -> Result<String> {
177    let base = if registry_url.ends_with('/') {
178        registry_url.to_string()
179    } else {
180        format!("{registry_url}/")
181    };
182    let path = match api_key_id {
183        Some(id) => format!("{}/{}", ME_API_KEYS_PATH.trim_start_matches('/'), id),
184        None => ME_API_KEYS_PATH.trim_start_matches('/').to_string(),
185    };
186    let joined = url::Url::parse(&base)
187        .map_err(|_| CoreError::InvalidUrl {
188            context: "registryUrl",
189            value: registry_url.to_string(),
190        })?
191        .join(&path)
192        .map_err(|_| CoreError::InvalidUrl {
193            context: "registryUrl",
194            value: registry_url.to_string(),
195        })?;
196    Ok(joined.to_string())
197}
198
199fn parse_error_message(response_body: &str) -> String {
200    match serde_json::from_str::<ErrorEnvelope>(response_body) {
201        Ok(envelope) => envelope
202            .error
203            .and_then(|error| error.message)
204            .unwrap_or_else(|| response_body.to_string()),
205        Err(_) => response_body.to_string(),
206    }
207}
208
209fn parse_api_key_with_token(payload: ApiKeyWithTokenPayload) -> Result<ApiKeyWithToken> {
210    Ok(ApiKeyWithToken {
211        id: parse_non_empty(&payload.id, "id")?,
212        name: parse_non_empty(&payload.name, "name")?,
213        status: parse_api_key_status(&payload.status)?,
214        created_at: parse_non_empty(&payload.created_at, "createdAt")?,
215        last_used_at: payload
216            .last_used_at
217            .map(|value| value.trim().to_string())
218            .filter(|value| !value.is_empty()),
219        token: parse_non_empty(&payload.token, "token")?,
220    })
221}
222
223fn parse_api_key_metadata(payload: ApiKeyMetadataPayload) -> Result<ApiKeyMetadata> {
224    Ok(ApiKeyMetadata {
225        id: parse_non_empty(&payload.id, "id")?,
226        name: parse_non_empty(&payload.name, "name")?,
227        status: parse_api_key_status(&payload.status)?,
228        created_at: parse_non_empty(&payload.created_at, "createdAt")?,
229        last_used_at: payload
230            .last_used_at
231            .map(|value| value.trim().to_string())
232            .filter(|value| !value.is_empty()),
233    })
234}
235
236fn parse_api_key_id(value: &str) -> Result<String> {
237    let trimmed = value.trim();
238    if trimmed.is_empty() {
239        return Err(CoreError::InvalidInput(
240            "API key id is required".to_string(),
241        ));
242    }
243    ulid::Ulid::from_string(trimmed)
244        .map(|_| trimmed.to_string())
245        .map_err(|_| CoreError::InvalidInput("API key id must be a valid ULID".to_string()))
246}
247
248/// TODO(clawdentity): document `create_api_key`.
249pub fn create_api_key(
250    options: &ConfigPathOptions,
251    input: ApiKeyCreateInput,
252) -> Result<ApiKeyCreateResult> {
253    let runtime = resolve_runtime(options, input.registry_url)?;
254    let response = blocking_client()?
255        .post(to_api_key_request_url(&runtime.registry_url, None)?)
256        .header("authorization", format!("Bearer {}", runtime.api_key))
257        .header("content-type", "application/json")
258        .json(&serde_json::json!({
259            "name": input
260                .name
261                .as_deref()
262                .map(str::trim)
263                .filter(|value| !value.is_empty()),
264        }))
265        .send()
266        .map_err(|error| CoreError::Http(error.to_string()))?;
267
268    if !response.status().is_success() {
269        let status = response.status().as_u16();
270        let response_body = response.text().unwrap_or_default();
271        return Err(CoreError::HttpStatus {
272            status,
273            message: parse_error_message(&response_body),
274        });
275    }
276
277    let payload = response
278        .json::<ApiKeyCreateResponse>()
279        .map_err(|error| CoreError::Http(error.to_string()))?;
280    Ok(ApiKeyCreateResult {
281        api_key: parse_api_key_with_token(payload.api_key)?,
282        registry_url: runtime.registry_url,
283    })
284}
285
286/// TODO(clawdentity): document `list_api_keys`.
287pub fn list_api_keys(
288    options: &ConfigPathOptions,
289    input: ApiKeyListInput,
290) -> Result<ApiKeyListResult> {
291    let runtime = resolve_runtime(options, input.registry_url)?;
292    let response = blocking_client()?
293        .get(to_api_key_request_url(&runtime.registry_url, None)?)
294        .header("authorization", format!("Bearer {}", runtime.api_key))
295        .send()
296        .map_err(|error| CoreError::Http(error.to_string()))?;
297
298    if !response.status().is_success() {
299        let status = response.status().as_u16();
300        let response_body = response.text().unwrap_or_default();
301        return Err(CoreError::HttpStatus {
302            status,
303            message: parse_error_message(&response_body),
304        });
305    }
306
307    let payload = response
308        .json::<ApiKeyListResponse>()
309        .map_err(|error| CoreError::Http(error.to_string()))?;
310    let api_keys = payload
311        .api_keys
312        .into_iter()
313        .map(parse_api_key_metadata)
314        .collect::<Result<Vec<_>>>()?;
315    Ok(ApiKeyListResult {
316        api_keys,
317        registry_url: runtime.registry_url,
318    })
319}
320
321/// TODO(clawdentity): document `revoke_api_key`.
322pub fn revoke_api_key(
323    options: &ConfigPathOptions,
324    input: ApiKeyRevokeInput,
325) -> Result<ApiKeyRevokeResult> {
326    let runtime = resolve_runtime(options, input.registry_url)?;
327    let api_key_id = parse_api_key_id(&input.id)?;
328    let response = blocking_client()?
329        .delete(to_api_key_request_url(
330            &runtime.registry_url,
331            Some(&api_key_id),
332        )?)
333        .header("authorization", format!("Bearer {}", runtime.api_key))
334        .send()
335        .map_err(|error| CoreError::Http(error.to_string()))?;
336
337    if !response.status().is_success() {
338        let status = response.status().as_u16();
339        let response_body = response.text().unwrap_or_default();
340        return Err(CoreError::HttpStatus {
341            status,
342            message: parse_error_message(&response_body),
343        });
344    }
345
346    Ok(ApiKeyRevokeResult {
347        api_key_id,
348        registry_url: runtime.registry_url,
349    })
350}
351
352#[cfg(test)]
353mod tests {
354    use std::path::Path;
355
356    use tempfile::TempDir;
357    use wiremock::matchers::{header, method, path};
358    use wiremock::{Mock, MockServer, ResponseTemplate};
359
360    use crate::config::{CliConfig, ConfigPathOptions, write_config};
361
362    use super::{
363        ApiKeyCreateInput, ApiKeyListInput, ApiKeyMetadataPayload, ApiKeyRevokeInput,
364        create_api_key, list_api_keys, parse_api_key_metadata, revoke_api_key,
365    };
366
367    fn options(home: &Path) -> ConfigPathOptions {
368        ConfigPathOptions {
369            home_dir: Some(home.to_path_buf()),
370            registry_url_hint: None,
371        }
372    }
373
374    fn seed_config(home: &Path, registry_url: &str) {
375        let options = options(home);
376        let config = CliConfig {
377            registry_url: registry_url.to_string(),
378            proxy_url: None,
379            api_key: Some("pat_local".to_string()),
380            human_name: Some("alice".to_string()),
381        };
382        let _ = write_config(&config, &options).expect("write config");
383    }
384
385    #[tokio::test]
386    async fn create_list_and_revoke_api_key_round_trip() {
387        let server = MockServer::start().await;
388        Mock::given(method("POST"))
389            .and(path("/v1/me/api-keys"))
390            .and(header("authorization", "Bearer pat_local"))
391            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
392                "apiKey": {
393                    "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
394                    "name": "primary",
395                    "status": "active",
396                    "createdAt": "2030-01-01T00:00:00.000Z",
397                    "lastUsedAt": null,
398                    "token": "pat_123"
399                }
400            })))
401            .mount(&server)
402            .await;
403        Mock::given(method("GET"))
404            .and(path("/v1/me/api-keys"))
405            .and(header("authorization", "Bearer pat_local"))
406            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
407                "apiKeys": [{
408                    "id": "01HF7YAT00W6W7CM7N3W5FDXT4",
409                    "name": "primary",
410                    "status": "active",
411                    "createdAt": "2030-01-01T00:00:00.000Z",
412                    "lastUsedAt": "2030-01-02T00:00:00.000Z"
413                }]
414            })))
415            .mount(&server)
416            .await;
417        Mock::given(method("DELETE"))
418            .and(path("/v1/me/api-keys/01HF7YAT00W6W7CM7N3W5FDXT4"))
419            .and(header("authorization", "Bearer pat_local"))
420            .respond_with(ResponseTemplate::new(204))
421            .mount(&server)
422            .await;
423
424        let temp = TempDir::new().expect("temp dir");
425        seed_config(temp.path(), &server.uri());
426        let options = options(temp.path());
427
428        let create_options = options.clone();
429        let created = tokio::task::spawn_blocking(move || {
430            create_api_key(
431                &create_options,
432                ApiKeyCreateInput {
433                    name: Some("primary".to_string()),
434                    registry_url: None,
435                },
436            )
437        })
438        .await
439        .expect("join")
440        .expect("create");
441        assert_eq!(created.api_key.token, "pat_123");
442
443        let list_options = options.clone();
444        let listed = tokio::task::spawn_blocking(move || {
445            list_api_keys(&list_options, ApiKeyListInput { registry_url: None })
446        })
447        .await
448        .expect("join")
449        .expect("list");
450        assert_eq!(listed.api_keys.len(), 1);
451        assert_eq!(
452            listed.api_keys[0].last_used_at.as_deref(),
453            Some("2030-01-02T00:00:00.000Z")
454        );
455
456        let revoke_options = options.clone();
457        let revoked = tokio::task::spawn_blocking(move || {
458            revoke_api_key(
459                &revoke_options,
460                ApiKeyRevokeInput {
461                    id: "01HF7YAT00W6W7CM7N3W5FDXT4".to_string(),
462                    registry_url: None,
463                },
464            )
465        })
466        .await
467        .expect("join")
468        .expect("revoke");
469        assert_eq!(revoked.api_key_id, "01HF7YAT00W6W7CM7N3W5FDXT4");
470    }
471
472    #[test]
473    fn revoke_rejects_invalid_ulid() {
474        let temp = TempDir::new().expect("temp dir");
475        seed_config(temp.path(), "https://registry.example");
476        let options = options(temp.path());
477        let error = revoke_api_key(
478            &options,
479            ApiKeyRevokeInput {
480                id: "not-ulid".to_string(),
481                registry_url: None,
482            },
483        )
484        .expect_err("invalid id");
485        assert!(error.to_string().contains("valid ULID"));
486    }
487
488    #[test]
489    fn accepts_unknown_status_values_from_registry_payload() {
490        let parsed = parse_api_key_metadata(ApiKeyMetadataPayload {
491            id: "01HF7YAT00W6W7CM7N3W5FDXT4".to_string(),
492            name: "primary".to_string(),
493            status: "pending-review".to_string(),
494            created_at: "2030-01-01T00:00:00.000Z".to_string(),
495            last_used_at: None,
496        })
497        .expect("metadata parse");
498        assert_eq!(parsed.status, "pending-review");
499    }
500}