diff --git a/Cargo.lock b/Cargo.lock index 9bab3f5..f0f2950 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,7 @@ dependencies = [ "dotenvy", "libc", "memorylake-core", + "serde", "serde_json", "tracing", "tracing-subscriber", diff --git a/README.md b/README.md index e892b37..1610d9d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ after it. Pass it explicitly to override for a single command. ## Commands Aliases: `ws` = `workspace`, `proj` = `project`, `lib` = `library`, -`doc` = `document`, `conv` = `conversation`, `msg` = `message`. +`doc` = `document`, `conv` = `conversation`, `msg` = `message`, +`key` = `api-key`, `invite` = `invitation`. ### Auth @@ -340,6 +341,48 @@ ranked list. Filters take one comma-separated value each (`--projects a,b`), and omitting a filter searches everything in that dimension. `--top-k` caps results per type. There is no pagination. +### Team management + +The team your API key belongs to — its API keys, members, invitations and +usage — is managed with the same key and endpoint as everything above. The team +is fixed by the key: nothing here takes a team parameter, and each command is +authorized by what the key's creator may do in the console. + +```bash +memorylake team get +memorylake team rename --name "New Name" # owner only + +memorylake key list [--name FUZZY] [--page-size N] [--continuation-token TOKEN] +memorylake key get +memorylake key create --name ci [--member ] [--expires-at UNIX_SECONDS] +memorylake key rotate +memorylake key revoke + +memorylake member list [--name FUZZY] [--page-size N] +memorylake member create --name "CI Bot" --role tenant_member # virtual member +memorylake member set-role --role tenant_admin +memorylake member remove + +memorylake invite create --email person@example.com --role tenant_member +memorylake invite list [--status pending|accepted|rejected|expired|revoked] +memorylake invite revoke + +memorylake usage [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] +``` + +`key create` and `key rotate` print the full key **exactly once** — list and get +only ever return its prefix, and an idempotent replay omits it too, so capture +it from the first response. + +A *virtual member* is a login-less identity for automations: create one with +`member create`, then issue it a key with `key create --member `. +That key acts with the virtual member's role instead of yours, so a CI job can +hold exactly the permissions it needs. + +Every write takes `--idempotency-key VALUE`. Retrying with the same value +replays the first result instead of repeating the write — no duplicate key, +member, or invitation email. + ## Configuration Credentials and settings live in `~/.memorylake/` (`credentials.toml`, diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 441c429..77c7b76 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,6 +18,7 @@ ctrlc = { workspace = true } dialoguer = { workspace = true } libc = { workspace = true } memorylake-core = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/cli/src/commands/api_key.rs b/crates/cli/src/commands/api_key.rs new file mode 100644 index 0000000..f500714 --- /dev/null +++ b/crates/cli/src/commands/api_key.rs @@ -0,0 +1,134 @@ +//! `memorylake api-key` / `key` commands. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use memorylake_core::api::admin::{ + CreateApiKeyRequest, ListParams, create_api_key, get_api_key, list_api_keys, revoke_api_key, + rotate_api_key, +}; + +use super::{api_client, print_json}; + +/// API key subcommands. +#[derive(Debug, Subcommand)] +pub enum ApiKeyCommand { + /// List the team's API keys. The keys themselves are never returned — + /// only their prefixes. + List { + /// Number of items per page. + #[arg(long)] + page_size: Option, + /// Continuation token from a previous response. + #[arg(long)] + continuation_token: Option, + /// Fuzzy filter by key name (partial match). + #[arg(long = "name")] + name_fuzzy: Option, + }, + /// Get a single API key by id. + Get { + /// API key id. + id: String, + }, + /// Create an API key. The full key is printed exactly once — save it. + Create { + /// Display name for the key. + #[arg(long)] + name: String, + /// Issue the key for this virtual member (see `member create`); the + /// key then acts as that member. Human members cannot be targeted. + #[arg(long = "member", value_name = "PRINCIPAL_ID")] + member_principal_id: Option, + /// Expiry, Unix seconds. Omit for a key that never expires. + #[arg(long)] + expires_at: Option, + /// Retrying with the same value replays the first result instead of + /// creating a second key. + #[arg(long)] + idempotency_key: Option, + }, + /// Replace a key's material and print the new value once. The previous + /// value stops working immediately. + Rotate { + /// API key id. + id: String, + /// Retrying with the same value replays the first result instead of + /// minting a second key. + #[arg(long)] + idempotency_key: Option, + }, + /// Delete an API key. The key making the request cannot revoke itself. + Revoke { + /// API key id. + id: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, +} + +/// Execute an `api-key` subcommand. +pub fn run( + command: ApiKeyCommand, + profile: Option, + base_url: Option, +) -> Result<()> { + let client = api_client(profile, base_url)?; + + match command { + ApiKeyCommand::List { + page_size, + continuation_token, + name_fuzzy, + } => { + let data = list_api_keys( + &client, + &ListParams { + page_size, + continuation_token, + name_fuzzy, + }, + ) + .context("list API keys")?; + print_json(&data) + } + ApiKeyCommand::Get { id } => { + let data = get_api_key(&client, &id).context("get API key")?; + print_json(&data) + } + ApiKeyCommand::Create { + name, + member_principal_id, + expires_at, + idempotency_key, + } => { + let data = create_api_key( + &client, + &CreateApiKeyRequest { + name, + member_principal_id, + expires_at, + }, + idempotency_key.as_deref(), + ) + .context("create API key")?; + print_json(&data) + } + ApiKeyCommand::Rotate { + id, + idempotency_key, + } => { + let data = rotate_api_key(&client, &id, idempotency_key.as_deref()) + .context("rotate API key")?; + print_json(&data) + } + ApiKeyCommand::Revoke { + id, + idempotency_key, + } => { + revoke_api_key(&client, &id, idempotency_key.as_deref()).context("revoke API key")?; + println!("API key {id} revoked"); + Ok(()) + } + } +} diff --git a/crates/cli/src/commands/invitation.rs b/crates/cli/src/commands/invitation.rs new file mode 100644 index 0000000..a606ace --- /dev/null +++ b/crates/cli/src/commands/invitation.rs @@ -0,0 +1,100 @@ +//! `memorylake invitation` / `invite` commands. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use memorylake_core::api::admin::{ + CreateInvitationRequest, ListInvitationsParams, create_invitation, list_invitations, + revoke_invitation, +}; + +use super::{api_client, print_json}; + +/// Invitation subcommands. +#[derive(Debug, Subcommand)] +pub enum InvitationCommand { + /// Invite someone to the team by email. One live invitation per address; + /// re-inviting is revoke + create. + Create { + /// Invitee email address. + #[arg(long)] + email: String, + /// Role on acceptance: tenant_admin, tenant_member, or a custom role + /// key. + #[arg(long)] + role: String, + /// Retrying with the same value replays the first result instead of + /// sending a second email. + #[arg(long)] + idempotency_key: Option, + }, + /// List the team's invitations, newest first. + List { + /// Number of items per page. + #[arg(long)] + page_size: Option, + /// Continuation token from a previous response. + #[arg(long)] + continuation_token: Option, + /// Only this state: pending, accepted, rejected, expired, revoked. + #[arg(long)] + status: Option, + }, + /// Revoke a pending invitation; its email link stops working. + Revoke { + /// Invitation id. + id: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, +} + +/// Execute an `invitation` subcommand. +pub fn run( + command: InvitationCommand, + profile: Option, + base_url: Option, +) -> Result<()> { + let client = api_client(profile, base_url)?; + + match command { + InvitationCommand::Create { + email, + role, + idempotency_key, + } => { + let data = create_invitation( + &client, + &CreateInvitationRequest { email, role }, + idempotency_key.as_deref(), + ) + .context("create invitation")?; + print_json(&data) + } + InvitationCommand::List { + page_size, + continuation_token, + status, + } => { + let data = list_invitations( + &client, + &ListInvitationsParams { + page_size, + continuation_token, + status, + }, + ) + .context("list invitations")?; + print_json(&data) + } + InvitationCommand::Revoke { + id, + idempotency_key, + } => { + revoke_invitation(&client, &id, idempotency_key.as_deref()) + .context("revoke invitation")?; + println!("invitation {id} revoked"); + Ok(()) + } + } +} diff --git a/crates/cli/src/commands/member.rs b/crates/cli/src/commands/member.rs new file mode 100644 index 0000000..20c87e5 --- /dev/null +++ b/crates/cli/src/commands/member.rs @@ -0,0 +1,123 @@ +//! `memorylake member` commands. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use memorylake_core::api::admin::{ + CreateMemberRequest, ListParams, create_member, list_members, remove_member, set_member_role, +}; + +use super::{api_client, print_json}; + +/// Member subcommands. +#[derive(Debug, Subcommand)] +pub enum MemberCommand { + /// List the team roster. Contact details appear only for owners/admins. + List { + /// Number of items per page. + #[arg(long)] + page_size: Option, + /// Continuation token from a previous response. + #[arg(long)] + continuation_token: Option, + /// Fuzzy filter by display name (owners/admins also match email and + /// username). + #[arg(long = "name")] + name_fuzzy: Option, + }, + /// Create a virtual member: a login-less identity that acts only through + /// API keys issued for it (`api-key create --member`). + Create { + /// Display name of the virtual member. + #[arg(long)] + name: String, + /// Role: tenant_admin, tenant_member, or a custom role key. + #[arg(long)] + role: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, + /// Change a member's role. The owner role cannot be assigned this way. + SetRole { + /// Member principal id (from `member list`). + principal_id: String, + /// New role: tenant_admin, tenant_member, or a custom role key. + #[arg(long)] + role: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, + /// Remove a member. Their API keys in this team are disabled, not + /// deleted. The owner cannot be removed; neither can yourself. + Remove { + /// Member principal id (from `member list`). + principal_id: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, +} + +/// Execute a `member` subcommand. +pub fn run( + command: MemberCommand, + profile: Option, + base_url: Option, +) -> Result<()> { + let client = api_client(profile, base_url)?; + + match command { + MemberCommand::List { + page_size, + continuation_token, + name_fuzzy, + } => { + let data = list_members( + &client, + &ListParams { + page_size, + continuation_token, + name_fuzzy, + }, + ) + .context("list members")?; + print_json(&data) + } + MemberCommand::Create { + name, + role, + idempotency_key, + } => { + let data = create_member( + &client, + &CreateMemberRequest { + display_name: name, + role, + }, + idempotency_key.as_deref(), + ) + .context("create virtual member")?; + print_json(&data) + } + MemberCommand::SetRole { + principal_id, + role, + idempotency_key, + } => { + set_member_role(&client, &principal_id, &role, idempotency_key.as_deref()) + .context("change member role")?; + println!("member {principal_id} now has role {role}"); + Ok(()) + } + MemberCommand::Remove { + principal_id, + idempotency_key, + } => { + remove_member(&client, &principal_id, idempotency_key.as_deref()) + .context("remove member")?; + println!("member {principal_id} removed"); + Ok(()) + } + } +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index dbd8060..a89b6c4 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,17 +2,39 @@ pub mod actor; pub mod agent; +pub mod api_key; pub mod auth; pub mod conversation; pub mod fact; +pub mod invitation; pub mod library; +pub mod member; pub mod project; pub mod search; +pub mod team; +pub mod usage; pub mod workspace; -use anyhow::{Result, bail}; -use memorylake_core::{Paths, resolve_profile_workspace}; +use anyhow::{Context, Result, bail}; +use memorylake_core::{Client, Paths, ResolveOverrides, resolve, resolve_profile_workspace}; + +/// Resolve credentials and build the authenticated API client. +/// +/// The team-management commands share this instead of each repeating the +/// paths → resolve → client chain the older command modules carry inline. +pub fn api_client(profile: Option, base_url: Option) -> Result { + let paths = Paths::default_home().context("resolve MemoryLake config paths")?; + let runtime = resolve(&paths, &ResolveOverrides { profile, base_url }) + .context("resolve API credentials")?; + Client::new(&runtime.base_url, &runtime.api_key).context("build API client") +} + +/// Print an API payload the way every command here does: pretty JSON. +pub fn print_json(data: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(data)?); + Ok(()) +} /// Resolve the workspace a command should act on. /// diff --git a/crates/cli/src/commands/team.rs b/crates/cli/src/commands/team.rs new file mode 100644 index 0000000..932e580 --- /dev/null +++ b/crates/cli/src/commands/team.rs @@ -0,0 +1,43 @@ +//! `memorylake team` commands. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use memorylake_core::api::admin::{get_team, rename_team}; + +use super::{api_client, print_json}; + +/// Team subcommands. +#[derive(Debug, Subcommand)] +pub enum TeamCommand { + /// Show the team this API key belongs to. + Get, + /// Rename the team. Only the team owner may do this. + Rename { + /// New display name. + #[arg(long)] + name: String, + /// Retrying with the same value replays the first result. + #[arg(long)] + idempotency_key: Option, + }, +} + +/// Execute a `team` subcommand. +pub fn run(command: TeamCommand, profile: Option, base_url: Option) -> Result<()> { + let client = api_client(profile, base_url)?; + + match command { + TeamCommand::Get => { + let data = get_team(&client).context("get the team")?; + print_json(&data) + } + TeamCommand::Rename { + name, + idempotency_key, + } => { + let data = rename_team(&client, &name, idempotency_key.as_deref()) + .context("rename the team")?; + print_json(&data) + } + } +} diff --git a/crates/cli/src/commands/usage.rs b/crates/cli/src/commands/usage.rs new file mode 100644 index 0000000..a91ba10 --- /dev/null +++ b/crates/cli/src/commands/usage.rs @@ -0,0 +1,35 @@ +//! `memorylake usage` command. + +use anyhow::{Context, Result}; +use clap::Args; +use memorylake_core::api::admin::{GetUsageParams, get_usage}; + +use super::{api_client, print_json}; + +/// Show the team's quota snapshot and consumption over a period. +/// +/// The quota is a snapshot taken now; the totals cover the requested period +/// (at most 92 days, default the last 7 days). +#[derive(Debug, Args)] +pub struct UsageArgs { + /// First day to include, YYYY-MM-DD. Defaults to six days before the end. + #[arg(long)] + start_date: Option, + /// Last day to include, YYYY-MM-DD. Defaults to today. + #[arg(long)] + end_date: Option, +} + +/// Execute the `usage` command. +pub fn run(args: UsageArgs, profile: Option, base_url: Option) -> Result<()> { + let client = api_client(profile, base_url)?; + let data = get_usage( + &client, + &GetUsageParams { + start_date: args.start_date, + end_date: args.end_date, + }, + ) + .context("get usage")?; + print_json(&data) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8509dfd..7c97aa9 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,12 +10,17 @@ use tracing_subscriber::EnvFilter; use commands::actor::{ActorCommand, run as run_actor}; use commands::agent::{AgentCommand, run as run_agent}; +use commands::api_key::{ApiKeyCommand, run as run_api_key}; use commands::auth::{AuthCommand, run as run_auth}; use commands::conversation::{ConversationCommand, run as run_conversation}; use commands::fact::{FactCommand, run as run_fact}; +use commands::invitation::{InvitationCommand, run as run_invitation}; use commands::library::{LibraryCommand, run as run_library}; +use commands::member::{MemberCommand, run as run_member}; use commands::project::{ProjectCommand, run as run_project}; use commands::search::{SearchArgs, run as run_search}; +use commands::team::{TeamCommand, run as run_team}; +use commands::usage::{UsageArgs, run as run_usage}; use commands::workspace::{WorkspaceCommand, run as run_workspace}; /// What `--version` and `version` report. @@ -101,6 +106,30 @@ enum Commands { }, /// Search memories in a workspace. Search(SearchArgs), + /// Show and rename the team this API key belongs to. + Team { + #[command(subcommand)] + command: TeamCommand, + }, + /// Manage the team's API keys. + #[command(visible_alias = "key")] + ApiKey { + #[command(subcommand)] + command: ApiKeyCommand, + }, + /// Manage the team roster and virtual members. + Member { + #[command(subcommand)] + command: MemberCommand, + }, + /// Invite people to the team and manage pending invitations. + #[command(visible_alias = "invite")] + Invitation { + #[command(subcommand)] + command: InvitationCommand, + }, + /// Show the team's quota and usage. + Usage(UsageArgs), /// Print the CLI version. Version, } @@ -119,6 +148,11 @@ fn main() -> Result<()> { Commands::Conversation { command } => run_conversation(command, cli.profile, cli.base_url)?, Commands::Fact { command } => run_fact(command, cli.profile, cli.base_url)?, Commands::Search(args) => run_search(args, cli.profile, cli.base_url)?, + Commands::Team { command } => run_team(command, cli.profile, cli.base_url)?, + Commands::ApiKey { command } => run_api_key(command, cli.profile, cli.base_url)?, + Commands::Member { command } => run_member(command, cli.profile, cli.base_url)?, + Commands::Invitation { command } => run_invitation(command, cli.profile, cli.base_url)?, + Commands::Usage(args) => run_usage(args, cli.profile, cli.base_url)?, Commands::Version => { println!("{VERSION}"); } diff --git a/crates/cli/tests/admin/live.rs b/crates/cli/tests/admin/live.rs new file mode 100644 index 0000000..8973d25 --- /dev/null +++ b/crates/cli/tests/admin/live.rs @@ -0,0 +1,614 @@ +//! Live team-management tests (require `MEMORYLAKE_API_KEY`). +//! +//! The key must belong to a team OWNER on an org team: `team rename` is +//! owner-only, and virtual members cannot exist on a personal team. +//! +//! These assert SEMANTICS, not just exit codes: a minted key must actually +//! authenticate, a rotated-away key must actually stop working, a role change +//! must be visible on the roster, and a virtual member's key must act with +//! that member's identity and limits. +//! +//! Invitation writes are OPT-IN: creating one sends a real email and spends +//! the team's daily invitation cap, so that test only runs when +//! MEMORYLAKE_INVITE_EMAIL supplies an inbox (env or .env, never committed) +//! and skips silently otherwise. +//! +//! Every object these tests create carries a unique `mlcli-` name and is +//! removed before the test ends; an assertion failure mid-test can leave at +//! most one scratch key or virtual member behind, named clearly enough to +//! delete by hand. + +use std::fs; +use std::path::Path; +use std::process::Output; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::common::{ + assert_failure, assert_success, live_base_url, login_args, require_api_key, run, temp_home, + unique_name, +}; + +/// Log `api_key` in under `profile`, returning the raw process output. +/// +/// `auth login` VALIDATES the key against the API before storing it, which is +/// exactly what makes it the probe for "does this key still authenticate?". +fn login(home: &Path, api_key: &str, profile: &str) -> Output { + let base_url = live_base_url(); + let args = login_args(api_key, profile, base_url.as_deref()); + run(home, &args) +} + +fn login_default(home: &Path, api_key: &str) { + assert_success(&login(home, api_key, "default"), &["auth", "login"]); +} + +/// Assert that `api_key` authenticates, and return the team it sees. +/// +/// ⚠️ Runs in a THROWAWAY home. `auth login` switches the active profile to +/// the profile it just wrote, so probing a freshly minted key inside the +/// test's main home would silently re-identify every later bare command as +/// that key — which is how an owner-side `member remove` once became the +/// member removing itself. +fn assert_key_authenticates(api_key: &str) -> serde_json::Value { + let probe_home = temp_home(); + assert_success(&login(&probe_home, api_key, "probe"), &["login probe"]); + let args = ["team", "get"]; + let stdout = assert_success(&run(&probe_home, &args), &args); + let team = parse(&stdout, "team via probed key"); + let _ = fs::remove_dir_all(&probe_home); + team +} + +/// Assert that `api_key` no longer authenticates (login validates the key). +fn assert_key_rejected(api_key: &str) { + let probe_home = temp_home(); + assert_failure(&login(&probe_home, api_key, "probe"), &["login probe"]); + let _ = fs::remove_dir_all(&probe_home); +} + +/// Parse a command's pretty-JSON stdout. +fn parse(stdout: &str, what: &str) -> serde_json::Value { + serde_json::from_str(stdout).unwrap_or_else(|err| panic!("parse {what} JSON ({err}): {stdout}")) +} + +fn str_field(value: &serde_json::Value, field: &str) -> String { + value + .get(field) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("response missing `{field}`: {value}")) + .to_string() +} + +fn unix_now() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() +} + +/// Report whether the logged-in key's team is a personal (single-user) team. +/// +/// The suite runs against whatever team the CI secret or the operator's key +/// belongs to, and two of the endpoints are defined to REFUSE personal teams. +/// Tests covering those branch on this instead of assuming an org. +fn team_is_personal(home: &Path) -> bool { + let args = ["team", "get"]; + let stdout = assert_success(&run(home, &args), &args); + str_field(&parse(&stdout, "team"), "type") == "personal" +} + +#[test] +fn every_read_endpoint_answers_and_the_team_joins_its_roster() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + let args = ["team", "get"]; + let stdout = assert_success(&run(&home, &args), &args); + let team = parse(&stdout, "team"); + assert!(!str_field(&team, "id").is_empty()); + assert!(!str_field(&team, "name").is_empty()); + // The suite requires an owner key, so the key's own standing must say so. + assert_eq!(str_field(&team, "caller_role"), "tenant_owner"); + + // owner_principal_id is documented to join to the members list — verify + // the join actually holds on real data. + let owner_principal = str_field(&team, "owner_principal_id"); + let args = ["member", "list"]; + let stdout = assert_success(&run(&home, &args), &args); + let roster = parse(&stdout, "member list"); + let items = roster + .get("items") + .and_then(|v| v.as_array()) + .expect("member list has items"); + assert!( + items + .iter() + .any(|m| str_field(m, "principal_id") == owner_principal), + "team.owner_principal_id {owner_principal} not on the roster: {stdout}" + ); + + for args in [ + ["api-key", "list"].as_slice(), + ["invitation", "list"].as_slice(), + ] { + let stdout = assert_success(&run(&home, args), args); + let page = parse(&stdout, "list"); + assert!( + page.get("items").is_some_and(|v| v.is_array()), + "{args:?} returned no items array: {stdout}" + ); + } + + let args = ["usage"]; + let stdout = assert_success(&run(&home, &args), &args); + let usage = parse(&stdout, "usage"); + assert!( + usage.get("quota").is_some(), + "usage missing quota: {stdout}" + ); + assert!( + usage.get("totals").is_some(), + "usage missing totals: {stdout}" + ); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn usage_honours_the_period_and_its_cap() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + // An explicit window is echoed back as the period the totals cover. + let args = [ + "usage", + "--start-date", + "2026-08-01", + "--end-date", + "2026-08-27", + ]; + let stdout = assert_success(&run(&home, &args), &args); + let usage = parse(&stdout, "usage"); + let period = usage.get("period").expect("usage has period"); + assert_eq!(str_field(period, "start_date"), "2026-08-01"); + assert_eq!(str_field(period, "end_date"), "2026-08-27"); + + // A window over 92 days is a client error, surfaced with its error code. + let args = [ + "usage", + "--start-date", + "2026-01-01", + "--end-date", + "2026-08-27", + ]; + let err = assert_failure(&run(&home, &args), &args); + assert!( + err.contains("92") && err.contains("INVALID_ARGUMENT"), + "cap violation not surfaced: {err}" + ); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn a_key_works_until_rotated_away_and_dies_on_revoke() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + let name = unique_name("key"); + + // Create: the one response that carries the full key. + let args = ["api-key", "create", "--name", name.as_str()]; + let stdout = assert_success(&run(&home, &args), &args); + let created = parse(&stdout, "api-key create"); + let id = str_field(&created, "id"); + let first_key = str_field(&created, "key"); + assert!( + first_key.starts_with("sk-"), + "created key has no sk- prefix: {first_key}" + ); + + // The minted key AUTHENTICATES, and lands in the same team. + let team = assert_key_authenticates(&first_key); + assert!( + !str_field(&team, "id").is_empty(), + "minted key sees no team: {team}" + ); + + // Read endpoints know it, but never return the key itself. + let get_args = ["api-key", "get", id.as_str()]; + let stdout = assert_success(&run(&home, &get_args), &get_args); + assert!( + !stdout.contains(&first_key), + "get leaked the full key: {stdout}" + ); + + let list_args = ["api-key", "list", "--name", name.as_str()]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + assert!(stdout.contains(&id), "list --name missed the key: {stdout}"); + + // Rotate mints different material under the same id… + let rotate_args = ["api-key", "rotate", id.as_str()]; + let stdout = assert_success(&run(&home, &rotate_args), &rotate_args); + let rotated = parse(&stdout, "api-key rotate"); + assert_eq!(str_field(&rotated, "id"), id); + let second_key = str_field(&rotated, "key"); + assert_ne!(second_key, first_key, "rotate returned the old key"); + + // …the OLD key stops working immediately, the NEW one works. + assert_key_rejected(&first_key); + assert_key_authenticates(&second_key); + + // Revoke: the id is gone and the material no longer authenticates. + let revoke_args = ["api-key", "revoke", id.as_str()]; + let stdout = assert_success(&run(&home, &revoke_args), &revoke_args); + assert!(stdout.contains("revoked"), "{stdout}"); + assert_failure(&run(&home, &get_args), &get_args); + assert_key_rejected(&second_key); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn a_virtual_members_key_acts_as_that_member() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + // The server caps a virtual member's display_name at 20 characters, so the + // shared unique_name (pid + counter + nanos) does not fit. Nanos alone, + // truncated, is unique enough for a scratch object that gets removed. + let name = format!("mlcli-vm-{}", unix_now() % 10_000_000_000); + + // Create a virtual member… + let args = [ + "member", + "create", + "--name", + name.as_str(), + "--role", + "tenant_member", + ]; + + // On a personal team the endpoint is DEFINED to refuse — pin that + // contract instead of skipping. The full lifecycle needs an org team. + if team_is_personal(&home) { + let err = assert_failure(&run(&home, &args), &args); + assert!( + err.contains("STATE_NOT_READY") && err.contains("409"), + "personal-team refusal not surfaced: {err}" + ); + let _ = fs::remove_dir_all(&home); + return; + } + + let stdout = assert_success(&run(&home, &args), &args); + let member = parse(&stdout, "member create"); + let principal_id = str_field(&member, "principal_id"); + assert_eq!(str_field(&member, "member_type"), "virtual"); + + // …change its role, and prove the change on the roster, not just the + // write's exit code. + let role_args = [ + "member", + "set-role", + principal_id.as_str(), + "--role", + "tenant_admin", + ]; + assert_success(&run(&home, &role_args), &role_args); + let list_args = ["member", "list", "--name", name.as_str()]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + let roster = parse(&stdout, "member list"); + let row = roster + .get("items") + .and_then(|v| v.as_array()) + .and_then(|items| { + items + .iter() + .find(|m| str_field(m, "principal_id") == principal_id) + }) + .unwrap_or_else(|| panic!("virtual member not on the roster: {stdout}")); + assert_eq!( + str_field(row, "role"), + "tenant_admin", + "set-role did not stick" + ); + + // …issue it a key, and prove the key acts AS THE MEMBER: it carries the + // member's role, and it hits the member's ceiling — team rename is + // owner-only, so the admin-role key must be refused. The attempted name is + // the CURRENT name so that an authorization bug cannot damage anything. + let key_name = unique_name("vmkey"); + let key_args = [ + "api-key", + "create", + "--name", + key_name.as_str(), + "--member", + principal_id.as_str(), + ]; + let stdout = assert_success(&run(&home, &key_args), &key_args); + let minted = parse(&stdout, "member key create"); + let key_id = str_field(&minted, "id"); + let vm_key = str_field(&minted, "key"); + + let team = assert_key_authenticates(&vm_key); + assert_eq!( + str_field(&team, "caller_role"), + "tenant_admin", + "the key does not act as the virtual member" + ); + // The member key hits the MEMBER's ceiling: team rename is owner-only. + // In its own home so the main home stays identified as the owner; the + // attempted name is the CURRENT name so an authorization bug that let it + // through could not damage anything. + let vm_home = temp_home(); + assert_success(&login(&vm_home, &vm_key, "vm"), &["login vm"]); + let current_name = str_field(&team, "name"); + let rename_args = ["team", "rename", "--name", current_name.as_str()]; + let err = assert_failure(&run(&vm_home, &rename_args), &rename_args); + assert!( + err.contains("403"), + "owner-only op not refused for the member key: {err}" + ); + let _ = fs::remove_dir_all(&vm_home); + + // Removing the member DISABLES its keys — the server's documented promise. + // The key is deliberately still live at removal time so the promise is + // what's tested. + let remove_args = ["member", "remove", principal_id.as_str()]; + let stdout = assert_success(&run(&home, &remove_args), &remove_args); + assert!(stdout.contains("removed"), "{stdout}"); + assert_key_rejected(&vm_key); + + let stdout = assert_success(&run(&home, &list_args), &list_args); + assert!( + !stdout.contains(&principal_id), + "removed member still listed: {stdout}" + ); + + // Clean up the disabled key row. + let revoke_args = ["api-key", "revoke", key_id.as_str()]; + assert_success(&run(&home, &revoke_args), &revoke_args); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn an_idempotent_create_replays_without_the_secret() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + let name = unique_name("idem"); + let idem = format!("mlcli-idem-{}", unix_now()); + + let args = [ + "api-key", + "create", + "--name", + name.as_str(), + "--idempotency-key", + idem.as_str(), + ]; + let stdout = assert_success(&run(&home, &args), &args); + let first = parse(&stdout, "first create"); + let id = str_field(&first, "id"); + assert!(str_field(&first, "key").starts_with("sk-")); + + // The exact same command again: no second key, and — the server's + // documented redaction — no secret in the replay. The server currently + // renders the redacted field as `"key": ""` (the Go DTO lacks omitempty), + // so absent, null, and empty all count as redacted. + let stdout = assert_success(&run(&home, &args), &args); + let replay = parse(&stdout, "replayed create"); + assert_eq!(str_field(&replay, "id"), id, "replay minted a second key"); + assert!( + replay + .get("key") + .is_none_or(|v| v.is_null() || v.as_str() == Some("")), + "replay leaked the secret: {stdout}" + ); + + let revoke_args = ["api-key", "revoke", id.as_str()]; + assert_success(&run(&home, &revoke_args), &revoke_args); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn pagination_walks_every_page_and_expiry_is_reported() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + let tag = format!("mlcli-pg-{}", unix_now()); + let name_a = format!("{tag}-a"); + let name_b = format!("{tag}-b"); + let expires_at = ((unix_now() / 1_000_000_000) + 3600).to_string(); + + // Two keys sharing a filterable tag; one with an expiry. + let args = ["api-key", "create", "--name", name_a.as_str()]; + let stdout = assert_success(&run(&home, &args), &args); + let id_a = str_field(&parse(&stdout, "create a"), "id"); + let args = [ + "api-key", + "create", + "--name", + name_b.as_str(), + "--expires-at", + expires_at.as_str(), + ]; + let stdout = assert_success(&run(&home, &args), &args); + let id_b = str_field(&parse(&stdout, "create b"), "id"); + + // The expiry made it through and is reported by the read endpoint. + let get_args = ["api-key", "get", id_b.as_str()]; + let stdout = assert_success(&run(&home, &get_args), &get_args); + let fetched = parse(&stdout, "get b"); + assert_eq!( + fetched.get("expires_at").and_then(|v| v.as_i64()), + expires_at.parse::().ok(), + "expires_at not honoured: {stdout}" + ); + + // Page 1 of 2: one item, a total of two, and a token onwards. + let list_args = [ + "api-key", + "list", + "--name", + tag.as_str(), + "--page-size", + "1", + ]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + let page1 = parse(&stdout, "page 1"); + let items1 = page1.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(items1.len(), 1, "page_size ignored: {stdout}"); + assert_eq!(page1.get("total").and_then(|v| v.as_i64()), Some(2)); + let token = str_field(&page1, "continuation_token"); + + // Page 2 of 2: the OTHER item, and no token past the end. + let list_args = [ + "api-key", + "list", + "--name", + tag.as_str(), + "--page-size", + "1", + "--continuation-token", + token.as_str(), + ]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + let page2 = parse(&stdout, "page 2"); + let items2 = page2.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(items2.len(), 1, "page 2 wrong size: {stdout}"); + assert!( + page2.get("continuation_token").is_none_or(|v| v.is_null()), + "token past the last page: {stdout}" + ); + let seen: Vec = [&items1[0], &items2[0]] + .iter() + .map(|m| str_field(m, "id")) + .collect(); + let mut expected = [id_a.clone(), id_b.clone()]; + expected.sort(); + let mut seen_sorted = seen.clone(); + seen_sorted.sort(); + assert_eq!( + seen_sorted, expected, + "pages repeated or skipped an item: {seen:?}" + ); + + for id in [id_a, id_b] { + let revoke_args = ["api-key", "revoke", id.as_str()]; + assert_success(&run(&home, &revoke_args), &revoke_args); + } + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn an_invitation_is_created_pending_and_dies_on_revoke() { + // Opt-in: creating an invitation emails a real inbox and spends the + // team's daily invitation cap, so this runs only when the operator + // supplies an address via MEMORYLAKE_INVITE_EMAIL (env or .env — both + // stay out of git). The address itself must never appear in this file. + crate::common::load_dotenv(); + let Some(email) = std::env::var("MEMORYLAKE_INVITE_EMAIL") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + else { + eprintln!("MEMORYLAKE_INVITE_EMAIL not set; skipping the invitation live test"); + return; + }; + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + // Create: the invitee address comes back normalised, the state is + // pending, and — the DTO's security promise — no invite token is + // anywhere in the response. + let args = [ + "invitation", + "create", + "--email", + &email, + "--role", + "tenant_member", + ]; + + // Personal teams cannot invite; pin the refusal and stop (no email sent). + if team_is_personal(&home) { + let err = assert_failure(&run(&home, &args), &args); + assert!( + err.contains("STATE_NOT_READY") && err.contains("409"), + "personal-team refusal not surfaced: {err}" + ); + let _ = fs::remove_dir_all(&home); + return; + } + + let stdout = assert_success(&run(&home, &args), &args); + let invitation = parse(&stdout, "invitation create"); + let id = str_field(&invitation, "id"); + assert_eq!(str_field(&invitation, "email"), email.to_lowercase()); + assert_eq!(str_field(&invitation, "status"), "pending"); + assert!( + !stdout.contains("token"), + "invitation response must not carry the accept token: {stdout}" + ); + + // It shows up under the pending filter… + let list_args = ["invitation", "list", "--status", "pending"]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + assert!(stdout.contains(&id), "pending filter missed it: {stdout}"); + + // …revoke kills it, and the state filters agree. + let revoke_args = ["invitation", "revoke", id.as_str()]; + let stdout = assert_success(&run(&home, &revoke_args), &revoke_args); + assert!(stdout.contains("revoked"), "{stdout}"); + let stdout = assert_success(&run(&home, &list_args), &list_args); + assert!( + !stdout.contains(&id), + "revoked invitation still pending: {stdout}" + ); + let list_args = ["invitation", "list", "--status", "revoked"]; + let stdout = assert_success(&run(&home, &list_args), &list_args); + assert!(stdout.contains(&id), "revoked filter missed it: {stdout}"); + + let _ = fs::remove_dir_all(&home); +} + +#[test] +fn team_rename_roundtrip() { + let api_key = require_api_key(); + let home = temp_home(); + login_default(&home, &api_key); + + let args = ["team", "get"]; + let stdout = assert_success(&run(&home, &args), &args); + let original = str_field(&parse(&stdout, "team"), "name"); + + // Rename and rename back immediately, THEN assert — a failed assertion in + // between would strand the team under the scratch name. + let scratch = format!("{original} [cli-live]"); + let rename_args = ["team", "rename", "--name", scratch.as_str()]; + let renamed_output = run(&home, &rename_args); + let restore_args = ["team", "rename", "--name", original.as_str()]; + let restored_output = run(&home, &restore_args); + + let renamed = parse( + &assert_success(&renamed_output, &rename_args), + "team rename", + ); + assert_eq!(str_field(&renamed, "name"), scratch); + let restored = parse( + &assert_success(&restored_output, &restore_args), + "team restore", + ); + assert_eq!(str_field(&restored, "name"), original); + + let _ = fs::remove_dir_all(&home); +} diff --git a/crates/cli/tests/admin/mod.rs b/crates/cli/tests/admin/mod.rs new file mode 100644 index 0000000..b94d39d --- /dev/null +++ b/crates/cli/tests/admin/mod.rs @@ -0,0 +1,6 @@ +//! Test suite for the team-management commands +//! (`team`, `api-key`, `member`, `invitation`, `usage`). + +mod live; +mod offline; +mod wire; diff --git a/crates/cli/tests/admin/offline.rs b/crates/cli/tests/admin/offline.rs new file mode 100644 index 0000000..de104a8 --- /dev/null +++ b/crates/cli/tests/admin/offline.rs @@ -0,0 +1,26 @@ +//! Offline team-management tests (no network; temp `$HOME` only). + +use std::fs; + +use crate::common::{assert_failure, run, temp_home}; + +#[test] +fn every_management_command_family_requires_login() { + // One command per family: they all talk to the API, so none may pretend + // to work while logged out. + let home = temp_home(); + for args in [ + ["team", "get"].as_slice(), + ["api-key", "list"].as_slice(), + ["member", "list"].as_slice(), + ["invitation", "list"].as_slice(), + ["usage"].as_slice(), + ] { + let err = assert_failure(&run(&home, args), args); + assert!( + err.contains("not logged in") || err.contains("resolve API credentials"), + "{args:?} should require credentials: {err}" + ); + } + let _ = fs::remove_dir_all(&home); +} diff --git a/crates/cli/tests/admin/wire.rs b/crates/cli/tests/admin/wire.rs new file mode 100644 index 0000000..7391f7d --- /dev/null +++ b/crates/cli/tests/admin/wire.rs @@ -0,0 +1,270 @@ +//! Wire-level tests for the team-management commands. +//! +//! The management endpoints live under `/admin/v1` on the SAME base URL and +//! key as everything else — that is the whole design. These tests pin the +//! method, path, query, body, and `Idempotency-Key` header each subcommand +//! sends, and what it prints back. + +use crate::common::assert_success; +use crate::common::stub::{exchange, request_line}; + +const EMPTY_PAGE: &str = r#"{"success":true,"message":"Operation completed successfully","data":{"items":[],"total":0}}"#; +const EMPTY_DATA: &str = + r#"{"success":true,"message":"Operation completed successfully","data":{}}"#; + +#[test] +fn team_get_reads_the_team_endpoint() { + let team = r#"{"success":true,"data":{"id":"t-1","name":"Acme","type":"org","owner_principal_id":"prin-1","created_at":1756000000,"caller_role":"tenant_owner"}}"#; + let (request, output) = exchange(team, &["team", "get"]); + let stdout = assert_success(&output, &["team", "get"]); + + assert_eq!(request_line(&request), "GET /admin/v1/team HTTP/1.1"); + assert!( + stdout.contains("\"tenant_owner\""), + "prints the payload: {stdout}" + ); +} + +#[test] +fn team_rename_patches_the_name_and_carries_the_idempotency_key() { + let team = r#"{"success":true,"data":{"id":"t-1","name":"Renamed","type":"org","owner_principal_id":"prin-1","created_at":1756000000}}"#; + let args = [ + "team", + "rename", + "--name", + "Renamed", + "--idempotency-key", + "idem-team-1", + ]; + let (request, output) = exchange(team, &args); + assert_success(&output, &args); + + assert_eq!(request_line(&request), "PATCH /admin/v1/team HTTP/1.1"); + assert!( + request.contains(r#"{"name":"Renamed"}"#), + "body not sent: {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("idempotency-key: idem-team-1"), + "idempotency key not sent: {request}" + ); +} + +#[test] +fn api_key_list_filters_ride_the_query_string() { + let args = [ + "api-key", + "list", + "--page-size", + "5", + "--continuation-token", + "tok", + "--name", + "ci", + ]; + let (request, output) = exchange(EMPTY_PAGE, &args); + assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "GET /admin/v1/api-keys?page_size=5&continuation_token=tok&name_fuzzy=ci HTTP/1.1" + ); +} + +#[test] +fn api_key_create_posts_the_request_and_prints_the_one_time_key() { + let created = r#"{"success":true,"data":{"id":"42","name":"ci","key_prefix":"sk-abc12","key":"sk-abc1234567890"}}"#; + let args = [ + "api-key", + "create", + "--name", + "ci", + "--member", + "prin-bot", + "--expires-at", + "1790000000", + ]; + let (request, output) = exchange(created, &args); + let stdout = assert_success(&output, &args); + + assert_eq!(request_line(&request), "POST /admin/v1/api-keys HTTP/1.1"); + assert!( + request + .contains(r#"{"name":"ci","member_principal_id":"prin-bot","expires_at":1790000000}"#), + "body not sent: {request}" + ); + assert!( + stdout.contains("sk-abc1234567890"), + "the one-time key must be shown: {stdout}" + ); +} + +#[test] +fn api_key_rotate_posts_to_the_rotate_endpoint() { + let created = r#"{"success":true,"data":{"id":"42","name":"ci","key_prefix":"sk-new12","key":"sk-new1234567890"}}"#; + let args = ["api-key", "rotate", "42", "--idempotency-key", "idem-rot-1"]; + let (request, output) = exchange(created, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "POST /admin/v1/api-keys/42/rotate HTTP/1.1" + ); + assert!( + request + .to_ascii_lowercase() + .contains("idempotency-key: idem-rot-1"), + "idempotency key not sent: {request}" + ); + assert!(stdout.contains("sk-new1234567890"), "{stdout}"); +} + +#[test] +fn api_key_revoke_deletes_and_confirms() { + let args = ["api-key", "revoke", "42"]; + let (request, output) = exchange(EMPTY_DATA, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "DELETE /admin/v1/api-keys/42 HTTP/1.1" + ); + assert!(stdout.contains("revoked"), "{stdout}"); +} + +#[test] +fn the_key_alias_reaches_the_same_endpoint() { + let (request, output) = exchange(EMPTY_PAGE, &["key", "list"]); + assert_success(&output, &["key", "list"]); + assert_eq!(request_line(&request), "GET /admin/v1/api-keys HTTP/1.1"); +} + +#[test] +fn member_list_reads_the_roster() { + let (request, output) = exchange(EMPTY_PAGE, &["member", "list"]); + assert_success(&output, &["member", "list"]); + assert_eq!(request_line(&request), "GET /admin/v1/members HTTP/1.1"); +} + +#[test] +fn member_create_posts_a_virtual_member() { + let member = r#"{"success":true,"data":{"principal_id":"prin-bot","display_name":"CI Bot","member_type":"virtual","role":"tenant_member","joined_at":1756000000,"status":"active","used_tokens":0}}"#; + let args = [ + "member", + "create", + "--name", + "CI Bot", + "--role", + "tenant_member", + ]; + let (request, output) = exchange(member, &args); + let stdout = assert_success(&output, &args); + + assert_eq!(request_line(&request), "POST /admin/v1/members HTTP/1.1"); + assert!( + request.contains(r#"{"display_name":"CI Bot","role":"tenant_member"}"#), + "body not sent: {request}" + ); + assert!(stdout.contains("prin-bot"), "{stdout}"); +} + +#[test] +fn member_set_role_patches_the_member() { + let args = ["member", "set-role", "prin-1", "--role", "tenant_admin"]; + let (request, output) = exchange(EMPTY_DATA, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "PATCH /admin/v1/members/prin-1 HTTP/1.1" + ); + assert!( + request.contains(r#"{"role":"tenant_admin"}"#), + "body not sent: {request}" + ); + assert!(stdout.contains("tenant_admin"), "{stdout}"); +} + +#[test] +fn member_remove_deletes_the_member() { + let args = ["member", "remove", "prin-1"]; + let (request, output) = exchange(EMPTY_DATA, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "DELETE /admin/v1/members/prin-1 HTTP/1.1" + ); + assert!(stdout.contains("removed"), "{stdout}"); +} + +#[test] +fn invitation_create_posts_email_and_role() { + let invitation = r#"{"success":true,"data":{"id":"7","email":"a@b.co","role":"tenant_member","status":"pending","created_at":1756000000,"expires_at":1756604800}}"#; + let args = [ + "invitation", + "create", + "--email", + "a@b.co", + "--role", + "tenant_member", + ]; + let (request, output) = exchange(invitation, &args); + assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "POST /admin/v1/invitations HTTP/1.1" + ); + assert!( + request.contains(r#"{"email":"a@b.co","role":"tenant_member"}"#), + "body not sent: {request}" + ); +} + +#[test] +fn invitation_list_filters_by_status() { + let args = ["invitation", "list", "--status", "pending"]; + let (request, output) = exchange(EMPTY_PAGE, &args); + assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "GET /admin/v1/invitations?status=pending HTTP/1.1" + ); +} + +#[test] +fn invitation_revoke_deletes_via_the_invite_alias() { + let args = ["invite", "revoke", "7"]; + let (request, output) = exchange(EMPTY_DATA, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "DELETE /admin/v1/invitations/7 HTTP/1.1" + ); + assert!(stdout.contains("revoked"), "{stdout}"); +} + +#[test] +fn usage_sends_the_period_bounds() { + let usage = r#"{"success":true,"data":{"period":{"start_date":"2026-08-01","end_date":"2026-08-27"},"quota":{"available_tokens":1000,"unlimited":false},"totals":{"requests":1,"prompt_tokens":2,"completion_tokens":3},"by_model":[]}}"#; + let args = [ + "usage", + "--start-date", + "2026-08-01", + "--end-date", + "2026-08-27", + ]; + let (request, output) = exchange(usage, &args); + let stdout = assert_success(&output, &args); + + assert_eq!( + request_line(&request), + "GET /admin/v1/usage?start_date=2026-08-01&end_date=2026-08-27 HTTP/1.1" + ); + assert!(stdout.contains("available_tokens"), "{stdout}"); +} diff --git a/crates/cli/tests/cli_commands.rs b/crates/cli/tests/cli_commands.rs index 32a2f60..f7c7e07 100644 --- a/crates/cli/tests/cli_commands.rs +++ b/crates/cli/tests/cli_commands.rs @@ -6,6 +6,7 @@ //! tests/ //! cli_commands.rs # this harness //! common/ # shared process helpers +//! admin/{offline,wire}.rs # team / api-key / member / invitation / usage //! meta/ # version, --help, … //! actor/{offline,live}.rs //! library/{offline,live}.rs @@ -24,6 +25,7 @@ //! clean up the objects they create in the real workspace. mod actor; +mod admin; mod agent; mod auth; mod common; diff --git a/crates/core/src/api/admin/api_keys.rs b/crates/core/src/api/admin/api_keys.rs new file mode 100644 index 0000000..c0a454f --- /dev/null +++ b/crates/core/src/api/admin/api_keys.rs @@ -0,0 +1,74 @@ +//! API keys of the team (`/admin/v1/api-keys`). + +use serde::Serialize; + +use crate::client::Client; +use crate::error::Result; + +use super::path; +use super::types::{ApiKey, ApiKeyCreated, ListParams, Page}; +use super::{EmptyData, idempotency_headers}; + +/// Request body for creating an API key. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CreateApiKeyRequest { + /// Display name for the key. + pub name: String, + /// Issue the key for this VIRTUAL member (from `member create`); the key + /// then acts as that member. Human members cannot be targeted. + #[serde(skip_serializing_if = "Option::is_none")] + pub member_principal_id: Option, + /// Expiry, Unix seconds. Omit for a key that never expires. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +/// List the team's API keys. Callers holding only own-scope read permission +/// see just the keys they created. +pub fn list_api_keys(client: &Client, params: &ListParams) -> Result> { + client.get_data(path::API_KEYS, ¶ms.to_query()) +} + +/// Return one API key. Keys of other teams are reported as not found. +pub fn get_api_key(client: &Client, id: &str) -> Result { + client.get_data(&path::api_key(id), &[]) +} + +/// Create an API key. The response carries the full key exactly once. +pub fn create_api_key( + client: &Client, + request: &CreateApiKeyRequest, + idempotency_key: Option<&str>, +) -> Result { + client.post_data_with_headers( + path::API_KEYS, + request, + &idempotency_headers(idempotency_key), + ) +} + +/// Replace the key material and return the new value once. The previous value +/// stops working immediately. +pub fn rotate_api_key( + client: &Client, + id: &str, + idempotency_key: Option<&str>, +) -> Result { + // The endpoint takes no body; an empty object keeps the request valid + // JSON under the client's `application/json` default. + client.post_data_with_headers( + &path::api_key_rotate(id), + &serde_json::json!({}), + &idempotency_headers(idempotency_key), + ) +} + +/// Delete an API key. The key used to make the request cannot revoke itself. +pub fn revoke_api_key(client: &Client, id: &str, idempotency_key: Option<&str>) -> Result<()> { + client + .delete_data_with_headers::( + &path::api_key(id), + &idempotency_headers(idempotency_key), + ) + .map(|_| ()) +} diff --git a/crates/core/src/api/admin/invitations.rs b/crates/core/src/api/admin/invitations.rs new file mode 100644 index 0000000..6da2fa1 --- /dev/null +++ b/crates/core/src/api/admin/invitations.rs @@ -0,0 +1,75 @@ +//! Invitations to the team (`/admin/v1/invitations`). + +use serde::Serialize; + +use crate::client::Client; +use crate::error::Result; + +use super::path; +use super::types::{Invitation, Page, push_page_query}; +use super::{EmptyData, idempotency_headers}; + +/// Request body for inviting someone to the team. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateInvitationRequest { + /// Invitee email address. One live invitation per address per team. + pub email: String, + /// Role the invitee will hold on acceptance: `tenant_admin`, + /// `tenant_member`, or a custom role key. The owner role cannot be + /// invited. + pub role: String, +} + +/// Query parameters for listing invitations. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ListInvitationsParams { + /// Page size (server default 20, maximum 100). + pub page_size: Option, + /// Continuation token from a previous page. + pub continuation_token: Option, + /// Only return invitations in this state: `pending`, `accepted`, + /// `rejected`, `expired`, or `revoked`. + pub status: Option, +} + +/// Invite someone by email. Re-inviting is revoke + create; there is no +/// resend endpoint. Invitation emails expire after 7 days. +pub fn create_invitation( + client: &Client, + request: &CreateInvitationRequest, + idempotency_key: Option<&str>, +) -> Result { + client.post_data_with_headers( + path::INVITATIONS, + request, + &idempotency_headers(idempotency_key), + ) +} + +/// List the team's invitations, newest first. +pub fn list_invitations( + client: &Client, + params: &ListInvitationsParams, +) -> Result> { + let mut query = Vec::new(); + push_page_query( + &mut query, + params.page_size, + params.continuation_token.as_deref(), + ); + if let Some(status) = ¶ms.status { + query.push(("status", status.clone())); + } + client.get_data(path::INVITATIONS, &query) +} + +/// Revoke a pending invitation; its email link stops working. Accepted +/// invitations cannot be revoked — remove the member instead. +pub fn revoke_invitation(client: &Client, id: &str, idempotency_key: Option<&str>) -> Result<()> { + client + .delete_data_with_headers::( + &path::invitation(id), + &idempotency_headers(idempotency_key), + ) + .map(|_| ()) +} diff --git a/crates/core/src/api/admin/members.rs b/crates/core/src/api/admin/members.rs new file mode 100644 index 0000000..707f9ce --- /dev/null +++ b/crates/core/src/api/admin/members.rs @@ -0,0 +1,80 @@ +//! Members of the team (`/admin/v1/members`). + +use serde::Serialize; + +use crate::client::Client; +use crate::error::Result; + +use super::path; +use super::types::{ListParams, Member, Page}; +use super::{EmptyData, idempotency_headers}; + +/// Request body for creating a virtual member. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CreateMemberRequest { + /// Display name of the virtual member. + pub display_name: String, + /// Role: `tenant_admin`, `tenant_member`, or a custom role key. The owner + /// role cannot be assigned. + pub role: String, +} + +/// Request body for changing a member's role. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct SetRoleRequest<'a> { + role: &'a str, +} + +/// List the team roster. Any member may read it; contact details come back +/// only to owners and admins, and `name_fuzzy` matches email/username only +/// for them too. +pub fn list_members(client: &Client, params: &ListParams) -> Result> { + client.get_data(path::MEMBERS, ¶ms.to_query()) +} + +/// Create a VIRTUAL member: a login-less managed identity that holds a role +/// and acts only through API keys issued for it (`api-key create +/// --member-principal-id`). Humans join through invitations only. +pub fn create_member( + client: &Client, + request: &CreateMemberRequest, + idempotency_key: Option<&str>, +) -> Result { + client.post_data_with_headers( + path::MEMBERS, + request, + &idempotency_headers(idempotency_key), + ) +} + +/// Change a member's role. The owner role cannot be assigned, and the owner's +/// own role cannot be changed. +pub fn set_member_role( + client: &Client, + principal_id: &str, + role: &str, + idempotency_key: Option<&str>, +) -> Result<()> { + client + .patch_data_with_headers::( + &path::member(principal_id), + &SetRoleRequest { role }, + &idempotency_headers(idempotency_key), + ) + .map(|_| ()) +} + +/// Remove a member. Their API keys in this team are disabled (not deleted). +/// The team owner cannot be removed, and the caller cannot remove themselves. +pub fn remove_member( + client: &Client, + principal_id: &str, + idempotency_key: Option<&str>, +) -> Result<()> { + client + .delete_data_with_headers::( + &path::member(principal_id), + &idempotency_headers(idempotency_key), + ) + .map(|_| ()) +} diff --git a/crates/core/src/api/admin/mod.rs b/crates/core/src/api/admin/mod.rs new file mode 100644 index 0000000..54ef8f6 --- /dev/null +++ b/crates/core/src/api/admin/mod.rs @@ -0,0 +1,55 @@ +//! Team management API (`/admin/v1/*`). +//! +//! Governs the team the API key belongs to: the team itself, its API keys, +//! members, invitations, and usage. Same base URL and same key as every other +//! module here — the management endpoints simply live under `/admin/v1` +//! instead of `/api/v1`. The team is fixed by the key; nothing in a request +//! can address a different one. +//! +//! Write endpoints accept an optional `Idempotency-Key` header: retrying with +//! the same value replays the first result instead of repeating the write. +//! That matters most for [`api_keys::create_api_key`] and +//! [`api_keys::rotate_api_key`], whose responses carry a secret shown exactly +//! once. + +mod api_keys; +mod invitations; +mod members; +mod path; +mod team; +mod types; +mod usage; + +pub use api_keys::{ + CreateApiKeyRequest, create_api_key, get_api_key, list_api_keys, revoke_api_key, rotate_api_key, +}; +pub use invitations::{ + CreateInvitationRequest, ListInvitationsParams, create_invitation, list_invitations, + revoke_invitation, +}; +pub use members::{ + CreateMemberRequest, create_member, list_members, remove_member, set_member_role, +}; +pub use team::{get_team, rename_team}; +pub use types::{ + ApiKey, ApiKeyCreated, Invitation, ListParams, Member, Page, Team, Usage, UsageByModel, + UsagePeriod, UsageQuota, UsageTotals, +}; +pub use usage::{GetUsageParams, get_usage}; + +/// Header that makes a retried write replay its first result. +const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key"; + +/// Render an optional idempotency key as the extra-headers slice the client +/// takes. `None` means "send nothing", not an empty header value. +fn idempotency_headers(key: Option<&str>) -> Vec<(&'static str, &str)> { + key.map(|key| vec![(IDEMPOTENCY_KEY_HEADER, key)]) + .unwrap_or_default() +} + +/// Discardable payload of writes that answer `{"success":true,"data":{}}`. +/// +/// `serde_json::Value` rather than `()` or an empty struct: `{}` cannot +/// deserialize into `()`, and an empty struct would reject the equally valid +/// absent-`data` form (which decodes as `Value::Null`). +type EmptyData = serde_json::Value; diff --git a/crates/core/src/api/admin/path.rs b/crates/core/src/api/admin/path.rs new file mode 100644 index 0000000..2b68b0e --- /dev/null +++ b/crates/core/src/api/admin/path.rs @@ -0,0 +1,55 @@ +//! URL paths of the team management API. +//! +//! All of them are fixed except for one trailing id segment, which is +//! percent-encoded because key ids, principal ids, and invitation ids are +//! caller-supplied strings on revoke/update calls. + +use crate::api::path::encode_segment; + +pub(super) const TEAM: &str = "/admin/v1/team"; +pub(super) const API_KEYS: &str = "/admin/v1/api-keys"; +pub(super) const MEMBERS: &str = "/admin/v1/members"; +pub(super) const INVITATIONS: &str = "/admin/v1/invitations"; +pub(super) const USAGE: &str = "/admin/v1/usage"; + +pub(super) fn api_key(id: &str) -> String { + format!("{API_KEYS}/{}", encode_segment(id)) +} + +pub(super) fn api_key_rotate(id: &str) -> String { + format!("{}/rotate", api_key(id)) +} + +pub(super) fn member(principal_id: &str) -> String { + format!("{MEMBERS}/{}", encode_segment(principal_id)) +} + +pub(super) fn invitation(id: &str) -> String { + format!("{INVITATIONS}/{}", encode_segment(id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_paths_take_the_typical_ids_verbatim() { + assert_eq!(api_key("42"), "/admin/v1/api-keys/42"); + assert_eq!(api_key_rotate("42"), "/admin/v1/api-keys/42/rotate"); + assert_eq!( + member("prin-b83fa7f09f19487f"), + "/admin/v1/members/prin-b83fa7f09f19487f" + ); + assert_eq!(invitation("7"), "/admin/v1/invitations/7"); + } + + #[test] + fn a_hostile_id_cannot_restructure_the_path() { + // These ids come from command-line arguments; an embedded `/` or `?` + // must not address a different endpoint. + assert_eq!( + api_key("../members?x=1"), + "/admin/v1/api-keys/..%2Fmembers%3Fx=1" + ); + } +} diff --git a/crates/core/src/api/admin/team.rs b/crates/core/src/api/admin/team.rs new file mode 100644 index 0000000..6e8f812 --- /dev/null +++ b/crates/core/src/api/admin/team.rs @@ -0,0 +1,30 @@ +//! The team itself (`/admin/v1/team`). + +use serde::Serialize; + +use crate::client::Client; +use crate::error::Result; + +use super::idempotency_headers; +use super::path; +use super::types::Team; + +/// Request body for renaming the team. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RenameTeamRequest<'a> { + name: &'a str, +} + +/// Return the team the key belongs to. Any member may read it. +pub fn get_team(client: &Client) -> Result { + client.get_data(path::TEAM, &[]) +} + +/// Rename the team. Only the team owner may do this. +pub fn rename_team(client: &Client, name: &str, idempotency_key: Option<&str>) -> Result { + client.patch_data_with_headers( + path::TEAM, + &RenameTeamRequest { name }, + &idempotency_headers(idempotency_key), + ) +} diff --git a/crates/core/src/api/admin/types.rs b/crates/core/src/api/admin/types.rs new file mode 100644 index 0000000..9543cad --- /dev/null +++ b/crates/core/src/api/admin/types.rs @@ -0,0 +1,236 @@ +//! Shared team-management resource types. + +use serde::{Deserialize, Serialize}; + +/// Cursor-paged list envelope shared by every management list endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Page { + /// Items on this page. + #[serde(default = "Vec::new")] + pub items: Vec, + /// Total items matching the filter across all pages, when the server + /// reports one. + #[serde(default)] + pub total: Option, + /// Token for the next page; absent on the last page. Opaque — do not + /// construct or parse it. + #[serde(default)] + pub continuation_token: Option, +} + +/// Query parameters shared by the API-key and member list endpoints. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ListParams { + /// Page size (server default 20, maximum 100). + pub page_size: Option, + /// Continuation token from a previous page. + pub continuation_token: Option, + /// Case-insensitive substring filter on the name. Sent as `name_fuzzy`. + pub name_fuzzy: Option, +} + +impl ListParams { + /// Render these parameters as a query string, `name_fuzzy` included. + pub(super) fn to_query(&self) -> Vec<(&'static str, String)> { + let mut query = Vec::new(); + push_page_query( + &mut query, + self.page_size, + self.continuation_token.as_deref(), + ); + if let Some(name_fuzzy) = &self.name_fuzzy { + query.push(("name_fuzzy", name_fuzzy.clone())); + } + query + } +} + +/// Append the pagination parameters every list endpoint shares. +pub(super) fn push_page_query( + query: &mut Vec<(&'static str, String)>, + page_size: Option, + continuation_token: Option<&str>, +) { + if let Some(page_size) = page_size { + query.push(("page_size", page_size.to_string())); + } + if let Some(token) = continuation_token { + query.push(("continuation_token", token.to_string())); + } +} + +/// The team the calling key belongs to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Team { + /// Stable team id. + pub id: String, + /// Display name. + pub name: String, + /// `personal` (single-user space) or `org` (team with members). + #[serde(rename = "type")] + pub team_type: String, + /// `principal_id` of the team owner (joins to [`Member::principal_id`]). + pub owner_principal_id: String, + /// Creation time, Unix seconds. + pub created_at: i64, + /// The calling key's role in this team. + #[serde(default)] + pub caller_role: Option, +} + +/// An API key of the team. Never carries the key itself — only +/// [`ApiKeyCreated`] does, once. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiKey { + /// Stable key id. + pub id: String, + /// Display name. + pub name: String, + /// First 8 characters of the key body. + pub key_prefix: String, + /// `enabled`, `disabled`, or `expired`. + pub status: String, + /// Creation time, Unix seconds. + pub created_at: i64, + /// Expiry time, Unix seconds. Absent when the key never expires. + #[serde(default)] + pub expires_at: Option, + /// Last-used time, Unix seconds. Approximate — the server throttles + /// updates, so it can lag real usage by up to an hour. + #[serde(default)] + pub last_used_at: Option, +} + +/// The one response shape that carries a full API key. Shown exactly once by +/// create and rotate; read endpoints never return it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiKeyCreated { + /// Stable key id. + pub id: String, + /// Display name. + pub name: String, + /// First 8 characters of the key body. + pub key_prefix: String, + /// The full API key, including the `sk-` prefix. An idempotent replay + /// omits it — the secret is only ever sent on the live first response. + #[serde(default)] + pub key: Option, +} + +/// A team member. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Member { + /// Stable member id. + pub principal_id: String, + /// Display name. + pub display_name: String, + /// `human` (joined via invitation) or `virtual` (managed identity that + /// acts only through API keys issued for it). + pub member_type: String, + /// Role in this team: `tenant_owner`, `tenant_admin`, `tenant_member`, or + /// a custom role key. + pub role: String, + /// Only returned to team owners and admins. + #[serde(default)] + pub email: Option, + /// Only returned to team owners and admins. + #[serde(default)] + pub username: Option, + /// Join time, Unix seconds. + pub joined_at: i64, + /// `active` or `inactive`. + pub status: String, + /// Tokens consumed by this member. + pub used_tokens: i64, + /// Per-member cap in tokens. Absent when uncapped. + #[serde(default)] + pub max_tokens: Option, +} + +/// A team invitation. Never carries the invite token — that token is the +/// accept credential. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Invitation { + /// Stable invitation id. + pub id: String, + /// Invitee email (normalised to lowercase). + pub email: String, + /// Role the invitee will hold on acceptance. + pub role: String, + /// `pending`, `accepted`, `rejected`, `expired`, or `revoked`. + pub status: String, + /// First invitation time, Unix seconds. Re-inviting does not reset it. + pub created_at: i64, + /// Expiry of the current invitation email, Unix seconds. + pub expires_at: i64, + /// Most recent invitation email time, Unix seconds. + #[serde(default)] + pub last_invited_at: Option, + /// Acceptance time, Unix seconds. Present only when accepted. + #[serde(default)] + pub accepted_at: Option, + /// `principal_id` of the member this invitation produced. Present only + /// when accepted. + #[serde(default)] + pub accepted_principal_id: Option, +} + +/// Quota snapshot plus consumption over a period. +/// +/// `quota` is an account-level snapshot taken now; `totals` and `by_model` +/// aggregate over the requested period. They answer different questions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + /// The period the totals cover. + pub period: UsagePeriod, + /// Account-level quota snapshot — not scoped to the period. + pub quota: UsageQuota, + /// Aggregate consumption over the period. + pub totals: UsageTotals, + /// Per-model breakdown over the period. + #[serde(default)] + pub by_model: Vec, +} + +/// First and last day a usage report covers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsagePeriod { + /// First day included, `YYYY-MM-DD`. + pub start_date: String, + /// Last day included, `YYYY-MM-DD`. + pub end_date: String, +} + +/// Account-level quota snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageQuota { + /// Currently available tokens. Not meaningful when `unlimited` is true. + pub available_tokens: i64, + /// When true, `available_tokens` is not meaningful. + pub unlimited: bool, +} + +/// Aggregate consumption over a period. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageTotals { + /// Number of requests. + pub requests: i64, + /// Prompt tokens consumed. + pub prompt_tokens: i64, + /// Completion tokens produced. + pub completion_tokens: i64, +} + +/// One model's (or platform metering operation's) consumption over a period. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageByModel { + /// Model name, or a platform metering operation (`files_process`, + /// `memory_input`, `retrieval_call`, `search_call`). + pub model: String, + /// Number of requests. + pub requests: i64, + /// Prompt tokens consumed. + pub prompt_tokens: i64, + /// Completion tokens produced. + pub completion_tokens: i64, +} diff --git a/crates/core/src/api/admin/usage.rs b/crates/core/src/api/admin/usage.rs new file mode 100644 index 0000000..aefb493 --- /dev/null +++ b/crates/core/src/api/admin/usage.rs @@ -0,0 +1,32 @@ +//! Quota and usage of the team (`/admin/v1/usage`). + +use crate::client::Client; +use crate::error::Result; + +use super::path; +use super::types::Usage; + +/// Query parameters for a usage report. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GetUsageParams { + /// First day to include, `YYYY-MM-DD`. The server defaults it to six days + /// before `end_date`. + pub start_date: Option, + /// Last day to include, `YYYY-MM-DD`. The server defaults it to today in + /// its own timezone. + pub end_date: Option, +} + +/// Return the team's quota snapshot plus consumption over the requested +/// period (at most 92 days). Requires usage read permission on the whole +/// team. +pub fn get_usage(client: &Client, params: &GetUsageParams) -> Result { + let mut query = Vec::new(); + if let Some(start_date) = ¶ms.start_date { + query.push(("start_date", start_date.clone())); + } + if let Some(end_date) = ¶ms.end_date { + query.push(("end_date", end_date.clone())); + } + client.get_data(path::USAGE, &query) +} diff --git a/crates/core/src/api/mod.rs b/crates/core/src/api/mod.rs index 1a0ee48..8be62b6 100644 --- a/crates/core/src/api/mod.rs +++ b/crates/core/src/api/mod.rs @@ -1,6 +1,7 @@ //! MemoryLake API bindings. pub mod actors; +pub mod admin; pub mod agents; pub mod conversations; pub mod documents; diff --git a/crates/core/src/client.rs b/crates/core/src/client.rs index d690b73..acd1db8 100644 --- a/crates/core/src/client.rs +++ b/crates/core/src/client.rs @@ -164,14 +164,26 @@ impl Client { /// Perform a POST with a JSON body and deserialize the API `data` payload. pub fn post_data(&self, path: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize, + { + self.post_data_with_headers(path, body, &[]) + } + + /// [`Self::post_data`] with extra per-request headers (e.g. `Idempotency-Key`). + pub fn post_data_with_headers( + &self, + path: &str, + body: &B, + headers: &[(&str, &str)], + ) -> Result where T: DeserializeOwned, B: Serialize, { let url = self.url(path); - let request = self - .http - .post(&url) + let request = apply_headers(self.http.post(&url), headers) .headers(self.auth_headers()?) .json(body) .build()?; @@ -180,14 +192,26 @@ impl Client { /// Perform a PATCH with a JSON body and deserialize the API `data` payload. pub fn patch_data(&self, path: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize, + { + self.patch_data_with_headers(path, body, &[]) + } + + /// [`Self::patch_data`] with extra per-request headers (e.g. `Idempotency-Key`). + pub fn patch_data_with_headers( + &self, + path: &str, + body: &B, + headers: &[(&str, &str)], + ) -> Result where T: DeserializeOwned, B: Serialize, { let url = self.url(path); - let request = self - .http - .patch(&url) + let request = apply_headers(self.http.patch(&url), headers) .headers(self.auth_headers()?) .json(body) .build()?; @@ -199,13 +223,19 @@ impl Client { /// Endpoints that answer `{"success": true, "message": ...}` with no `data` /// decode into `()`. pub fn delete_data(&self, path: &str) -> Result + where + T: DeserializeOwned, + { + self.delete_data_with_headers(path, &[]) + } + + /// [`Self::delete_data`] with extra per-request headers (e.g. `Idempotency-Key`). + pub fn delete_data_with_headers(&self, path: &str, headers: &[(&str, &str)]) -> Result where T: DeserializeOwned, { let url = self.url(path); - let request = self - .http - .delete(&url) + let request = apply_headers(self.http.delete(&url), headers) .headers(self.auth_headers()?) .build()?; self.send(request) @@ -408,6 +438,22 @@ impl Client { } } +/// Apply extra per-request headers to a builder. +/// +/// An invalid name or value is not swallowed: `reqwest` records it on the +/// builder and surfaces it as an error from `build()`, so a bad +/// `Idempotency-Key` fails the request loudly instead of being silently +/// dropped. +fn apply_headers( + mut builder: reqwest::blocking::RequestBuilder, + headers: &[(&str, &str)], +) -> reqwest::blocking::RequestBuilder { + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder +} + /// Why a single pre-signed part upload attempt failed. /// /// Kept separate from [`Error`] so the upload orchestrator can decide whether @@ -1166,6 +1212,32 @@ mod tests { ); } + #[test] + fn extra_headers_reach_the_wire() { + // The management-plane writes ride on this: an Idempotency-Key that + // silently fell off would turn a replayed retry into a second key. + let server = StubServer::new("200 OK", r#"{"success":true,"data":{}}"#); + let client = Client::new(&server.base_url, "sk_test_key_1234").unwrap(); + + let _: Value = client + .post_data_with_headers( + "/admin/v1/api-keys", + &serde_json::json!({"name": "ci"}), + &[("Idempotency-Key", "idem-123")], + ) + .expect("post with extra headers succeeds"); + + let request = server.received().to_ascii_lowercase(); + assert!( + request.contains("idempotency-key: idem-123"), + "extra header missing from the wire:\n{request}" + ); + assert!( + request.contains("authorization: "), + "extra headers must not displace auth:\n{request}" + ); + } + #[test] fn delete_accepts_success_envelope_without_data() { let server = StubServer::new(