feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled

- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db
- Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp*
- Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md
- Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
This commit is contained in:
agent
2026-04-13 08:49:57 +08:00
parent cb5865b958
commit 0374899dab
130 changed files with 20447 additions and 21577 deletions

27
apps/api/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "secrets-api"
version = "0.1.0"
edition.workspace = true
[[bin]]
name = "secrets-api"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
axum.workspace = true
dotenvy.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true
secrets-application = { path = "../../crates/application" }
secrets-device-auth = { path = "../../crates/device-auth" }
secrets-domain = { path = "../../crates/domain" }
secrets-infrastructure-db = { path = "../../crates/infrastructure-db" }

View File

@@ -0,0 +1,15 @@
use anyhow::{Context, Result};
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let database_url = secrets_infrastructure_db::load_database_url()?;
let pool = secrets_infrastructure_db::create_pool(&database_url).await?;
secrets_infrastructure_db::migrate_current_schema(&pool)
.await
.context("failed to initialize current database schema")?;
println!("current database schema initialized");
Ok(())
}

568
apps/api/src/main.rs Normal file
View File

@@ -0,0 +1,568 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, StatusCode, header},
routing::{get, post},
};
use chrono::{DateTime, Utc};
use reqwest::Client;
use secrets_application::sync::{fetch_object, sync_pull, sync_push};
use secrets_device_auth::{
hash_device_login_token, new_device_fingerprint, new_device_login_token,
};
use secrets_domain::{
SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing_subscriber::EnvFilter;
use uuid::Uuid;
#[derive(Clone)]
struct AppState {
pool: PgPool,
http: Client,
}
#[derive(Serialize)]
struct DemoLoginResponse {
device_token: String,
}
#[derive(Debug, Deserialize)]
struct DesktopGoogleLoginRequest {
access_token: String,
device_name: String,
platform: String,
client_version: String,
device_fingerprint: String,
}
#[derive(Debug, Deserialize)]
struct GoogleUserInfo {
email: String,
name: Option<String>,
}
#[derive(Serialize)]
struct DeviceView {
name: String,
platform: String,
client_version: String,
last_seen: String,
ip: Option<String>,
}
#[derive(Serialize)]
struct UserProfileView {
id: Uuid,
name: String,
email: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct UserRow {
id: Uuid,
email: Option<String>,
name: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct DeviceRow {
id: Uuid,
display_name: String,
platform: String,
client_version: String,
last_seen_at: DateTime<Utc>,
last_ip: Option<String>,
}
#[derive(Debug, Serialize)]
struct ObjectResponse {
object: VaultObjectEnvelope,
}
#[tokio::main]
async fn main() -> AnyResult<()> {
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| "secrets_api=info".into()),
)
.init();
let database_url = secrets_infrastructure_db::load_database_url()?;
let pool = secrets_infrastructure_db::create_pool(&database_url).await?;
secrets_infrastructure_db::migrate_current_schema(&pool)
.await
.context("failed to initialize current database schema")?;
let bind = std::env::var("SECRETS_API_BIND").unwrap_or_else(|_| "127.0.0.1:9415".to_string());
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/auth/demo-login", post(api_demo_login))
.route("/auth/google/desktop-login", post(api_google_desktop_login))
.route("/me", get(api_me))
.route("/sync/pull", post(api_sync_pull))
.route("/sync/push", post(api_sync_push))
.route("/sync/objects/{id}", get(api_sync_object))
.route("/devices", get(api_devices))
.with_state(AppState {
pool,
http: Client::new(),
});
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("failed to bind {}", bind))?;
tracing::info!(bind = %bind, "secrets-api listening");
axum::serve(listener, app)
.await
.context("api server error")?;
Ok(())
}
async fn api_demo_login(
State(state): State<AppState>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user_id, device_id) = ensure_demo_user(&state.pool)
.await
.map_err(internal_error)?;
let device_token = new_device_login_token();
let token_hash = hash_device_login_token(&device_token);
sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1")
.bind(device_id)
.execute(&state.pool)
.await
.map_err(internal_error)?;
sqlx::query(
r#"
INSERT INTO device_login_tokens (device_id, token_hash)
VALUES ($1, $2)
"#,
)
.bind(device_id)
.bind(token_hash)
.execute(&state.pool)
.await
.map_err(internal_error)?;
sqlx::query(
r#"
INSERT INTO auth_events (
user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'device_token', 'success')
"#,
)
.bind(user_id)
.bind(device_id)
.bind("Voson 的 Mac mini")
.bind("macOS")
.bind(env!("CARGO_PKG_VERSION"))
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(&state.pool)
.await
.map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token }))
}
async fn api_google_desktop_login(
State(state): State<AppState>,
Json(payload): Json<DesktopGoogleLoginRequest>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> {
let google_user = state
.http
.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(&payload.access_token)
.send()
.await
.map_err(internal_error)?
.error_for_status()
.map_err(internal_error)?
.json::<GoogleUserInfo>()
.await
.map_err(internal_error)?;
let user_id = upsert_user_from_google(&state.pool, &google_user)
.await
.map_err(internal_error)?;
let device_id = upsert_device_for_login(
&state.pool,
user_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
&payload.device_fingerprint,
)
.await
.map_err(internal_error)?;
let device_token = issue_device_login_token(
&state.pool,
user_id,
device_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
)
.await
.map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token }))
}
async fn api_sync_pull(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncPullRequest>,
) -> std::result::Result<Json<SyncPullResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let response = sync_pull(&state.pool, user.id, payload)
.await
.map_err(internal_error)?;
Ok(Json(response))
}
async fn api_sync_push(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<SyncPushRequest>,
) -> std::result::Result<Json<SyncPushResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let response = sync_push(&state.pool, user.id, payload)
.await
.map_err(internal_error)?;
Ok(Json(response))
}
async fn api_sync_object(
State(state): State<AppState>,
headers: HeaderMap,
Path(object_id): Path<Uuid>,
) -> std::result::Result<Json<ObjectResponse>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let object = fetch_object(&state.pool, user.id, object_id)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("object not found"))?;
Ok(Json(ObjectResponse { object }))
}
async fn api_devices(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Json<Vec<DeviceView>>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
let rows = sqlx::query_as::<_, DeviceRow>(
r#"
SELECT
d.id,
d.display_name,
d.platform,
d.client_version,
d.last_seen_at,
COALESCE(NULLIF(a.forwarded_ip, ''), NULLIF(a.ip_addr, '')) AS last_ip
FROM devices d
LEFT JOIN LATERAL (
SELECT ip_addr, forwarded_ip
FROM auth_events
WHERE device_id = d.id
ORDER BY created_at DESC
LIMIT 1
) a ON TRUE
WHERE d.user_id = $1
ORDER BY last_seen_at DESC
"#,
)
.bind(user.id)
.fetch_all(&state.pool)
.await
.map_err(internal_error)?;
let devices = rows
.into_iter()
.map(|row| DeviceView {
name: row.display_name,
platform: row.platform,
client_version: row.client_version,
last_seen: row.last_seen_at.format("%Y-%m-%d %H:%M").to_string(),
ip: row.last_ip,
})
.collect();
Ok(Json(devices))
}
async fn api_me(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Json<UserProfileView>, (StatusCode, Json<serde_json::Value>)> {
let (user, _) = require_auth(&state.pool, &headers).await?;
Ok(Json(UserProfileView {
id: user.id,
name: user.name,
email: user.email.unwrap_or_default(),
}))
}
async fn require_auth(
pool: &PgPool,
headers: &HeaderMap,
) -> std::result::Result<(UserRow, DeviceRow), (StatusCode, Json<serde_json::Value>)> {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "))
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing bearer token"))?;
let token_hash = hash_device_login_token(auth);
let row = sqlx::query_as::<_, DeviceRow>(
r#"
SELECT
d.id,
d.display_name,
d.platform,
d.client_version,
d.last_seen_at,
NULL::text AS last_ip
FROM device_login_tokens t
JOIN devices d ON d.id = t.device_id
WHERE t.token_hash = $1
"#,
)
.bind(&token_hash)
.fetch_optional(pool)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("invalid device token"))?;
sqlx::query("UPDATE device_login_tokens SET last_seen_at = NOW() WHERE token_hash = $1")
.bind(&token_hash)
.execute(pool)
.await
.map_err(internal_error)?;
sqlx::query("UPDATE devices SET last_seen_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(pool)
.await
.map_err(internal_error)?;
let user = sqlx::query_as::<_, UserRow>(
r#"
SELECT u.id, u.email, u.name
FROM users u
JOIN devices d ON d.user_id = u.id
WHERE d.id = $1
"#,
)
.bind(row.id)
.fetch_one(pool)
.await
.map_err(internal_error)?;
Ok((user, row))
}
async fn ensure_demo_user(pool: &PgPool) -> AnyResult<(Uuid, Uuid)> {
let existing =
sqlx::query_as::<_, UserRow>("SELECT id, email, name FROM users WHERE email = $1 LIMIT 1")
.bind("voson.wang.s@gmail.com")
.fetch_optional(pool)
.await?;
let user_id = if let Some(user) = existing {
user.id
} else {
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id
"#,
)
.bind("voson.wang.s@gmail.com")
.bind("Voson")
.fetch_one(pool)
.await?
};
let existing_device = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM devices WHERE user_id = $1 AND display_name = $2 LIMIT 1",
)
.bind(user_id)
.bind("Voson 的 Mac mini")
.fetch_optional(pool)
.await?;
let device_id = if let Some(id) = existing_device {
id
} else {
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
)
.bind(user_id)
.bind("Voson 的 Mac mini")
.bind("macOS")
.bind(env!("CARGO_PKG_VERSION"))
.bind(new_device_fingerprint())
.fetch_one(pool)
.await?
};
Ok((user_id, device_id))
}
async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) -> AnyResult<Uuid> {
let existing = sqlx::query_scalar::<_, Uuid>("SELECT id FROM users WHERE email = $1 LIMIT 1")
.bind(&google_user.email)
.fetch_optional(pool)
.await?;
if let Some(user_id) = existing {
sqlx::query("UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2")
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.bind(user_id)
.execute(pool)
.await?;
return Ok(user_id);
}
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id
"#,
)
.bind(&google_user.email)
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.fetch_one(pool)
.await
.context("failed to create user from google login")
}
async fn upsert_device_for_login(
pool: &PgPool,
user_id: Uuid,
device_name: &str,
platform: &str,
client_version: &str,
device_fingerprint: &str,
) -> AnyResult<Uuid> {
let existing = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM devices WHERE user_id = $1 AND device_fingerprint = $2 LIMIT 1",
)
.bind(user_id)
.bind(device_fingerprint)
.fetch_optional(pool)
.await?;
if let Some(device_id) = existing {
sqlx::query(
r#"
UPDATE devices
SET display_name = $1, platform = $2, client_version = $3, last_seen_at = NOW()
WHERE id = $4
"#,
)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind(device_id)
.execute(pool)
.await?;
return Ok(device_id);
}
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO devices (user_id, display_name, platform, client_version, device_fingerprint)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
)
.bind(user_id)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind(device_fingerprint)
.fetch_one(pool)
.await
.context("failed to create device")
}
async fn issue_device_login_token(
pool: &PgPool,
user_id: Uuid,
device_id: Uuid,
device_name: &str,
platform: &str,
client_version: &str,
) -> AnyResult<String> {
let device_token = new_device_login_token();
let token_hash = hash_device_login_token(&device_token);
sqlx::query("DELETE FROM device_login_tokens WHERE device_id = $1")
.bind(device_id)
.execute(pool)
.await?;
sqlx::query("INSERT INTO device_login_tokens (device_id, token_hash) VALUES ($1, $2)")
.bind(device_id)
.bind(token_hash)
.execute(pool)
.await?;
sqlx::query(
r#"
INSERT INTO auth_events (
user_id, device_id, device_name, platform, client_version, ip_addr, forwarded_ip, login_method, login_result
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'google_desktop', 'success')
"#,
)
.bind(user_id)
.bind(device_id)
.bind(device_name)
.bind(platform)
.bind(client_version)
.bind::<Option<String>>(None)
.bind::<Option<String>>(None)
.execute(pool)
.await?;
Ok(device_token)
}
fn internal_error<E: std::fmt::Display>(error: E) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": error.to_string() })),
)
}
fn unauthorized(message: &str) -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": message })))
}