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

View File

@@ -0,0 +1,18 @@
[package]
name = "secrets-application"
version = "0.1.0"
edition.workspace = true
[lib]
name = "secrets_application"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
uuid.workspace = true
secrets-domain = { path = "../domain" }

View File

@@ -0,0 +1,9 @@
use secrets_domain::VaultObjectEnvelope;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct RevisionConflict {
pub change_id: Uuid,
pub object_id: Uuid,
pub server_object: Option<VaultObjectEnvelope>,
}

View File

@@ -0,0 +1,3 @@
pub mod conflict;
pub mod sync;
pub mod vault_store;

View File

@@ -0,0 +1,252 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
SyncPushResponse, VaultObjectChange, VaultObjectEnvelope,
};
use crate::vault_store::{
get_object, list_objects_since, list_tombstones_since, max_server_revision,
};
fn detect_conflict(
change: &VaultObjectChange,
existing: Option<&VaultObjectEnvelope>,
) -> Option<SyncConflict> {
match (change.base_revision, existing) {
(Some(base_revision), Some(server_object)) if server_object.revision != base_revision => {
Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "revision_conflict".to_string(),
server_object: Some(server_object.clone()),
})
}
_ if !matches!(change.operation.as_str(), "upsert" | "delete") => Some(SyncConflict {
change_id: change.change_id,
object_id: change.object_id,
reason: "unsupported_operation".to_string(),
server_object: existing.cloned(),
}),
_ => None,
}
}
pub async fn sync_pull(
pool: &PgPool,
user_id: Uuid,
request: SyncPullRequest,
) -> Result<SyncPullResponse> {
let cursor = request.cursor.unwrap_or(0).max(0);
let limit = request.limit.unwrap_or(200).clamp(1, 500);
let objects = list_objects_since(pool, user_id, cursor, limit).await?;
let tombstones = if request.include_deleted {
list_tombstones_since(pool, user_id, cursor, limit).await?
} else {
Vec::new()
};
let server_revision = max_server_revision(pool, user_id).await?;
let next_cursor = objects
.last()
.map(|object| object.revision)
.unwrap_or(cursor);
Ok(SyncPullResponse {
server_revision,
next_cursor,
has_more: (objects.len() as i64) >= limit,
objects,
tombstones,
})
}
pub async fn sync_push(
pool: &PgPool,
user_id: Uuid,
request: SyncPushRequest,
) -> Result<SyncPushResponse> {
let mut accepted = Vec::new();
let mut conflicts = Vec::new();
for change in request.changes {
let existing = get_object(pool, user_id, change.object_id).await?;
if let Some(conflict) = detect_conflict(&change, existing.as_ref()) {
conflicts.push(conflict);
continue;
}
let next_revision = existing
.as_ref()
.map(|object| object.revision + 1)
.unwrap_or(1);
let next_cipher_version = change.cipher_version.unwrap_or(1);
let next_ciphertext = change.ciphertext.clone().unwrap_or_default();
let next_content_hash = change.content_hash.clone().unwrap_or_default();
let next_deleted_at = if change.operation == "delete" {
Some(chrono::Utc::now())
} else {
None
};
match change.operation.as_str() {
"upsert" => {
sqlx::query(
r#"
INSERT INTO vault_objects (
object_id, user_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, created_by_device
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW(), NULL)
ON CONFLICT (object_id)
DO UPDATE SET
revision = EXCLUDED.revision,
cipher_version = EXCLUDED.cipher_version,
ciphertext = EXCLUDED.ciphertext,
content_hash = EXCLUDED.content_hash,
deleted_at = NULL,
updated_at = NOW()
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(change.object_kind.as_str())
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext.clone())
.bind(next_content_hash.clone())
.execute(pool)
.await?;
}
"delete" => {
sqlx::query(
r#"
UPDATE vault_objects
SET revision = $1, deleted_at = NOW(), updated_at = NOW()
WHERE object_id = $2
AND user_id = $3
"#,
)
.bind(next_revision)
.bind(change.object_id)
.bind(user_id)
.execute(pool)
.await?;
}
_ => unreachable!("unsupported operations are filtered by detect_conflict"),
}
sqlx::query(
r#"
INSERT INTO vault_object_revisions (
object_id, user_id, revision, cipher_version, ciphertext, content_hash, deleted_at, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
"#,
)
.bind(change.object_id)
.bind(user_id)
.bind(next_revision)
.bind(next_cipher_version)
.bind(next_ciphertext)
.bind(next_content_hash)
.bind(next_deleted_at)
.execute(pool)
.await?;
accepted.push(SyncAcceptedChange {
change_id: change.change_id,
object_id: change.object_id,
revision: next_revision,
});
}
let server_revision = max_server_revision(pool, user_id).await?;
Ok(SyncPushResponse {
server_revision,
accepted,
conflicts,
})
}
pub async fn fetch_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
get_object(pool, user_id, object_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use secrets_domain::{VaultObjectChange, VaultObjectKind};
use uuid::Uuid;
fn sample_change(operation: &str, base_revision: Option<i64>) -> VaultObjectChange {
VaultObjectChange {
change_id: Uuid::nil(),
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
operation: operation.to_string(),
base_revision,
cipher_version: Some(1),
ciphertext: Some(vec![1, 2, 3]),
content_hash: Some("sha256:test".to_string()),
}
}
fn sample_object(revision: i64) -> VaultObjectEnvelope {
VaultObjectEnvelope {
object_id: Uuid::max(),
object_kind: VaultObjectKind::Cipher,
revision,
cipher_version: 1,
ciphertext: vec![9, 9, 9],
content_hash: "sha256:server".to_string(),
deleted_at: None,
updated_at: Utc::now(),
}
}
#[test]
fn conflict_when_base_revision_is_stale() {
let mut change = sample_change("upsert", Some(3));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server)).expect("expected conflict");
assert_eq!(conflict.reason, "revision_conflict");
assert_eq!(conflict.object_id, server.object_id);
assert_eq!(
conflict
.server_object
.as_ref()
.map(|object| object.revision),
Some(5)
);
}
#[test]
fn no_conflict_when_revision_matches() {
let mut change = sample_change("upsert", Some(5));
let server = sample_object(5);
change.object_id = server.object_id;
let conflict = detect_conflict(&change, Some(&server));
assert!(conflict.is_none());
}
#[test]
fn unsupported_operation_is_conflict() {
let change = sample_change("merge", None);
let conflict = detect_conflict(&change, None).expect("expected unsupported operation");
assert_eq!(conflict.reason, "unsupported_operation");
assert!(conflict.server_object.is_none());
}
}

