Skip to content

Fix secrets endpoints: use /api/v1 and shared response parser#106

Merged
antidmg merged 2 commits intomainfrom
fix/secrets-api-endpoints
Feb 28, 2026
Merged

Fix secrets endpoints: use /api/v1 and shared response parser#106
antidmg merged 2 commits intomainfrom
fix/secrets-api-endpoints

Conversation

@antidmg
Copy link
Copy Markdown
Contributor

@antidmg antidmg commented Feb 28, 2026

Switches secrets CLI endpoints from /dashboard/apps/ to /api/v1/environments/ and replaces manual response parsing in list_secret_keys with the shared parse_api_response helper.

Summary by CodeRabbit

  • New Features

    • Added secret management to the CLI: set, list, and delete secrets for an application environment.
    • Set secrets via KEY=VALUE pairs; list secret keys; delete by key.
  • Refactor

    • Centralized gateway/client construction to reduce repetition across CLI commands.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 28, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 819b119 and 3253f4d.

📒 Files selected for processing (4)
  • binaries/statespace-cli/src/gateway/client.rs
  • binaries/statespace-cli/src/gateway/mod.rs
  • binaries/statespace-cli/src/gateway/secrets.rs
  • binaries/statespace-cli/src/main.rs

Walkthrough

Adds a top-level Secrets command group to the CLI with subcommands to set, list, and delete environment secrets; introduces CLI arg types and command handler; adds GatewayClient secrets REST methods; registers the secrets command module and centralizes GatewayClient construction in main.

Changes

