chore(release): secrets-mcp 0.4.0
Bump version for the N:N entry_secrets data model and related MCP/Web changes. Remove superseded SQL migration artifacts; rely on auto-migrate. Add structured errors, taxonomy normalization, and web i18n helpers. Made-with: Cursor
This commit is contained in:
@@ -7,7 +7,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::error::{AppError, DbErrorContext};
|
||||
use crate::models::EntryRow;
|
||||
use crate::taxonomy;
|
||||
|
||||
// ── Key/value parsing helpers ─────────────────────────────────────────────────
|
||||
|
||||
@@ -177,13 +179,19 @@ pub struct AddParams<'a> {
|
||||
pub tags: &'a [String],
|
||||
pub meta_entries: &'a [String],
|
||||
pub secret_entries: &'a [String],
|
||||
pub secret_types: &'a std::collections::HashMap<String, String>,
|
||||
pub link_secret_names: &'a [String],
|
||||
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
||||
let metadata = build_json(params.meta_entries)?;
|
||||
let Value::Object(mut metadata_map) = build_json(params.meta_entries)? else {
|
||||
unreachable!("build_json always returns a JSON object");
|
||||
};
|
||||
let normalized_entry_type =
|
||||
taxonomy::normalize_entry_type_and_metadata(params.entry_type, &mut metadata_map);
|
||||
let metadata = Value::Object(metadata_map);
|
||||
let secret_json = build_json(params.secret_entries)?;
|
||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
||||
let secret_keys = collect_key_paths(params.secret_entries)?;
|
||||
@@ -224,7 +232,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
entry_id: ex.id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
entry_type: &normalized_entry_type,
|
||||
name: params.name,
|
||||
version: ex.version,
|
||||
action: "add",
|
||||
@@ -254,7 +262,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(&normalized_entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -277,7 +285,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(params.folder)
|
||||
.bind(params.entry_type)
|
||||
.bind(&normalized_entry_type)
|
||||
.bind(params.name)
|
||||
.bind(params.notes)
|
||||
.bind(params.tags)
|
||||
@@ -299,7 +307,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
entry_id,
|
||||
user_id: params.user_id,
|
||||
folder: params.folder,
|
||||
entry_type: params.entry_type,
|
||||
entry_type: &normalized_entry_type,
|
||||
name: params.name,
|
||||
version: current_entry_version,
|
||||
action: "create",
|
||||
@@ -345,30 +353,42 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
}
|
||||
}
|
||||
|
||||
let orphan_candidates: Vec<Uuid> = existing_fields.iter().map(|f| f.id).collect();
|
||||
|
||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
|
||||
.bind(entry_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if !orphan_candidates.is_empty() {
|
||||
sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = ANY($1) \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(&orphan_candidates)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
for (field_name, field_value) in &flat_fields {
|
||||
let encrypted = crypto::encrypt_json(master_key, field_value)?;
|
||||
let secret_type = params
|
||||
.secret_types
|
||||
.get(field_name)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("text");
|
||||
let secret_id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(params.user_id)
|
||||
.bind(field_name)
|
||||
.bind(infer_secret_type(field_name))
|
||||
.bind(secret_type)
|
||||
.bind(&encrypted)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| AppError::from_db_error(e, DbErrorContext::secret_name(field_name)))?;
|
||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
||||
.bind(entry_id)
|
||||
.bind(secret_id)
|
||||
@@ -414,7 +434,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
params.user_id,
|
||||
"add",
|
||||
params.folder,
|
||||
params.entry_type,
|
||||
&normalized_entry_type,
|
||||
params.name,
|
||||
serde_json::json!({
|
||||
"tags": params.tags,
|
||||
@@ -429,32 +449,13 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
||||
Ok(AddResult {
|
||||
name: params.name.to_string(),
|
||||
folder: params.folder.to_string(),
|
||||
entry_type: params.entry_type.to_string(),
|
||||
entry_type: normalized_entry_type,
|
||||
tags: params.tags.to_vec(),
|
||||
meta_keys,
|
||||
secret_keys,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn infer_secret_type(name: &str) -> &'static str {
|
||||
match name {
|
||||
"ssh_key" => "pem",
|
||||
"password" => "password",
|
||||
"phone" | "phone_2" => "phone",
|
||||
"webhook_url" | "address" => "url",
|
||||
"access_key_id"
|
||||
| "access_key_secret"
|
||||
| "global_api_key"
|
||||
| "api_key"
|
||||
| "secret_key"
|
||||
| "personal_access_token"
|
||||
| "runner_token"
|
||||
| "GOOGLE_CLIENT_ID"
|
||||
| "GOOGLE_CLIENT_SECRET" => "token",
|
||||
_ => "text",
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_link_secret_names(
|
||||
link_secret_names: &[String],
|
||||
new_secret_names: &BTreeSet<String>,
|
||||
@@ -601,6 +602,7 @@ mod tests {
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
@@ -647,6 +649,7 @@ mod tests {
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
@@ -697,6 +700,7 @@ mod tests {
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: std::slice::from_ref(&secret_name),
|
||||
user_id: None,
|
||||
},
|
||||
@@ -709,4 +713,69 @@ mod tests {
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_duplicate_secret_name_returns_conflict_error() -> Result<()> {
|
||||
let Some(pool) = maybe_test_pool().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
||||
let marker = format!("dup_secret_{}", &suffix[..8]);
|
||||
let entry_name = format!("{}_entry", marker);
|
||||
let secret_name = "shared_token";
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
|
||||
// First add succeeds
|
||||
run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[format!("{}=value1", secret_name)],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: &[],
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Second add with same secret name under same user_id should fail with ConflictSecretName
|
||||
let entry_name2 = format!("{}_entry2", marker);
|
||||
let err = run(
|
||||
&pool,
|
||||
AddParams {
|
||||
name: &entry_name2,
|
||||
folder: &marker,
|
||||
entry_type: "service",
|
||||
notes: "",
|
||||
tags: &[],
|
||||
meta_entries: &[],
|
||||
secret_entries: &[format!("{}=value2", secret_name)],
|
||||
secret_types: &Default::default(),
|
||||
link_secret_names: &[],
|
||||
user_id: None,
|
||||
},
|
||||
&[0_u8; 32],
|
||||
)
|
||||
.await
|
||||
.expect_err("must fail on duplicate secret name");
|
||||
|
||||
let app_err = err
|
||||
.downcast_ref::<crate::error::AppError>()
|
||||
.expect("error should be AppError");
|
||||
assert!(
|
||||
matches!(app_err, crate::error::AppError::ConflictSecretName { .. }),
|
||||
"expected ConflictSecretName, got: {}",
|
||||
app_err
|
||||
);
|
||||
|
||||
cleanup_test_rows(&pool, &marker).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user