use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use uuid::Uuid; /// A top-level entry (server, service, account, person, …). /// Sensitive fields are stored separately in `secrets`. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Entry { pub id: Uuid, pub user_id: Option, pub folder: String, #[serde(rename = "type")] #[sqlx(rename = "type")] pub entry_type: String, pub name: String, pub notes: String, pub tags: Vec, pub metadata: Value, pub version: i64, pub created_at: DateTime, pub updated_at: DateTime, } /// A single encrypted field belonging to an Entry. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct SecretField { pub id: Uuid, pub user_id: Option, pub name: String, #[serde(rename = "type")] #[sqlx(rename = "type")] pub secret_type: String, /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag pub encrypted: Vec, pub version: i64, pub created_at: DateTime, pub updated_at: DateTime, } // ── Internal query row types (shared across commands) ───────────────────────── /// Minimal entry row fetched for write operations (add / update / delete / rollback). #[derive(Debug, sqlx::FromRow)] pub struct EntryRow { pub id: Uuid, pub version: i64, pub folder: String, #[sqlx(rename = "type")] pub entry_type: String, pub tags: Vec, pub metadata: Value, pub notes: String, } /// Entry row including `name` (used for id-scoped web / service updates). #[derive(Debug, sqlx::FromRow)] pub struct EntryWriteRow { pub id: Uuid, pub version: i64, pub folder: String, #[sqlx(rename = "type")] pub entry_type: String, pub name: String, pub tags: Vec, pub metadata: Value, pub notes: String, } impl From<&EntryWriteRow> for EntryRow { fn from(r: &EntryWriteRow) -> Self { EntryRow { id: r.id, version: r.version, folder: r.folder.clone(), entry_type: r.entry_type.clone(), tags: r.tags.clone(), metadata: r.metadata.clone(), notes: r.notes.clone(), } } } /// Minimal secret field row fetched before snapshots or cascade deletes. #[derive(Debug, sqlx::FromRow)] pub struct SecretFieldRow { pub id: Uuid, pub name: String, pub encrypted: Vec, } // ── Export / Import types ────────────────────────────────────────────────────── /// Supported file formats for export/import. #[derive(Debug, Clone, Copy, PartialEq)] pub enum ExportFormat { Json, Toml, Yaml, } impl std::str::FromStr for ExportFormat { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "json" => Ok(Self::Json), "toml" => Ok(Self::Toml), "yaml" | "yml" => Ok(Self::Yaml), other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other), } } } impl ExportFormat { /// Infer format from file extension (.json / .toml / .yaml / .yml). pub fn from_extension(path: &str) -> anyhow::Result { let ext = path.rsplit('.').next().unwrap_or("").to_lowercase(); ext.parse().map_err(|_| { anyhow::anyhow!( "Cannot infer format from extension '.{}'. Use --format json|toml|yaml", ext ) }) } /// Serialize ExportData to a string in this format. pub fn serialize(&self, data: &ExportData) -> anyhow::Result { match self { Self::Json => Ok(serde_json::to_string_pretty(data)?), Self::Toml => { let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?; toml::to_string_pretty(&toml_val) .map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e)) } Self::Yaml => serde_yaml::to_string(data) .map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)), } } /// Deserialize ExportData from a string in this format. pub fn deserialize(&self, content: &str) -> anyhow::Result { match self { Self::Json => Ok(serde_json::from_str(content)?), Self::Toml => { let toml_val: toml::Value = toml::from_str(content) .map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?; let json_val = toml_to_json_value(&toml_val); Ok(serde_json::from_value(json_val)?) } Self::Yaml => serde_yaml::from_str(content) .map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)), } } } /// Top-level structure for export/import files. #[derive(Debug, Serialize, Deserialize)] pub struct ExportData { pub version: u32, pub exported_at: String, pub entries: Vec, } /// A single entry with decrypted secrets for export/import. #[derive(Debug, Serialize, Deserialize)] pub struct ExportEntry { pub name: String, #[serde(default)] pub folder: String, #[serde(default, rename = "type")] pub entry_type: String, #[serde(default)] pub notes: String, #[serde(default)] pub tags: Vec, #[serde(default)] pub metadata: Value, /// Decrypted secret fields. None means no secrets in this export (--no-secrets). #[serde(default, skip_serializing_if = "Option::is_none")] pub secrets: Option>, } // ── Multi-user models ────────────────────────────────────────────────────────── /// A registered user (created on first OAuth login). #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct User { pub id: Uuid, pub email: Option, pub name: String, pub avatar_url: Option, /// PBKDF2 salt (32 B). NULL until user sets up passphrase. pub key_salt: Option>, /// AES-256-GCM encryption of the known constant "secrets-mcp-key-check". /// Used to verify the passphrase without storing the key itself. pub key_check: Option>, /// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}. pub key_params: Option, /// Plaintext API key for MCP Bearer authentication. Auto-created on first login. pub api_key: Option, /// Incremented each time the passphrase is changed; used to invalidate sessions on other devices. pub key_version: i64, pub created_at: DateTime, pub updated_at: DateTime, } /// An OAuth account linked to a user. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct OauthAccount { pub id: Uuid, pub user_id: Uuid, pub provider: String, pub provider_id: String, pub email: Option, pub name: Option, pub avatar_url: Option, pub created_at: DateTime, } /// A single audit log row, optionally scoped to a business user. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct AuditLogEntry { pub id: i64, pub user_id: Option, pub action: String, pub folder: String, #[serde(rename = "type")] #[sqlx(rename = "type")] pub entry_type: String, pub name: String, pub detail: Value, pub created_at: DateTime, } // ── TOML ↔ JSON value conversion ────────────────────────────────────────────── /// Convert a serde_json Value to a toml Value. /// `null` values are filtered out (TOML does not support null). /// Mixed-type arrays are serialised as JSON strings. pub fn json_to_toml_value(v: &Value) -> anyhow::Result { match v { Value::Null => anyhow::bail!("TOML does not support null values"), Value::Bool(b) => Ok(toml::Value::Boolean(*b)), Value::Number(n) => { if let Some(i) = n.as_i64() { Ok(toml::Value::Integer(i)) } else if let Some(f) = n.as_f64() { Ok(toml::Value::Float(f)) } else { anyhow::bail!("unsupported number: {}", n) } } Value::String(s) => Ok(toml::Value::String(s.clone())), Value::Array(arr) => { let items: anyhow::Result> = arr.iter().map(json_to_toml_value).collect(); match items { Ok(vals) => Ok(toml::Value::Array(vals)), Err(e) => { tracing::debug!(error = %e, "mixed-type array; falling back to JSON string"); Ok(toml::Value::String(serde_json::to_string(v)?)) } } } Value::Object(map) => { let mut toml_map = toml::map::Map::new(); for (k, val) in map { if val.is_null() { // Skip null entries continue; } match json_to_toml_value(val) { Ok(tv) => { toml_map.insert(k.clone(), tv); } Err(e) => { tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string"); toml_map .insert(k.clone(), toml::Value::String(serde_json::to_string(val)?)); } } } Ok(toml::Value::Table(toml_map)) } } } /// Convert a toml Value back to a serde_json Value. pub fn toml_to_json_value(v: &toml::Value) -> Value { match v { toml::Value::Boolean(b) => Value::Bool(*b), toml::Value::Integer(i) => Value::Number((*i).into()), toml::Value::Float(f) => serde_json::Number::from_f64(*f) .map(Value::Number) .unwrap_or(Value::Null), toml::Value::String(s) => Value::String(s.clone()), toml::Value::Datetime(dt) => Value::String(dt.to_string()), toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()), toml::Value::Table(map) => { let obj: serde_json::Map = map .iter() .map(|(k, v)| (k.clone(), toml_to_json_value(v))) .collect(); Value::Object(obj) } } }