Cohort / File(s) Summary
CLI Argument Definitions
binaries/statespace-cli/src/args.rs
Added SecretsCommands enum (Set, List, Delete) and supporting structs SecretsSetArgs (app, secrets), SecretsListArgs (app), SecretsDeleteArgs (app, key); updated Commands to include Secrets { command }.
Secrets Command Handler
binaries/statespace-cli/src/commands/secrets.rs
New module implementing pub(crate) async fn run(cmd: SecretsCommands, gateway: GatewayClient) -> Result<()> with run_set (parses KEY=VALUE, calls gateway.set_secret), run_list (calls gateway.list_secret_keys), and run_delete (calls gateway.delete_secret); resolves environment IDs via gateway.
Command Module Registration
binaries/statespace-cli/src/commands/mod.rs
Added pub(crate) mod secrets; to register the new secrets command module.
Gateway: new secrets API methods
binaries/statespace-cli/src/gateway/secrets.rs, binaries/statespace-cli/src/gateway/mod.rs
Added set_secret, list_secret_keys, and delete_secret methods on GatewayClient that call /api/v1/environments/{env_id}/secrets endpoints; registered mod secrets; in gateway module.
Gateway client visibility tweaks
binaries/statespace-cli/src/gateway/client.rs
Broadened visibility of GatewayClient/AuthClient fields (base_url, http) and helper funcs (with_headers, check_api_response, parse_api_response) to pub(super) to allow use from the new gateway/secrets module.
Main Command Dispatcher
binaries/statespace-cli/src/main.rs
Added Commands::Secrets match arm and introduced build_gateway helper closure; consolidated GatewayClient construction across multiple commands and dispatched to secrets::run.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI
    participant SecretsCmd
    participant GatewayClient
    participant API
    User->>CLI: run "statespace secrets set|list|delete" args
    CLI->>SecretsCmd: dispatch run(cmd, gateway)
    SecretsCmd->>GatewayClient: resolve environment id
    SecretsCmd->>GatewayClient: set/list/delete secret(s)
    GatewayClient->>API: HTTP PUT/GET/DELETE /api/v1/environments/{env_id}/secrets...
    API-->>GatewayClient: HTTP response
    GatewayClient-->>SecretsCmd: parsed result or error
    SecretsCmd-->>CLI: print results / errors
    CLI-->>User: stdout/stderr
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description provides a brief summary but does not follow the required template structure with Summary, Changes, Testing, and Checklist sections. Add proper sections following the template: expand Summary, detail Changes for each affected file, include Testing checklist items (fmt/clippy/test), and confirm Docs/Checklist items.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: switching secrets endpoints to /api/v1 and using shared response parser, which aligns with the file changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/secrets-api-endpoints

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@binaries/statespace-cli/src/commands/secrets.rs`:
- Around line 1-57: This new CLI commands module is missing unit tests; add
tests that exercise run_set (valid KEY=VALUE and invalid formats), run_list
(empty keys and non-empty keys output), run_delete (successful deletion
message), and error propagation from resolve_env_id; create test functions that
construct a mocked or fake GatewayClient implementing the same async methods
used (get_environment, set_secret, list_secret_keys, delete_secret), call
run_set/run_list/run_delete/resolve_env_id with appropriate
SecretsSetArgs/SecretsListArgs/SecretsDeleteArgs inputs, assert returned Result
variants and captured stdout/stderr strings for the "✓" messages and "No secrets
set." output, and include a test where the mock get_environment returns an error
to verify resolve_env_id propagates it.
- Around line 23-28: The loop over args.secrets currently uses
secret.split_once('=') but doesn't reject empty or whitespace-only keys (e.g.,
"=VALUE" or " KEY =VALUE"); update the parsing in the loop that handles secret
(where you destructure into key and value) to trim whitespace from both parts,
then validate that key.trim() is not empty and return Err(Error::cli(...)) with
a clear message like "Invalid secret format '{secret}': missing key" if it's
empty; keep the rest of the flow (using key and value) unchanged so the API only
receives validated, trimmed keys.

In `@binaries/statespace-cli/src/gateway/client.rs`:
- Around line 366-369: The URL builds the secret endpoint by interpolating raw
key into the path (let url = format!( "{}/api/v1/environments/{}/secrets/{}",
self.base_url, env_id, key )), which breaks for keys with URL-special
characters; fix by URL-encoding the key (use urlencoding::encode or equivalent)
before interpolating—e.g., compute let encoded = urlencoding::encode(&key) and
use encoded in the format call (mirror the approach used in remove_ssh_key) so
routing works for keys containing /, ?, #, etc.
- Around line 387-390: The constructed URL uses the raw secret key and must be
URL-encoded like in set_secret to safely handle special characters; update the
code that builds url (the format! with self.base_url, env_id, key) to
percent-encode key (and any other path components if needed) before
interpolation (use the same encoding helper used by set_secret or a consistent
percent-encoding utility), ensuring the final string uses the encoded key.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4c83eef and 166ed53.

📒 Files selected for processing (5)
  • binaries/statespace-cli/src/args.rs
  • binaries/statespace-cli/src/commands/mod.rs
  • binaries/statespace-cli/src/commands/secrets.rs
  • binaries/statespace-cli/src/gateway/client.rs
  • binaries/statespace-cli/src/main.rs

Comment on lines +1 to +57
use crate::args::{SecretsCommands, SecretsDeleteArgs, SecretsListArgs, SecretsSetArgs};
use crate::error::{Error, Result};
use crate::gateway::GatewayClient;
use crate::identifiers::normalize_environment_reference;

pub(crate) async fn run(cmd: SecretsCommands, gateway: GatewayClient) -> Result<()> {
match cmd {
SecretsCommands::Set(args) => run_set(args, gateway).await,
SecretsCommands::List(args) => run_list(args, gateway).await,
SecretsCommands::Delete(args) => run_delete(args, gateway).await,
}
}

async fn resolve_env_id(gateway: &GatewayClient, app: &str) -> Result<String> {
let reference = normalize_environment_reference(app).map_err(Error::cli)?;
let env = gateway.get_environment(&reference).await?;
Ok(env.id)
}

async fn run_set(args: SecretsSetArgs, gateway: GatewayClient) -> Result<()> {
let env_id = resolve_env_id(&gateway, &args.app).await?;

for secret in &args.secrets {
let Some((key, value)) = secret.split_once('=') else {
return Err(Error::cli(format!(
"Invalid secret format '{secret}': expected KEY=VALUE"
)));
};
gateway.set_secret(&env_id, key, value).await?;
eprintln!("✓ Set {key}");
}

Ok(())
}

async fn run_list(args: SecretsListArgs, gateway: GatewayClient) -> Result<()> {
let env_id = resolve_env_id(&gateway, &args.app).await?;
let keys = gateway.list_secret_keys(&env_id).await?;

if keys.is_empty() {
println!("No secrets set.");
return Ok(());
}

for key in &keys {
println!("{key}");
}

Ok(())
}

async fn run_delete(args: SecretsDeleteArgs, gateway: GatewayClient) -> Result<()> {
let env_id = resolve_env_id(&gateway, &args.app).await?;
gateway.delete_secret(&env_id, &args.key).await?;
eprintln!("✓ Deleted {}", args.key);
Ok(())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

New command module lacks test coverage.

Per coding guidelines for binaries/statespace-cli/src/commands/**: "Flag PRs that add new commands without corresponding unit tests." This new module introduces set, list, and delete subcommands without accompanying tests.

Consider adding unit tests for:

  • run_set parsing of KEY=VALUE format (valid and invalid inputs)
  • run_list output formatting (empty and non-empty cases)
  • Error propagation from resolve_env_id

Would you like me to help generate test scaffolding for this module, or open an issue to track adding test coverage?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@binaries/statespace-cli/src/commands/secrets.rs` around lines 1 - 57, This
new CLI commands module is missing unit tests; add tests that exercise run_set
(valid KEY=VALUE and invalid formats), run_list (empty keys and non-empty keys
output), run_delete (successful deletion message), and error propagation from
resolve_env_id; create test functions that construct a mocked or fake
GatewayClient implementing the same async methods used (get_environment,
set_secret, list_secret_keys, delete_secret), call
run_set/run_list/run_delete/resolve_env_id with appropriate
SecretsSetArgs/SecretsListArgs/SecretsDeleteArgs inputs, assert returned Result
variants and captured stdout/stderr strings for the "✓" messages and "No secrets
set." output, and include a test where the mock get_environment returns an error
to verify resolve_env_id propagates it.

