feat: add typed secrets metadata and API

This commit is contained in:
Bryan Helmkamp 2026-04-12 11:13:47 -04:00
parent af97e6cb8e
commit 884c4d4314
No known key found for this signature in database
21 changed files with 771 additions and 224 deletions

View file

@ -1327,24 +1327,16 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/SecretListResponse"
/api/v1/secrets/{name}:
put:
operationId: setSecret
post:
operationId: createSecret
tags: [Secrets]
summary: Store or update a secret
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetSecretRequest"
$ref: "#/components/schemas/CreateSecretRequest"
responses:
"200":
description: Secret stored
@ -1359,20 +1351,20 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteSecret
operationId: deleteSecretByName
tags: [Secrets]
summary: Delete a stored secret
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DeleteSecretRequest"
responses:
"204":
description: Secret deleted
"400":
description: Invalid secret name
description: Invalid secret name or request body
content:
application/json:
schema:
@ -4329,28 +4321,61 @@ components:
description: Server version string.
example: "0.176.2"
SetSecretRequest:
description: Request to store a secret value.
SecretType:
description: The way a secret is consumed by the sandbox.
type: string
enum:
- environment
- file
CreateSecretRequest:
description: Request to store or update a secret.
type: object
required:
- name
- value
- type
properties:
name:
type: string
description: Secret name or destination path for file secrets.
value:
type: string
description: The secret value to store.
type:
$ref: "#/components/schemas/SecretType"
description:
type: string
description: Optional operator-facing description of the secret.
DeleteSecretRequest:
description: Request to delete a secret by name.
type: object
required:
- name
properties:
name:
type: string
description: Secret name or destination path for file secrets.
SecretMetadata:
description: Metadata for a stored secret (value is never exposed).
type: object
required:
- name
- type
- created_at
- updated_at
properties:
name:
type: string
description: Secret key name.
description: Secret key name or destination path.
example: ANTHROPIC_API_KEY
type:
$ref: "#/components/schemas/SecretType"
description:
type: string
description: Optional operator-facing description of the secret.
created_at:
type: string
format: date-time

View file

