release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m1s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been skipped

This commit is contained in:
voson
2026-04-12 15:48:22 +08:00
parent 34093b0e23
commit cb5865b958
19 changed files with 3515 additions and 453 deletions

View File

@@ -0,0 +1,894 @@
use askama::Template;
use axum::{
Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::PgPool;
use tower_sessions::Session;
use uuid::Uuid;
use secrets_core::crypto::hex;
use secrets_core::service::api_key::validate_api_key;
use secrets_core::service::delete::{DeleteParams, run as svc_delete};
use secrets_core::service::get_secret::get_all_secrets_by_id;
use secrets_core::service::history::run as svc_history;
use secrets_core::service::relations::get_relations_for_entries;
use secrets_core::service::search::{
SearchParams, count_entries, resolve_entry_by_id, run as svc_search,
};
use secrets_core::service::user::get_user_by_id;
use crate::AppState;
use super::{
UiLang, render_template, request_ui_lang, require_valid_user, require_valid_user_json,
};
const BIND_TTL_SECS: u64 = 600;
#[derive(Clone, sqlx::FromRow)]
struct BindRow {
device_code: String,
user_id: Option<Uuid>,
approved: bool,
}
enum ConsumeBindOutcome {
Pending,
Ready(BindRow),
NotFound,
DeviceMismatch,
}
#[derive(Serialize)]
pub(super) struct BindStartOutput {
bind_id: String,
device_code: String,
approve_url: String,
expires_in_secs: u64,
}
#[derive(Deserialize)]
pub(super) struct BindApproveInput {
bind_id: String,
device_code: String,
}
#[derive(Deserialize)]
pub(super) struct BindExchangeInput {
bind_id: String,
device_code: String,
}
#[derive(Template)]
#[template(
source = r#"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Local MCP 绑定确认</title></head>
<body>
<h1>确认绑定本地 MCP</h1>
{% if error.is_some() %}
<p style="color:#c00">{{ error.as_ref().unwrap() }}</p>
{% endif %}
{% if approved %}
<p>绑定已确认。你可以返回本地页面继续下一步。</p>
{% else %}
<p>Bind ID: <code>{{ bind_id }}</code></p>
<form method="post" action="/api/local-mcp/bind/approve">
<input type="hidden" name="bind_id" value="{{ bind_id }}"/>
<input type="hidden" name="device_code" value="{{ device_code }}"/>
<button type="submit">确认绑定</button>
</form>
{% endif %}
</body>
</html>"#,
ext = "html"
)]
struct ApproveTemplate {
bind_id: String,
device_code: String,
approved: bool,
error: Option<String>,
}
async fn cleanup_expired(pool: &PgPool) {
let _ = sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE expires_at <= NOW()")
.execute(pool)
.await;
}
async fn fetch_bind(pool: &PgPool, bind_id: &str) -> Result<Option<BindRow>, StatusCode> {
sqlx::query_as::<_, BindRow>(
"SELECT device_code, user_id, approved
FROM local_mcp_bind_sessions
WHERE bind_id = $1 AND expires_at > NOW()",
)
.bind(bind_id)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to fetch local MCP bind");
StatusCode::INTERNAL_SERVER_ERROR
})
}
async fn require_user_from_bearer(pool: &PgPool, headers: &HeaderMap) -> Result<Uuid, StatusCode> {
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let raw_key = auth_header
.strip_prefix("Bearer ")
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or(StatusCode::UNAUTHORIZED)?;
validate_api_key(pool, raw_key)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to validate api key for local MCP refresh");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)
}
async fn consume_bind_session(
pool: &PgPool,
bind_id: &str,
device_code: &str,
) -> Result<ConsumeBindOutcome, (StatusCode, Json<serde_json::Value>)> {
let mut tx = pool.begin().await.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to start tx for bind exchange");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to start bind exchange" })),
)
})?;
let stored = sqlx::query_as::<_, BindRow>(
"SELECT device_code, user_id, approved
FROM local_mcp_bind_sessions
WHERE bind_id = $1 AND expires_at > NOW()
FOR UPDATE",
)
.bind(bind_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to lock bind session");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to load bind session" })),
)
})?;
let Some(bind) = stored else {
tx.rollback().await.ok();
return Ok(ConsumeBindOutcome::NotFound);
};
if bind.device_code != device_code {
tx.rollback().await.ok();
return Ok(ConsumeBindOutcome::DeviceMismatch);
}
if !bind.approved {
tx.rollback().await.ok();
return Ok(ConsumeBindOutcome::Pending);
}
sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE bind_id = $1")
.bind(bind_id)
.execute(&mut *tx)
.await
.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to consume bind session");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to consume bind session" })),
)
})?;
tx.commit().await.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to commit bind exchange");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to commit bind exchange" })),
)
})?;
Ok(ConsumeBindOutcome::Ready(bind))
}
pub(super) async fn api_bind_start(
State(state): State<AppState>,
) -> Result<Json<BindStartOutput>, (StatusCode, Json<serde_json::Value>)> {
cleanup_expired(&state.pool).await;
let bind_id = Uuid::new_v4().to_string();
let device_code = Uuid::new_v4().simple().to_string();
sqlx::query(
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, expires_at)
VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 second'))",
)
.bind(&bind_id)
.bind(&device_code)
.bind(BIND_TTL_SECS as i64)
.execute(&state.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, bind_id, "failed to insert local MCP bind session");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to create bind session" })),
)
})?;
let approve_url = format!(
"{}/local-mcp/approve?bind_id={}&device_code={}",
state.base_url, bind_id, device_code
);
Ok(Json(BindStartOutput {
bind_id,
device_code,
approve_url,
expires_in_secs: BIND_TTL_SECS,
}))
}
#[derive(Deserialize)]
pub(super) struct ApproveQuery {
bind_id: String,
device_code: String,
}
pub(super) async fn approve_page(
State(state): State<AppState>,
session: Session,
Query(query): Query<ApproveQuery>,
) -> Result<Response, Response> {
let _user = require_valid_user(&state.pool, &session, "local_mcp.approve_page").await?;
cleanup_expired(&state.pool).await;
let mut approved = false;
let mut error = None;
match fetch_bind(&state.pool, &query.bind_id).await {
Ok(Some(bind)) if bind.device_code == query.device_code => approved = bind.approved,
Ok(Some(_)) => error = Some("device_code 不匹配".to_string()),
Ok(None) => error = Some("绑定已过期或不存在".to_string()),
Err(status) => return Err(status.into_response()),
}
render_template(ApproveTemplate {
bind_id: query.bind_id,
device_code: query.device_code,
approved,
error,
})
.map_err(|status| status.into_response())
}
pub(super) async fn api_bind_approve(
State(state): State<AppState>,
session: Session,
headers: axum::http::HeaderMap,
axum::Form(input): axum::Form<BindApproveInput>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let lang: UiLang = request_ui_lang(&headers);
let user = require_valid_user_json(&state.pool, &session, lang).await?;
cleanup_expired(&state.pool).await;
match fetch_bind(&state.pool, &input.bind_id).await {
Ok(Some(bind)) if bind.device_code == input.device_code => {
sqlx::query(
"UPDATE local_mcp_bind_sessions
SET user_id = $1, approved = TRUE
WHERE bind_id = $2 AND expires_at > NOW()",
)
.bind(user.id)
.bind(&input.bind_id)
.execute(&state.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, bind_id = %input.bind_id, "failed to approve bind session");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "failed to approve bind session" })),
)
})?;
Ok(axum::response::Redirect::to(&format!(
"/local-mcp/approve?bind_id={}&device_code={}&approved=1",
input.bind_id, input.device_code
))
.into_response())
}
Ok(Some(_)) => Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "device_code mismatch" })),
)),
Ok(None) => Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": "bind session not found or expired" })),
)),
Err(status) => Err((
status,
Json(json!({ "error": "failed to load bind session" })),
)),
}
}
pub(super) async fn api_bind_exchange(
State(state): State<AppState>,
Json(input): Json<BindExchangeInput>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
cleanup_expired(&state.pool).await;
let bind = match consume_bind_session(&state.pool, &input.bind_id, &input.device_code).await? {
ConsumeBindOutcome::Pending => {
return Ok((StatusCode::ACCEPTED, Json(json!({ "status": "pending" }))).into_response());
}
ConsumeBindOutcome::NotFound => {
return Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": "bind session not found or expired" })),
));
}
ConsumeBindOutcome::DeviceMismatch => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "device_code mismatch" })),
));
}
ConsumeBindOutcome::Ready(bind) => bind,
};
let user_id = bind.user_id.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "approved bind missing user_id" })),
)
})?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to load user: {e}") })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "user not found" })),
)
})?;
let key_salt_hex = user.key_salt.as_ref().map(|bytes| {
bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()
});
let key_check_hex = user.key_check.as_deref().map(hex::encode_hex);
Ok((
StatusCode::OK,
Json(json!({
"status": "ok",
"user_id": user.id,
"api_key": user.api_key,
"key_salt_hex": key_salt_hex,
"key_check_hex": key_check_hex,
"key_params": user.key_params,
"key_version": user.key_version,
})),
)
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
use secrets_core::{
config::resolve_db_config,
db::{create_pool, migrate},
};
async fn test_pool() -> Option<PgPool> {
let config = resolve_db_config("").ok()?;
let pool = create_pool(&config).await.ok()?;
migrate(&pool).await.ok()?;
Some(pool)
}
#[tokio::test]
async fn consume_bind_session_is_single_use() {
let Some(pool) = test_pool().await else {
return;
};
let bind_id = format!("test-{}", Uuid::new_v4());
let device_code = Uuid::new_v4().simple().to_string();
let user_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, user_id, approved, expires_at)
VALUES ($1, $2, $3, TRUE, NOW() + INTERVAL '10 minutes')",
)
.bind(&bind_id)
.bind(&device_code)
.bind(user_id)
.execute(&pool)
.await
.unwrap();
let first = consume_bind_session(&pool, &bind_id, &device_code)
.await
.unwrap();
assert!(matches!(first, ConsumeBindOutcome::Ready(_)));
let second = consume_bind_session(&pool, &bind_id, &device_code)
.await
.unwrap();
assert!(matches!(second, ConsumeBindOutcome::NotFound));
}
}
pub(super) async fn api_bind_refresh(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to load user: {e}") })),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "user not found" })),
)
})?;
Ok((
StatusCode::OK,
Json(json!({
"user_id": user.id,
"key_salt_hex": user.key_salt.as_deref().map(hex::encode_hex),
"key_check_hex": user.key_check.as_deref().map(hex::encode_hex),
"key_params": user.key_params,
"key_version": user.key_version,
})),
)
.into_response())
}
#[derive(Deserialize)]
pub(super) struct LocalSearchInput {
query: Option<String>,
metadata_query: Option<String>,
folder: Option<String>,
#[serde(rename = "type")]
entry_type: Option<String>,
name: Option<String>,
name_query: Option<String>,
tags: Option<Vec<String>>,
summary: Option<bool>,
sort: Option<String>,
limit: Option<u32>,
offset: Option<u32>,
}
#[derive(Deserialize)]
pub(super) struct LocalHistoryInput {
name: Option<String>,
folder: Option<String>,
id: Option<Uuid>,
limit: Option<u32>,
}
#[derive(Deserialize)]
pub(super) struct LocalDeleteInput {
id: Option<Uuid>,
name: Option<String>,
folder: Option<String>,
#[serde(rename = "type")]
entry_type: Option<String>,
dry_run: Option<bool>,
}
fn require_encryption_key_local(
headers: &HeaderMap,
) -> Result<[u8; 32], (StatusCode, Json<serde_json::Value>)> {
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Missing X-Encryption-Key header" })),
)
})?;
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid X-Encryption-Key format" })),
)
})
}
fn render_entry_json(
entry: &secrets_core::models::Entry,
relations: secrets_core::service::relations::EntryRelations,
secret_fields: &[secrets_core::models::SecretField],
summary: bool,
) -> Value {
if summary {
json!({
"name": entry.name,
"folder": entry.folder,
"type": entry.entry_type,
"tags": entry.tags,
"notes": entry.notes,
"parents": relations.parents,
"children": relations.children,
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
let schema: Vec<_> = secret_fields
.iter()
.map(|field| {
json!({
"id": field.id,
"name": field.name,
"type": field.secret_type,
})
})
.collect();
json!({
"id": entry.id,
"name": entry.name,
"folder": entry.folder,
"type": entry.entry_type,
"notes": entry.notes,
"tags": entry.tags,
"metadata": entry.metadata,
"parents": relations.parents,
"children": relations.children,
"secret_fields": schema,
"version": entry.version,
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
}
pub(super) async fn api_entries_find(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<LocalSearchInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let tags = input.tags.unwrap_or_default();
let result = svc_search(
&state.pool,
SearchParams {
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
user_id: Some(user_id),
},
)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp find failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "find failed" })),
)
})?;
let total_count = count_entries(
&state.pool,
&SearchParams {
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: 0,
offset: 0,
user_id: Some(user_id),
},
)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp find count failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "find count failed" })),
)
})?;
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp find relations failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "find relations failed" })),
)
})?;
let entries = result
.entries
.iter()
.map(|entry| {
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
let secret_fields = result
.secret_schemas
.get(&entry.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
render_entry_json(entry, relations, secret_fields, false)
})
.collect::<Vec<_>>();
Ok(Json(json!({
"total_count": total_count,
"entries": entries,
})))
}
pub(super) async fn api_entries_search(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<LocalSearchInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let tags = input.tags.unwrap_or_default();
let result = svc_search(
&state.pool,
SearchParams {
folder: input.folder.as_deref(),
entry_type: input.entry_type.as_deref(),
name: input.name.as_deref(),
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
user_id: Some(user_id),
},
)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp search failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "search failed" })),
)
})?;
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp search relations failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "search relations failed" })),
)
})?;
let summary = input.summary.unwrap_or(false);
let entries = result
.entries
.iter()
.map(|entry| {
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
let secret_fields = result
.secret_schemas
.get(&entry.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
render_entry_json(entry, relations, secret_fields, summary)
})
.collect::<Vec<_>>();
Ok(Json(Value::Array(entries)))
}
pub(super) async fn api_entry_history(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<LocalHistoryInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let (name, folder) = if let Some(id) = input.id {
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
.await
.map_err(|e| {
tracing::warn!(error = %e, %user_id, %id, "local mcp history missing entry");
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "entry not found" })),
)
})?;
(entry.name, Some(entry.folder))
} else {
let name = input.name.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "name or id is required" })),
)
})?;
(name, input.folder)
};
let result = svc_history(
&state.pool,
&name,
folder.as_deref(),
input.limit.unwrap_or(20),
Some(user_id),
)
.await
.map_err(|e| {
tracing::warn!(error = %e, %user_id, name = %name, "local mcp history failed");
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
})?;
Ok(Json(
serde_json::to_value(result).unwrap_or_else(|_| json!([])),
))
}
pub(super) async fn api_entries_overview(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
#[derive(sqlx::FromRow)]
struct CountRow {
name: String,
count: i64,
}
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let folder_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
"SELECT folder AS name, COUNT(*) AS count FROM entries \
WHERE user_id = $1 GROUP BY folder ORDER BY folder",
)
.bind(user_id)
.fetch_all(&state.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp overview folders failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "overview failed" })),
)
})?;
let type_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
"SELECT type AS name, COUNT(*) AS count FROM entries \
WHERE user_id = $1 GROUP BY type ORDER BY type",
)
.bind(user_id)
.fetch_all(&state.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "local mcp overview types failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "overview failed" })),
)
})?;
let total: i64 = folder_rows.iter().map(|row| row.count).sum();
Ok(Json(json!({
"total": total,
"folders": folder_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
"types": type_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
})))
}
pub(super) async fn api_entries_delete_preview(
State(state): State<AppState>,
headers: HeaderMap,
Json(input): Json<LocalDeleteInput>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
if !input.dry_run.unwrap_or(false) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "dry_run=true is required" })),
));
}
let (effective_name, effective_folder) =
if let Some(id) = input.id {
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
.await
.map_err(|e| {
tracing::warn!(error = %e, %user_id, %id, "local mcp delete preview missing entry");
(StatusCode::NOT_FOUND, Json(json!({ "error": "entry not found" })))
})?;
(Some(entry.name), Some(entry.folder))
} else {
(input.name, input.folder)
};
let result = svc_delete(
&state.pool,
DeleteParams {
name: effective_name.as_deref(),
folder: effective_folder.as_deref(),
entry_type: input.entry_type.as_deref(),
dry_run: true,
user_id: Some(user_id),
},
)
.await
.map_err(|e| {
tracing::warn!(error = %e, %user_id, "local mcp delete preview failed");
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
})?;
Ok(Json(
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
))
}
pub(super) async fn api_entry_secrets_decrypt_bearer(
State(state): State<AppState>,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let user_id = require_user_from_bearer(&state.pool, &headers)
.await
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
let master_key = require_encryption_key_local(&headers)?;
let secrets = get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
.await
.map_err(|e| {
tracing::warn!(error = %e, %user_id, %entry_id, "local mcp decrypt failed");
if let Some(app_err) = e.downcast_ref::<secrets_core::error::AppError>() {
return match app_err {
secrets_core::error::AppError::DecryptionFailed => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "Decryption failed, verify passphrase" })),
),
secrets_core::error::AppError::NotFoundEntry
| secrets_core::error::AppError::NotFoundUser
| secrets_core::error::AppError::NotFoundSecret => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "entry not found" })),
),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "decrypt failed" })),
),
};
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "decrypt failed" })),
)
})?;
Ok(Json(
serde_json::to_value(secrets).unwrap_or_else(|_| json!({})),
))
}

