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
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:
18
crates/application/Cargo.toml
Normal file
18
crates/application/Cargo.toml
Normal 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" }
|
||||
9
crates/application/src/conflict.rs
Normal file
9
crates/application/src/conflict.rs
Normal 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>,
|
||||
}
|
||||
3
crates/application/src/lib.rs
Normal file
3
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod conflict;
|
||||
pub mod sync;
|
||||
pub mod vault_store;
|
||||
252
crates/application/src/sync.rs
Normal file
252
crates/application/src/sync.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
147
crates/application/src/vault_store.rs
Normal file
147
crates/application/src/vault_store.rs
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user