@ -489,12 +489,22 @@ pub(crate) struct SecretRmArgs {
pub(crate) key: String,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub(crate) enum SecretTypeArg {
Environment,
File,
}
#[derive(Args)]
pub(crate) struct SecretSetArgs {
/// Name of the secret
pub(crate) key: String,
pub(crate) key: String,
/// Value to store
pub(crate) value: String,
pub(crate) value: String,
#[arg(long, value_enum, default_value = "environment")]
pub(crate) r#type: SecretTypeArg,
#[arg(long)]
pub(crate) description: Option<String>,
}
#[derive(Debug, Args)]

View file

@ -12,11 +12,11 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use dialoguer::console::Term;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{MultiSelect, Select};
use fabro_api::types::SetSecretRequest;
use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType};
use fabro_config::user::SETTINGS_CONFIG_FILENAME;
use fabro_config::{Storage, legacy_env};
use fabro_model::Provider;
use fabro_server::secret_store::SecretStore;
use fabro_server::secret_store::{SecretStore, SecretType};
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use rand::Rng;
@ -687,10 +687,12 @@ async fn persist_install_secrets(
let client = server_client::connect_api_client(storage_dir).await?;
for (name, value) in secrets {
client
.set_secret()
.name(name.clone())
.body(SetSecretRequest {
value: value.clone(),
.create_secret()
.body(CreateSecretRequest {
name: name.clone(),
value: value.clone(),
type_: ApiSecretType::Environment,
description: None,
})
.send()
.await?;
@ -700,7 +702,7 @@ async fn persist_install_secrets(
let mut store = SecretStore::load(Storage::new(storage_dir).secrets_path())?;
for (name, value) in secrets {
store.set(name, value)?;
store.set(name, value, SecretType::Environment, None)?;
}
Ok(())
}

View file

@ -46,9 +46,13 @@ pub(super) async fn login_command(
for (name, value) in env_pairs {
server
.api()
.set_secret()
.name(name.clone())
.body(types::SetSecretRequest { value })
.create_secret()
.body(types::CreateSecretRequest {
name: name.clone(),
value,
type_: types::SecretType::Environment,
description: None,
})
.send()
.await?;
fabro_util::printerr!(printer, " {} Saved {}", s.green.apply_to("✔"), name);

View file

@ -24,7 +24,13 @@ pub(super) async fn list_command(
}
let _ = args;
for secret in secrets {
fabro_util::printout!(printer, "{}\t{}", secret.name, secret.updated_at);
fabro_util::printout!(
printer,
"{}\t{}\t{}",
secret.name,
secret.type_,
secret.updated_at
);
}
Ok(())
}

View file

@ -1,5 +1,5 @@
use anyhow::Result;
use fabro_api::Client;
use fabro_api::{Client, types};
use fabro_util::printer::Printer;
use crate::args::{GlobalArgs, SecretRmArgs};
@ -13,8 +13,10 @@ pub(super) async fn rm_command(
printer: Printer,
) -> Result<()> {
client
.delete_secret()
.name(args.key.clone())
.delete_secret_by_name()
.body(types::DeleteSecretRequest {
name: args.key.clone(),
})
.send()
.await
.map_err(server_client::map_api_error)?;

View file

@ -2,10 +2,17 @@ use anyhow::Result;
use fabro_api::{Client, types};
use fabro_util::printer::Printer;
use crate::args::{GlobalArgs, SecretSetArgs};
use crate::args::{GlobalArgs, SecretSetArgs, SecretTypeArg};
use crate::server_client;
use crate::shared::print_json_pretty;
fn api_secret_type(secret_type: SecretTypeArg) -> types::SecretType {
match secret_type {
SecretTypeArg::Environment => types::SecretType::Environment,
SecretTypeArg::File => types::SecretType::File,
}
}
pub(super) async fn set_command(
client: &Client,
args: &SecretSetArgs,
@ -13,10 +20,12 @@ pub(super) async fn set_command(
printer: Printer,
) -> Result<()> {
let meta = client
.set_secret()
.name(args.key.clone())
.body(types::SetSecretRequest {
value: args.value.clone(),
.create_secret()
.body(types::CreateSecretRequest {
name: args.key.clone(),
value: args.value.clone(),
type_: api_secret_type(args.r#type),
description: args.description.clone(),
})
.send()
.await

View file

@ -47,7 +47,7 @@ fn test_secret_lifecycle() {
// 2. list -> contains FOO
secret(&["list"])
.success()
.stdout(predicates::str::contains("FOO"));
.stdout(predicates::str::contains("FOO\tenvironment"));
// 3. update FOO
secret(&["set", "FOO", "updated"]).success();
@ -90,7 +90,7 @@ fn test_secret_list_alias_ls() {
.args(["ls"])
.assert()
.success()
.stdout(predicates::str::contains("X"));
.stdout(predicates::str::contains("X\tenvironment"));
}
#[test]
@ -122,6 +122,36 @@ fn test_secret_value_with_equals() {
.args(["list"])
.assert()
.success()
.stdout(predicates::str::contains("URL"))
.stdout(predicates::str::contains("URL\tenvironment"))
.stdout(predicates::str::contains("https://x.com?a=1&b=2").not());
}
#[test]
fn test_file_secret_lifecycle() {
let context = test_context!();
let secret =
|args: &[&str]| -> assert_cmd::assert::Assert { context.secret().args(args).assert() };
secret(&[
"set",
"/tmp/test.pem",
"pem-data",
"--type",
"file",
"--description",
"Test certificate",
])
.success();
secret(&["list"])
.success()
.stdout(predicates::str::contains("/tmp/test.pem\tfile"))
.stdout(predicates::str::contains("pem-data").not());
secret(&["rm", "/tmp/test.pem"]).success();
secret(&["list"])
.success()
.stdout(predicates::str::contains("/tmp/test.pem").not());
}

View file

@ -47,6 +47,7 @@ fn secret_list_json_returns_metadata_only() {
.iter()
.find(|entry| entry["name"] == "ANTHROPIC_API_KEY")
.expect("secret list should include the saved key");
assert_eq!(entry["type"], "environment");
assert!(entry.get("updated_at").is_some());
assert!(entry.get("value").is_none());
}

View file

@ -18,12 +18,14 @@ fn help() {
<VALUE> Value to store
Options:
--json Output as JSON [env: FABRO_JSON=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--type <TYPE> [default: environment] [possible values: environment, file]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--description <DESCRIPTION>
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}

View file

@ -11,7 +11,7 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use fabro_api::types::RunArtifactListResponse;
use fabro_api::types::{CreateSecretRequest, DeleteSecretRequest, RunArtifactListResponse};
use serde_json::json;
use crate::error::ApiError;
@ -316,11 +316,13 @@ pub(crate) async fn list_secrets(
"data": [
{
"name": "OPENAI_API_KEY",
"type": "environment",
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-05T12:00:00Z"
},
{
"name": "GITHUB_APP_PRIVATE_KEY",
"type": "environment",
"created_at": "2026-04-05T12:05:00Z",
"updated_at": "2026-04-05T12:05:00Z"
}
@ -330,26 +332,27 @@ pub(crate) async fn list_secrets(
.into_response()
}
pub(crate) async fn set_secret(
pub(crate) async fn create_secret(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<CreateSecretRequest>,
) -> Response {
(
StatusCode::OK,
Json(json!({
"name": name,
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-05T12:00:00Z"
})),
)
.into_response()
let mut payload = serde_json::Map::new();
payload.insert("name".to_string(), json!(body.name));
payload.insert("type".to_string(), json!(body.type_));
if let Some(description) = body.description {
payload.insert("description".to_string(), json!(description));
}
payload.insert("created_at".to_string(), json!("2026-04-05T12:00:00Z"));
payload.insert("updated_at".to_string(), json!("2026-04-05T12:00:00Z"));
(StatusCode::OK, Json(serde_json::Value::Object(payload))).into_response()
}
pub(crate) async fn delete_secret(
pub(crate) async fn delete_secret_by_name(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_name): Path<String>,
Json(_body): Json<DeleteSecretRequest>,
) -> Response {
StatusCode::NO_CONTENT.into_response()
}

View file

@ -1,19 +1,35 @@
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecretType {
#[default]
Environment,
File,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SecretEntry {
pub value: String,
pub created_at: String,
pub updated_at: String,
pub value: String,
#[serde(rename = "type", default)]
pub secret_type: SecretType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SecretMetadata {
pub name: String,
pub created_at: String,
pub updated_at: String,
pub name: String,
#[serde(rename = "type")]
pub secret_type: SecretType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug)]
@ -66,16 +82,31 @@ impl SecretStore {
Ok(Self { path, entries })
}
pub fn set(&mut self, name: &str, value: &str) -> Result<SecretMetadata, SecretStoreError> {
Self::validate_name(name)?;
pub fn set(
&mut self,
name: &str,
value: &str,
secret_type: SecretType,
description: Option<&str>,
) -> Result<SecretMetadata, SecretStoreError> {
Self::validate_name(name, secret_type)?;
let now = chrono::Utc::now().to_rfc3339();
let created_at = self
.entries
.get(name)
.map_or_else(|| now.clone(), |entry| entry.created_at.clone());
let (created_at, description) = self.entries.get(name).map_or_else(
|| (now.clone(), description.map(str::to_string)),
|entry| {
(
entry.created_at.clone(),
description
.map(str::to_string)
.or_else(|| entry.description.clone()),
)
},
);
let entry = SecretEntry {
value: value.to_string(),
value: value.to_string(),
secret_type,
description: description.clone(),
created_at: created_at.clone(),
updated_at: now.clone(),
};
@ -84,13 +115,14 @@ impl SecretStore {
Ok(SecretMetadata {
name: name.to_string(),
secret_type,
description,
created_at,
updated_at: now,
})
}
pub fn remove(&mut self, name: &str) -> Result<(), SecretStoreError> {
Self::validate_name(name)?;
if self.entries.remove(name).is_none() {
return Err(SecretStoreError::NotFound(name.to_string()));
}
@ -103,9 +135,11 @@ impl SecretStore {
.entries
.iter()
.map(|(name, entry)| SecretMetadata {
name: name.clone(),
created_at: entry.created_at.clone(),
updated_at: entry.updated_at.clone(),
name: name.clone(),
secret_type: entry.secret_type,
description: entry.description.clone(),
created_at: entry.created_at.clone(),
updated_at: entry.updated_at.clone(),
})
.collect::<Vec<_>>();
data.sort_by(|a, b| a.name.cmp(&b.name));
@ -119,11 +153,30 @@ impl SecretStore {
pub fn snapshot(&self) -> HashMap<String, String> {
self.entries
.iter()
.filter(|(_, entry)| entry.secret_type == SecretType::Environment)
.map(|(name, entry)| (name.clone(), entry.value.clone()))
.collect()
}
pub fn validate_name(name: &str) -> Result<(), SecretStoreError> {
pub fn file_secrets(&self) -> Vec<(String, String)> {
let mut data = self
.entries
.iter()
.filter(|(_, entry)| entry.secret_type == SecretType::File)
.map(|(name, entry)| (name.clone(), entry.value.clone()))
.collect::<Vec<_>>();
data.sort_by(|a, b| a.0.cmp(&b.0));
data
}
pub fn validate_name(name: &str, secret_type: SecretType) -> Result<(), SecretStoreError> {
match secret_type {
SecretType::Environment => Self::validate_env_name(name),
SecretType::File => Self::validate_file_name(name),
}
}
fn validate_env_name(name: &str) -> Result<(), SecretStoreError> {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
@ -137,6 +190,26 @@ impl SecretStore {
}
}
fn validate_file_name(name: &str) -> Result<(), SecretStoreError> {
if !name.starts_with('/') || name.ends_with('/') || name.contains('\0') {
return Err(SecretStoreError::InvalidName(name.to_string()));
}
let path = Path::new(name);
if !path.is_absolute() {
return Err(SecretStoreError::InvalidName(name.to_string()));
}
if path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(SecretStoreError::InvalidName(name.to_string()));
}
Ok(())
}
fn write_atomic(&self) -> Result<(), SecretStoreError> {
let parent = self
.path
@ -188,9 +261,13 @@ mod tests {
let path = dir.path().join("secrets.json");
let mut store = SecretStore::load(path.clone()).unwrap();
let meta = store.set("OPENAI_API_KEY", "secret").unwrap();
let meta = store
.set("OPENAI_API_KEY", "secret", SecretType::Environment, None)
.unwrap();
assert_eq!(meta.name, "OPENAI_API_KEY");
assert_eq!(meta.secret_type, SecretType::Environment);
assert_eq!(meta.description, None);
assert_eq!(store.get("OPENAI_API_KEY"), Some("secret"));
assert!(path.exists());
}
@ -201,8 +278,12 @@ mod tests {
let path = dir.path().join("secrets.json");
let mut store = SecretStore::load(path).unwrap();
let first = store.set("OPENAI_API_KEY", "first").unwrap();
let second = store.set("OPENAI_API_KEY", "second").unwrap();
let first = store
.set("OPENAI_API_KEY", "first", SecretType::Environment, None)
.unwrap();
let second = store
.set("OPENAI_API_KEY", "second", SecretType::Environment, None)
.unwrap();
assert_eq!(first.created_at, second.created_at);
assert_eq!(store.get("OPENAI_API_KEY"), Some("second"));
@ -213,7 +294,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
let mut store = SecretStore::load(path.clone()).unwrap();
store.set("OPENAI_API_KEY", "secret").unwrap();
store
.set("OPENAI_API_KEY", "secret", SecretType::Environment, None)
.unwrap();
store.remove("OPENAI_API_KEY").unwrap();
@ -234,8 +317,12 @@ mod tests {
fn list_returns_sorted_metadata_without_values() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
store.set("Z_KEY", "z").unwrap();
store.set("A_KEY", "a").unwrap();
store
.set("Z_KEY", "z", SecretType::Environment, None)
.unwrap();
store
.set("A_KEY", "a", SecretType::Environment, None)
.unwrap();
let listed = store.list();
@ -252,7 +339,177 @@ mod tests {
fn invalid_names_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
let error = store.set("NOT-VALID", "secret").unwrap_err();
let error = store
.set("NOT-VALID", "secret", SecretType::Environment, None)
.unwrap_err();
assert_eq!(error.to_string(), "invalid secret name: NOT-VALID");
}
#[test]
fn set_file_secret_stores_type() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
let mut store = SecretStore::load(path.clone()).unwrap();
let meta = store
.set("/root/.ssh/id_rsa", "secret", SecretType::File, None)
.unwrap();
assert_eq!(meta.name, "/root/.ssh/id_rsa");
assert_eq!(meta.secret_type, SecretType::File);
let reloaded = SecretStore::load(path).unwrap();
let listed = reloaded.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].secret_type, SecretType::File);
}
#[test]
fn set_file_secret_validates_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
let error = store
.set("relative/path", "secret", SecretType::File, None)
.unwrap_err();
assert_eq!(error.to_string(), "invalid secret name: relative/path");
}
#[test]
fn set_file_secret_rejects_traversal() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
let error = store
.set("/root/../id_rsa", "secret", SecretType::File, None)
.unwrap_err();
assert_eq!(error.to_string(), "invalid secret name: /root/../id_rsa");
}
#[test]
fn set_env_secret_rejects_path_names() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
let error = store
.set("/foo/bar", "secret", SecretType::Environment, None)
.unwrap_err();
assert_eq!(error.to_string(), "invalid secret name: /foo/bar");
}
#[test]
fn snapshot_excludes_file_secrets() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
store
.set("OPENAI_API_KEY", "env", SecretType::Environment, None)
.unwrap();
store
.set("/tmp/test.pem", "file", SecretType::File, None)
.unwrap();
let snapshot = store.snapshot();
assert_eq!(snapshot.get("OPENAI_API_KEY"), Some(&"env".to_string()));
assert!(!snapshot.contains_key("/tmp/test.pem"));
}
#[test]
fn snapshot_includes_only_env_secrets() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
store
.set("ANTHROPIC_API_KEY", "a", SecretType::Environment, None)
.unwrap();
store
.set("OPENAI_API_KEY", "b", SecretType::Environment, None)
.unwrap();
let snapshot = store.snapshot();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot.get("ANTHROPIC_API_KEY"), Some(&"a".to_string()));
assert_eq!(snapshot.get("OPENAI_API_KEY"), Some(&"b".to_string()));
}
#[test]
fn file_secrets_returns_only_files() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
store
.set("OPENAI_API_KEY", "env", SecretType::Environment, None)
.unwrap();
store
.set("/tmp/test.pem", "file", SecretType::File, None)
.unwrap();
let files = store.file_secrets();
assert_eq!(files, vec![(
"/tmp/test.pem".to_string(),
"file".to_string()
)]);
}
#[test]
fn description_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
let mut store = SecretStore::load(path.clone()).unwrap();
let meta = store
.set(
"/tmp/test.pem",
"file",
SecretType::File,
Some("Test certificate"),
)
.unwrap();
assert_eq!(meta.description.as_deref(), Some("Test certificate"));
let reloaded = SecretStore::load(path).unwrap();
let listed = reloaded.list();
assert_eq!(listed[0].description.as_deref(), Some("Test certificate"));
}
#[test]
fn legacy_json_defaults_to_environment() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
std::fs::write(
&path,
r#"{
"OPENAI_API_KEY": {
"value": "secret",
"created_at": "2026-04-12T00:00:00Z",
"updated_at": "2026-04-12T00:00:00Z"
}
}"#,
)
.unwrap();
let store = SecretStore::load(path).unwrap();
let listed = store.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].secret_type, SecretType::Environment);
assert_eq!(listed[0].description, None);
}
#[test]
fn remove_allows_file_path_names() {
let dir = tempfile::tempdir().unwrap();
let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap();
store
.set("/tmp/test.pem", "file", SecretType::File, None)
.unwrap();
store.remove("/tmp/test.pem").unwrap();
assert!(store.list().is_empty());
}
}

View file

@ -15,7 +15,7 @@ use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::routing::{get, post};
use axum::{Json, Router};
use axum_extra::extract::cookie::Key;
use base64::Engine as _;
@ -26,16 +26,16 @@ pub use fabro_api::types::{
ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel,
BillingStageRef, CompletionContentPart, CompletionMessage, CompletionMessageRole,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope,
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat,
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling,
RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError,
RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse,
ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest,
StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts,
WriteBlobResponse,
CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow,
DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList,
PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse,
PruneRunEntry, PruneRunsRequest, PruneRunsResponse, QuestionType as ApiQuestionType,
RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest,
RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStatus, RunStatusResponse,
SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType, ServerSettings,
SshAccessRequest, SshAccessResponse, StartRunRequest, StatusReason as ApiStatusReason,
SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
};
use fabro_config::{Storage, resolve_server_from_file};
use fabro_graphviz::render::GraphFormat;
@ -112,7 +112,7 @@ use crate::error::ApiError;
use crate::jwt_auth::{
AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts,
};
use crate::secret_store::{SecretStore, SecretStoreError};
use crate::secret_store::{SecretStore, SecretStoreError, SecretType as StoreSecretType};
use crate::{demo, diagnostics, run_manifest, settings_view, static_files, web_auth};
pub fn default_page_limit() -> u32 {
@ -1027,10 +1027,11 @@ fn demo_routes() -> Router<Arc<AppState>> {
.route("/insights/history", get(demo::list_query_history))
.route("/models", get(list_models))
.route("/models/{id}/test", post(test_model))
.route("/secrets", get(demo::list_secrets))
.route(
"/secrets/{name}",
put(demo::set_secret).delete(demo::delete_secret),
"/secrets",
get(demo::list_secrets)
.post(demo::create_secret)
.delete(demo::delete_secret_by_name),
)
.route("/repos/github/{owner}/{name}", get(demo::get_github_repo))
.route("/health/diagnostics", post(demo::run_diagnostics))
@ -1106,8 +1107,12 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/insights/history", get(not_implemented))
.route("/models", get(list_models))
.route("/models/{id}/test", post(test_model))
.route("/secrets", get(list_secrets))
.route("/secrets/{name}", put(set_secret).delete(delete_secret))
.route(
"/secrets",
get(list_secrets)
.post(create_secret)
.delete(delete_secret_by_name),
)
.route("/repos/github/{owner}/{name}", get(get_github_repo))
.route("/health/diagnostics", post(run_diagnostics))
.route("/completions", post(create_completion))
@ -1621,16 +1626,26 @@ async fn list_secrets(_auth: AuthenticatedService, State(state): State<Arc<AppSt
(StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response()
}
async fn set_secret(
fn secret_type_from_api(secret_type: ApiSecretType) -> StoreSecretType {
match secret_type {
ApiSecretType::Environment => StoreSecretType::Environment,
ApiSecretType::File => StoreSecretType::File,
}
}
async fn create_secret(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<SetSecretRequest>,
Json(body): Json<CreateSecretRequest>,
) -> Response {
let secret_type = secret_type_from_api(body.type_);
let name = body.name;
let value = body.value;
let description = body.description;
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut store = state_for_write.secret_store.blocking_write();
store.set(&name, &body.value)
store.set(&name, &value, secret_type, description.as_deref())
})
.await;
@ -1658,11 +1673,12 @@ async fn set_secret(
}
}
async fn delete_secret(
async fn delete_secret_by_name(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<DeleteSecretRequest>,
) -> Response {
let name = body.name;
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut store = state_for_write.secret_store.blocking_write();
@ -6199,6 +6215,80 @@ mod tests {
format!("/api/v1{path}")
}
#[tokio::test]
async fn create_secret_stores_file_secret_and_excludes_it_from_snapshot() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri(api("/secrets"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"name": "/tmp/test.pem",
"value": "pem-data",
"type": "file",
"description": "Test certificate",
}))
.unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["name"], "/tmp/test.pem");
assert_eq!(body["type"], "file");
assert_eq!(body["description"], "Test certificate");
let store = state.secret_store.read().await;
assert!(!store.snapshot().contains_key("/tmp/test.pem"));
assert_eq!(store.file_secrets(), vec![(
"/tmp/test.pem".to_string(),
"pem-data".to_string()
)]);
}
#[tokio::test]
async fn delete_secret_by_name_removes_file_secret() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let create_req = Request::builder()
.method("POST")
.uri(api("/secrets"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"name": "/tmp/test.pem",
"value": "pem-data",
"type": "file",
}))
.unwrap(),
))
.unwrap();
let create_response = app.clone().oneshot(create_req).await.unwrap();
assert_eq!(create_response.status(), StatusCode::OK);
let delete_req = Request::builder()
.method("DELETE")
.uri(api("/secrets"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"name": "/tmp/test.pem",
}))
.unwrap(),
))
.unwrap();
let delete_response = app.oneshot(delete_req).await.unwrap();
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
assert!(state.secret_store.read().await.list().is_empty());
}
#[tokio::test]
async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);

View file

@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info, warn};
use crate::secret_store::SecretType;
use crate::server::AppState;
fn github_http_client(context: &str) -> Result<fabro_http::HttpClient, Response> {
@ -653,7 +654,7 @@ async fn setup_register(
{
let mut store = state.secret_store.write().await;
for (name, value) in secret_updates {
if let Err(err) = store.set(name, &value) {
if let Err(err) = store.set(name, &value, SecretType::Environment, None) {
error!(error = %err, secret = name, "Setup register failed: could not save secret");
return json_response(
StatusCode::INTERNAL_SERVER_ERROR,

View file

@ -42,6 +42,8 @@ models/completion-tool-choice.ts
models/completion-tool-definition.ts
models/completion-usage.ts
models/create-completion-request.ts
models/create-secret-request.ts
models/delete-secret-request.ts
models/diagnostics-check.ts
models/diagnostics-detail.ts
models/diagnostics-report.ts
@ -142,7 +144,7 @@ models/save-query-request.ts
models/saved-query.ts
models/secret-list-response.ts
models/secret-metadata.ts
models/set-secret-request.ts
models/secret-type.ts
models/ssh-access-request.ts
models/ssh-access-response.ts
models/stage-status.ts

View file

@ -22,13 +22,15 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreateSecretRequest } from '../models';
// @ts-ignore
import type { DeleteSecretRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { SecretListResponse } from '../models';
// @ts-ignore
import type { SecretMetadata } from '../models';
// @ts-ignore
import type { SetSecretRequest } from '../models';
/**
* SecretsApi - axios parameter creator
*/
@ -36,16 +38,57 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati
return {
/**
*
* @summary Delete a stored secret
* @param {string} name
* @summary Store or update a secret
* @param {CreateSecretRequest} createSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSecret: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('deleteSecret', 'name', name)
const localVarPath = `/api/v1/secrets/{name}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
createSecret: async (createSecretRequest: CreateSecretRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createSecretRequest' is not null or undefined
assertParamExists('createSecret', 'createSecretRequest', createSecretRequest)
const localVarPath = `/api/v1/secrets`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication mTLS required
await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createSecretRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Delete a stored secret
* @param {DeleteSecretRequest} deleteSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSecretByName: async (deleteSecretRequest: DeleteSecretRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'deleteSecretRequest' is not null or undefined
assertParamExists('deleteSecretByName', 'deleteSecretRequest', deleteSecretRequest)
const localVarPath = `/api/v1/secrets`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@ -64,11 +107,13 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(deleteSecretRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -107,52 +152,6 @@ export const SecretsApiAxiosParamCreator = function (configuration?: Configurati
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Store or update a secret
* @param {string} name
* @param {SetSecretRequest} setSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setSecret: async (name: string, setSecretRequest: SetSecretRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('setSecret', 'name', name)
// verify required parameter 'setSecretRequest' is not null or undefined
assertParamExists('setSecret', 'setSecretRequest', setSecretRequest)
const localVarPath = `/api/v1/secrets/{name}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication mTLS required
await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(setSecretRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
@ -169,15 +168,28 @@ export const SecretsApiFp = function(configuration?: Configuration) {
return {
/**
*
* @summary Delete a stored secret
* @param {string} name
* @summary Store or update a secret
* @param {CreateSecretRequest} createSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteSecret(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSecret(name, options);
async createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SecretMetadata>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createSecret(createSecretRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['SecretsApi.deleteSecret']?.[localVarOperationServerIndex]?.url;
const localVarOperationServerBasePath = operationServerMap['SecretsApi.createSecret']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Delete a stored secret
* @param {DeleteSecretRequest} deleteSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSecretByName(deleteSecretRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['SecretsApi.deleteSecretByName']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
@ -192,20 +204,6 @@ export const SecretsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['SecretsApi.listSecrets']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Store or update a secret
* @param {string} name
* @param {SetSecretRequest} setSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SecretMetadata>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.setSecret(name, setSecretRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['SecretsApi.setSecret']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
@ -217,13 +215,23 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
return {
/**
*
* @summary Delete a stored secret
* @param {string} name
* @summary Store or update a secret
* @param {CreateSecretRequest} createSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSecret(name: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteSecret(name, options).then((request) => request(axios, basePath));
createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise<SecretMetadata> {
return localVarFp.createSecret(createSecretRequest, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Delete a stored secret
* @param {DeleteSecretRequest} deleteSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteSecretByName(deleteSecretRequest, options).then((request) => request(axios, basePath));
},
/**
* Returns stored secret names and timestamps. Secret values are never exposed.
@ -234,17 +242,6 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
listSecrets(options?: RawAxiosRequestConfig): AxiosPromise<SecretListResponse> {
return localVarFp.listSecrets(options).then((request) => request(axios, basePath));
},
/**
*
* @summary Store or update a secret
* @param {string} name
* @param {SetSecretRequest} setSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise<SecretMetadata> {
return localVarFp.setSecret(name, setSecretRequest, options).then((request) => request(axios, basePath));
},
};
};
@ -254,13 +251,24 @@ export const SecretsApiFactory = function (configuration?: Configuration, basePa
export class SecretsApi extends BaseAPI {
/**
*
* @summary Delete a stored secret
* @param {string} name
* @summary Store or update a secret
* @param {CreateSecretRequest} createSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public deleteSecret(name: string, options?: RawAxiosRequestConfig) {
return SecretsApiFp(this.configuration).deleteSecret(name, options).then((request) => request(this.axios, this.basePath));
public createSecret(createSecretRequest: CreateSecretRequest, options?: RawAxiosRequestConfig) {
return SecretsApiFp(this.configuration).createSecret(createSecretRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Delete a stored secret
* @param {DeleteSecretRequest} deleteSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public deleteSecretByName(deleteSecretRequest: DeleteSecretRequest, options?: RawAxiosRequestConfig) {
return SecretsApiFp(this.configuration).deleteSecretByName(deleteSecretRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -272,17 +280,5 @@ export class SecretsApi extends BaseAPI {
public listSecrets(options?: RawAxiosRequestConfig) {
return SecretsApiFp(this.configuration).listSecrets(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Store or update a secret
* @param {string} name
* @param {SetSecretRequest} setSecretRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig) {
return SecretsApiFp(this.configuration).setSecret(name, setSecretRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -0,0 +1,40 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SecretType } from './secret-type';
/**
* Request to store or update a secret.
*/
export interface CreateSecretRequest {
/**
* Secret name or destination path for file secrets.
*/
'name': string;
/**
* The secret value to store.
*/
'value': string;
'type': SecretType;
/**
* Optional operator-facing description of the secret.
*/
'description'?: string;
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request to delete a secret by name.
*/
export interface DeleteSecretRequest {
/**
* Secret name or destination path for file secrets.
*/
'name': string;
}

View file

@ -24,6 +24,8 @@ export * from './completion-tool-choice';
export * from './completion-tool-definition';
export * from './completion-usage';
export * from './create-completion-request';
export * from './create-secret-request';
export * from './delete-secret-request';
export * from './diagnostics-check';
export * from './diagnostics-detail';
export * from './diagnostics-report';
@ -123,7 +125,7 @@ export * from './save-query-request';
export * from './saved-query';
export * from './secret-list-response';
export * from './secret-metadata';
export * from './set-secret-request';
export * from './secret-type';
export * from './ssh-access-request';
export * from './ssh-access-response';
export * from './stage-status';

View file

@ -13,15 +13,23 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SecretType } from './secret-type';
/**
* Metadata for a stored secret (value is never exposed).
*/
export interface SecretMetadata {
/**
* Secret key name.
* Secret key name or destination path.
*/
'name': string;
'type': SecretType;
/**
* Optional operator-facing description of the secret.
*/
'description'?: string;
/**
* When the secret was first stored.
*/
@ -32,3 +40,5 @@ export interface SecretMetadata {
'updated_at': string;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* The way a secret is consumed by the sandbox.
*/
export const SecretType = {
ENVIRONMENT: 'environment',
FILE: 'file'
} as const;
export type SecretType = typeof SecretType[keyof typeof SecretType];