View File

@@ -18,6 +18,7 @@ mod audit;
mod auth;
mod changelog;
mod entries;
mod local_mcp;
// ── Session keys ──────────────────────────────────────────────────────────────
@@ -259,6 +260,7 @@ pub fn web_router() -> Router<AppState> {
.route("/auth/google", get(auth::auth_google))
.route("/auth/google/callback", get(auth::auth_google_callback))
.route("/auth/logout", post(auth::auth_logout))
.route("/local-mcp/approve", get(local_mcp::approve_page))
.route("/dashboard", get(account::dashboard))
.route("/entries", get(entries::entries_page))
.route("/trash", get(entries::trash_page))
@@ -266,6 +268,43 @@ pub fn web_router() -> Router<AppState> {
.route("/account/bind/google", get(auth::account_bind_google))
.route("/account/unbind/{provider}", post(auth::account_unbind))
.route("/api/key-salt", get(account::api_key_salt))
.route("/api/local-mcp/bind/start", post(local_mcp::api_bind_start))
.route(
"/api/local-mcp/bind/approve",
post(local_mcp::api_bind_approve),
)
.route(
"/api/local-mcp/bind/exchange",
post(local_mcp::api_bind_exchange),
)
.route(
"/api/local-mcp/bind/refresh",
post(local_mcp::api_bind_refresh),
)
.route(
"/api/local-mcp/entries/find",
post(local_mcp::api_entries_find),
)
.route(
"/api/local-mcp/entries/search",
post(local_mcp::api_entries_search),
)
.route(
"/api/local-mcp/entries/history",
post(local_mcp::api_entry_history),
)
.route(
"/api/local-mcp/entries/overview",
get(local_mcp::api_entries_overview),
)
.route(
"/api/local-mcp/entries/delete-preview",
post(local_mcp::api_entries_delete_preview),
)
.route(
"/api/local-mcp/entries/{id}/secrets",
get(local_mcp::api_entry_secrets_decrypt_bearer),
)
.route("/api/key-setup", post(account::api_key_setup))
.route("/api/key-change", post(account::api_key_change))
.route("/api/apikey", get(account::api_apikey_get))