Comment on lines +23 to +28
for secret in &args.secrets {
let Some((key, value)) = secret.split_once('=') else {
return Err(Error::cli(format!(
"Invalid secret format '{secret}': expected KEY=VALUE"
)));
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider validating non-empty key before API call.

split_once('=') allows edge cases like =VALUE (empty key) or leading/trailing whitespace. Client-side validation would provide faster feedback than waiting for an API error.

💡 Optional enhancement
         let Some((key, value)) = secret.split_once('=') else {
             return Err(Error::cli(format!(
                 "Invalid secret format '{secret}': expected KEY=VALUE"
             )));
         };
+        let key = key.trim();
+        if key.is_empty() {
+            return Err(Error::cli(format!(
+                "Invalid secret format '{secret}': key cannot be empty"
+            )));
+        }
         gateway.set_secret(&env_id, key, value).await?;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@binaries/statespace-cli/src/commands/secrets.rs` around lines 23 - 28, The
loop over args.secrets currently uses secret.split_once('=') but doesn't reject
empty or whitespace-only keys (e.g., "=VALUE" or " KEY =VALUE"); update the
parsing in the loop that handles secret (where you destructure into key and
value) to trim whitespace from both parts, then validate that key.trim() is not
empty and return Err(Error::cli(...)) with a clear message like "Invalid secret
format '{secret}': missing key" if it's empty; keep the rest of the flow (using
key and value) unchanged so the API only receives validated, trimmed keys.

Comment thread binaries/statespace-cli/src/gateway/client.rs Outdated
Comment thread binaries/statespace-cli/src/gateway/client.rs Outdated
@antidmg antidmg force-pushed the fix/secrets-api-endpoints branch from 166ed53 to e16d386 Compare February 28, 2026 04:00
Implements set, list, and delete subcommands for managing per-environment
secrets via the gateway API. The CLI resolves environment names/URLs to
UUIDs, then calls the dashboard secrets endpoints.

Usage: statespace secrets set --app my-dashboard DB=postgresql://... API_KEY=sk_...
  statespace secrets list --app my-dashboard
  statespace secrets delete --app my-dashboard DB
Amp-Thread-ID: https://ampcode.com/threads/T-019c98d6-a71d-747d-b6ad-bb34b5aa92c6
@antidmg antidmg force-pushed the fix/secrets-api-endpoints branch 2 times, most recently from de80a8d to 819b119 Compare February 28, 2026 04:05
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
binaries/statespace-cli/src/commands/secrets.rs (2)

23-31: ⚠️ Potential issue | 🟠 Major

Pre-validate all secrets before any write to avoid partial updates.

At Line 23, validation and API mutation are interleaved. If one later entry is invalid, earlier secrets are already written. This creates partial, non-atomic updates and harder rollback behavior. Also reject empty keys before calling the API.

Proposed fix
 async fn run_set(args: SecretsSetArgs, gateway: GatewayClient) -> Result<()> {
     let env_id = resolve_env_id(&gateway, &args.app).await?;
 
-    for secret in &args.secrets {
-        let Some((key, value)) = secret.split_once('=') else {
+    let mut parsed = Vec::with_capacity(args.secrets.len());
+    for secret in &args.secrets {
+        let Some((raw_key, raw_value)) = secret.split_once('=') else {
             return Err(Error::cli(format!(
                 "Invalid secret format '{secret}': expected KEY=VALUE"
             )));
         };
-        gateway.set_secret(&env_id, key, value).await?;
+        let key = raw_key.trim();
+        if key.is_empty() {
+            return Err(Error::cli(format!(
+                "Invalid secret format '{secret}': missing key"
+            )));
+        }
+        parsed.push((key.to_string(), raw_value.to_string()));
+    }
+
+    for (key, value) in parsed {
+        gateway.set_secret(&env_id, &key, &value).await?;
         eprintln!("✓ Set {key}");
     }
 
     Ok(())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@binaries/statespace-cli/src/commands/secrets.rs` around lines 23 - 31,
Pre-validate and collect all secret key/value pairs before calling the API to
avoid partial updates: first iterate over &args.secrets, use
secret.split_once('=') to parse each entry, reject any missing '=' or empty key
(return Err(Error::cli(...)) on validation failure), and accumulate validated
(key, value) pairs; only after all inputs are validated, iterate the collected
pairs and call gateway.set_secret(&env_id, key, value).await? and eprintln!("✓
Set {key}") for each to ensure atomic-like behavior and no partial writes.

1-57: ⚠️ Potential issue | 🟠 Major

Add unit tests for the new secrets command handlers.

This file introduces new command logic and error paths, but there are no corresponding tests in the PR for set/list/delete flows and invalid input handling.

Would you like me to draft a focused unit test scaffold for run_set, run_list, run_delete, and resolve_env_id error propagation?

As per coding guidelines: binaries/statespace-cli/src/commands/**: “Flag PRs that add new commands without corresponding unit tests” and “modify command logic without updating tests.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@binaries/statespace-cli/src/commands/secrets.rs` around lines 1 - 57, Add
unit tests covering the new command handlers: write tests for run_set, run_list,
run_delete and for resolve_env_id error propagation; mock GatewayClient methods
(get_environment, set_secret, list_secret_keys, delete_secret) to return
expected values or errors, assert resolve_env_id propagates gateway errors,
assert run_set handles valid KEY=VALUE and returns a CLI error for invalid
formats (secret.split_once failure), assert run_list prints "No secrets set."
when keys empty and prints keys when present, and assert run_delete calls
delete_secret and prints the deleted key; locate the handlers by function names
run_set, run_list, run_delete and resolve_env_id and use the
SecretsSetArgs/SecretsListArgs/SecretsDeleteArgs structs to construct test
inputs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@binaries/statespace-cli/src/gateway/client.rs`:
- Around line 360-398: Move the three methods set_secret, list_secret_keys,
delete_secret and the local Payload<'a> struct out of client.rs into a new
gateway domain module named secrets (e.g., gateway::secrets) so domain-specific
API calls live in their own file; in the new module implement the same async
methods (same signatures) as impls on the existing Client type (reuse
self.base_url, self.http, with_headers, and call check_api_response /
parse_api_response), bring in serde::Serialize and urlencoding::encode where
needed, remove the originals from client.rs, and add a pub mod secrets;
declaration in the gateway module so callers continue to use Client::set_secret
/ list_secret_keys / delete_secret unchanged.

---

Duplicate comments:
In `@binaries/statespace-cli/src/commands/secrets.rs`:
- Around line 23-31: Pre-validate and collect all secret key/value pairs before
calling the API to avoid partial updates: first iterate over &args.secrets, use
secret.split_once('=') to parse each entry, reject any missing '=' or empty key
(return Err(Error::cli(...)) on validation failure), and accumulate validated
(key, value) pairs; only after all inputs are validated, iterate the collected
pairs and call gateway.set_secret(&env_id, key, value).await? and eprintln!("✓
Set {key}") for each to ensure atomic-like behavior and no partial writes.
- Around line 1-57: Add unit tests covering the new command handlers: write
tests for run_set, run_list, run_delete and for resolve_env_id error
propagation; mock GatewayClient methods (get_environment, set_secret,
list_secret_keys, delete_secret) to return expected values or errors, assert
resolve_env_id propagates gateway errors, assert run_set handles valid KEY=VALUE
and returns a CLI error for invalid formats (secret.split_once failure), assert
run_list prints "No secrets set." when keys empty and prints keys when present,
and assert run_delete calls delete_secret and prints the deleted key; locate the
handlers by function names run_set, run_list, run_delete and resolve_env_id and
use the SecretsSetArgs/SecretsListArgs/SecretsDeleteArgs structs to construct
test inputs.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e16d386 and 819b119.

📒 Files selected for processing (5)
  • binaries/statespace-cli/src/args.rs
  • binaries/statespace-cli/src/commands/mod.rs
  • binaries/statespace-cli/src/commands/secrets.rs
  • binaries/statespace-cli/src/gateway/client.rs
  • binaries/statespace-cli/src/main.rs

Comment on lines +360 to +398
pub(crate) async fn set_secret(&self, env_id: &str, key: &str, value: &str) -> Result<()> {
#[derive(Serialize)]
struct Payload<'a> {
value: &'a str,
}

let url = format!(
"{}/api/v1/environments/{}/secrets/{}",
self.base_url,
env_id,
urlencoding::encode(key)
);
let resp = self
.with_headers(self.http.put(&url))
.json(&Payload { value })
.send()
.await?;

check_api_response(resp).await
}

pub(crate) async fn list_secret_keys(&self, env_id: &str) -> Result<Vec<String>> {
let url = format!("{}/api/v1/environments/{}/secrets", self.base_url, env_id);
let resp = self.with_headers(self.http.get(&url)).send().await?;

parse_api_response(resp).await
}

pub(crate) async fn delete_secret(&self, env_id: &str, key: &str) -> Result<()> {
let url = format!(
"{}/api/v1/environments/{}/secrets/{}",
self.base_url,
env_id,
urlencoding::encode(key)
);
let resp = self.with_headers(self.http.delete(&url)).send().await?;

check_api_response(resp).await
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Move secrets gateway calls into a dedicated domain file.

Lines 360-398 add a new secrets domain directly in gateway/client.rs. Please move these methods into a dedicated gateway domain module (for example binaries/statespace-cli/src/gateway/secrets.rs) and keep client.rs as shared client primitives/composition.

As per coding guidelines: binaries/statespace-cli/src/gateway/**/*.rs: “Use one file per domain (e.g., environments.rs, auth.rs, tokens.rs) where each file owns the types and API calls for that domain.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@binaries/statespace-cli/src/gateway/client.rs` around lines 360 - 398, Move
the three methods set_secret, list_secret_keys, delete_secret and the local
Payload<'a> struct out of client.rs into a new gateway domain module named
secrets (e.g., gateway::secrets) so domain-specific API calls live in their own
file; in the new module implement the same async methods (same signatures) as
impls on the existing Client type (reuse self.base_url, self.http, with_headers,
and call check_api_response / parse_api_response), bring in serde::Serialize and
urlencoding::encode where needed, remove the originals from client.rs, and add a
pub mod secrets; declaration in the gateway module so callers continue to use
Client::set_secret / list_secret_keys / delete_secret unchanged.

@antidmg antidmg force-pushed the fix/secrets-api-endpoints branch from 819b119 to d8e8823 Compare February 28, 2026 04:30
@antidmg antidmg force-pushed the fix/secrets-api-endpoints branch from d8e8823 to 3253f4d Compare February 28, 2026 04:31
@antidmg antidmg merged commit a02ecbe into main Feb 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant