//! `pic api-keys` — long-lived bearer-key management. //! //! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`): //! * `raw_token` is returned **once** on mint and never again. //! * `app_id` (optional `--app`) binds the key to one app; instance //! scopes (`instance:*`) are rejected when `--app` is also set. //! * `scopes` is a `text[]` in the wire form (`script:read`, …). use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use picloud_shared::Scope; use crate::client::{Client, MintApiKeyBody}; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; pub async fn mint( name: &str, scope_strs: &[String], app_ident: Option<&str>, expires: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let scopes = parse_scopes(scope_strs)?; let expires_at = expires.map(parse_expires).transpose()?; let app_id = match app_ident { Some(ident) => Some(client.apps_get(ident).await?.app.id), None => None, }; let body = MintApiKeyBody { name, scopes: &scopes, app_id, expires_at, }; let resp = client.apikeys_mint(&body).await?; let mut block = KvBlock::new(); block .field("id", resp.key.id.to_string()) .field("name", resp.key.name.clone()) .field("prefix", resp.key.prefix.clone()) .field( "scopes", resp.key .scopes .iter() .map(|s| s.as_str()) .collect::>() .join(","), ) .field( "app_id", resp.key .app_id .map(|a| a.to_string()) .unwrap_or_else(|| "-".into()), ) .field( "expires_at", resp.key .expires_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "-".into()), ) .field("token", resp.raw_token.clone()); block.print(mode); if matches!(mode, OutputMode::Tsv) { // The token row is human-easy-to-miss in a wall of metadata; // call it out exactly once on the human path. Skip on JSON // since machine consumers don't need the nudge. eprintln!("Save this token — it will not be shown again."); } Ok(()) } pub async fn ls(mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let keys = client.apikeys_list().await?; let mut table = Table::new([ "id", "name", "prefix", "scopes", "app_id", "expires_at", "last_used_at", "created_at", ]); for k in keys { table.row([ k.id.to_string(), k.name, k.prefix, k.scopes .iter() .map(|s| s.as_str()) .collect::>() .join(","), k.app_id .map(|a| a.to_string()) .unwrap_or_else(|| "-".into()), k.expires_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "-".into()), k.last_used_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "-".into()), k.created_at.to_rfc3339(), ]); } table.print(mode); Ok(()) } pub async fn rm(id: &str) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; client.apikeys_delete(id).await?; println!("Revoked api-key {id}"); Ok(()) } fn parse_scopes(raw: &[String]) -> Result> { if raw.is_empty() { return Err(anyhow!( "at least one `--scope` is required (e.g. --scope script:read)" )); } raw.iter() .map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}"))) .collect() } /// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a /// shorthand `d` / `h` / `m` (days / hours / minutes from now). /// Shorthand wins for the common "key good for 30 days" case; full /// RFC 3339 keeps the door open for precise cutoffs. fn parse_expires(raw: &str) -> Result> { if let Some(spec) = raw.strip_suffix('d') { let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?; return Ok(Utc::now() + chrono::Duration::days(days)); } if let Some(spec) = raw.strip_suffix('h') { let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?; return Ok(Utc::now() + chrono::Duration::hours(hours)); } if let Some(spec) = raw.strip_suffix('m') { let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?; return Ok(Utc::now() + chrono::Duration::minutes(mins)); } DateTime::parse_from_rfc3339(raw) .map(|d| d.with_timezone(&Utc)) .map_err(|e| anyhow!("expected RFC 3339 or `d/h/m`, got {raw:?}: {e}")) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_scopes_accepts_wire_form() { let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap(); assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]); } #[test] fn parse_scopes_rejects_empty() { let err = parse_scopes(&[]).unwrap_err(); assert!(format!("{err}").contains("at least one")); } #[test] fn parse_scopes_rejects_unknown() { let err = parse_scopes(&["script:nope".into()]).unwrap_err(); assert!(format!("{err}").contains("unknown scope")); } #[test] fn parse_expires_days_shorthand() { let d = parse_expires("7d").unwrap(); let diff = (d - Utc::now()).num_days(); assert!((6..=7).contains(&diff), "got {diff}"); } #[test] fn parse_expires_rfc3339_passes_through() { let d = parse_expires("2030-01-01T00:00:00Z").unwrap(); assert_eq!(d.timestamp(), 1893456000); } #[test] fn parse_expires_garbage_errors() { assert!(parse_expires("tomorrow").is_err()); } }