View File

@@ -0,0 +1,147 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone};
#[derive(Debug, sqlx::FromRow)]
struct VaultObjectRow {
object_id: Uuid,
_object_kind: String,
revision: i64,
cipher_version: i32,
ciphertext: Vec<u8>,
content_hash: String,
deleted_at: Option<DateTime<Utc>>,
updated_at: DateTime<Utc>,
}
impl From<VaultObjectRow> for VaultObjectEnvelope {
fn from(row: VaultObjectRow) -> Self {
Self {
object_id: row.object_id,
object_kind: VaultObjectKind::Cipher,
revision: row.revision,
cipher_version: row.cipher_version,
ciphertext: row.ciphertext,
content_hash: row.content_hash,
deleted_at: row.deleted_at,
updated_at: row.updated_at,
}
}
}
pub async fn list_objects_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultObjectEnvelope>> {
let rows = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list vault objects")?;
Ok(rows.into_iter().map(Into::into).collect())
}
pub async fn get_object(
pool: &PgPool,
user_id: Uuid,
object_id: Uuid,
) -> Result<Option<VaultObjectEnvelope>> {
let row = sqlx::query_as::<_, VaultObjectRow>(
r#"
SELECT
object_id,
object_kind AS _object_kind,
revision,
cipher_version,
ciphertext,
content_hash,
deleted_at,
updated_at
FROM vault_objects
WHERE user_id = $1
AND object_id = $2
"#,
)
.bind(user_id)
.bind(object_id)
.fetch_optional(pool)
.await
.context("failed to load vault object")?;
Ok(row.map(Into::into))
}
pub async fn list_tombstones_since(
pool: &PgPool,
user_id: Uuid,
cursor: i64,
limit: i64,
) -> Result<Vec<VaultTombstone>> {
let rows = sqlx::query_as::<_, (Uuid, i64, DateTime<Utc>)>(
r#"
SELECT object_id, revision, deleted_at
FROM vault_objects
WHERE user_id = $1
AND revision > $2
AND deleted_at IS NOT NULL
ORDER BY revision ASC
LIMIT $3
"#,
)
.bind(user_id)
.bind(cursor)
.bind(limit.max(1))
.fetch_all(pool)
.await
.context("failed to list tombstones")?;
Ok(rows
.into_iter()
.map(|(object_id, revision, deleted_at)| VaultTombstone {
object_id,
revision,
deleted_at,
})
.collect())
}
pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result<i64> {
let revision = sqlx::query_scalar::<_, Option<i64>>(
r#"
SELECT MAX(revision)
FROM vault_objects
WHERE user_id = $1
"#,
)
.bind(user_id)
.fetch_one(pool)
.await
.context("failed to load max server revision")?;
Ok(revision.unwrap_or(0))
}