use futures::stream::StreamExt; use mongodb::{ bson::{doc, oid::ObjectId, DateTime}, Collection, }; use serde::{Deserialize, Serialize}; /// A profile shared from one account (owner) to another (recipient). /// /// Zero-knowledge envelope (Phase B, see `docs/adr/multi-person-sharing.md`): /// the owner wraps the profile DEK to the recipient's X25519 identity public /// key via ECDH, using a fresh ephemeral keypair per share. The server stores /// only opaque ciphertext + public keys and cannot read the profile DEK. /// /// `permissions` reserves `read` (Phase B), `write`, and `admin` for later /// phases. Phase B only grants read access. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileShare { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "profileId")] pub profile_id: String, /// Owner's account id (the profile's owner). Denormalized from the /// profile for query efficiency without a join. #[serde(rename = "ownerUserId")] pub owner_user_id: String, /// Recipient's account id. #[serde(rename = "recipientUserId")] pub recipient_user_id: String, /// Ephemeral X25519 public key (base64 raw) generated for this share. The /// recipient combines it with their identity private key to ECDH-derive /// the wrapping key. Plaintext — public keys are not secret. #[serde(rename = "ephemeralPublicKey")] pub ephemeral_public_key: String, /// Profile DEK wrapped (AES-256-GCM) under the ECDH-derived key. Opaque. #[serde(rename = "wrappedProfileDek")] pub wrapped_profile_dek: String, #[serde(rename = "wrappedProfileDekIv")] pub wrapped_profile_dek_iv: String, /// Reserved for later phases. Phase B writes `["read"]`. #[serde(rename = "permissions", default = "default_read")] pub permissions: Vec, /// Optional expiry; if set and past, `find_active` treats the share as gone. #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")] pub expires_at: Option, #[serde(rename = "createdAt")] pub created_at: DateTime, /// Supports a future "disable without delete" path. Phase B soft-revoke /// hard-deletes the doc, but the field is kept for forward-compat. #[serde(rename = "active", default = "default_true")] pub active: bool, } fn default_read() -> Vec { vec!["read".to_string()] } fn default_true() -> bool { true } pub struct ProfileShareRepository { collection: Collection, } impl ProfileShareRepository { pub fn new(collection: Collection) -> Self { Self { collection } } pub async fn create(&self, share: &ProfileShare) -> mongodb::error::Result<()> { self.collection.insert_one(share, None).await?; Ok(()) } /// All shares where the given account is the recipient (for the /// `/profiles/shared-with-me` endpoint). pub async fn find_for_recipient( &self, recipient_user_id: &str, ) -> mongodb::error::Result> { let mut cursor = self .collection .find(doc! { "recipientUserId": recipient_user_id }, None) .await?; let mut out = Vec::new(); while let Some(s) = cursor.next().await { out.push(s?); } Ok(out) } /// All shares for a given profile (owner listing who they've shared with). pub async fn find_for_profile( &self, profile_id: &str, ) -> mongodb::error::Result> { let mut cursor = self .collection .find(doc! { "profileId": profile_id }, None) .await?; let mut out = Vec::new(); while let Some(s) = cursor.next().await { out.push(s?); } Ok(out) } /// A specific (profile, recipient) share regardless of active/expiry state. pub async fn find( &self, profile_id: &str, recipient_user_id: &str, ) -> mongodb::error::Result> { self.collection .find_one( doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, None, ) .await } /// A specific (profile, recipient) share, only if currently usable: /// `active == true` and not past `expires_at`. Used by the share-gate. pub async fn find_active( &self, profile_id: &str, recipient_user_id: &str, ) -> mongodb::error::Result> { let now = DateTime::now(); // active==true AND (expiresAt missing OR expiresAt > now) let filter = doc! { "profileId": profile_id, "recipientUserId": recipient_user_id, "active": true, "$or": [ { "expiresAt": { "$exists": false } }, { "expiresAt": null }, { "expiresAt": { "$gt": now } }, ], }; self.collection.find_one(filter, None).await } /// All currently-active shares for a profile (Phase C rekey needs the full /// recipient list to validate submitted envelopes and hard-delete omitted /// recipients). "Active" = `active==true` and not past `expires_at`. pub async fn find_active_for_profile( &self, profile_id: &str, ) -> mongodb::error::Result> { let now = DateTime::now(); let filter = doc! { "profileId": profile_id, "active": true, "$or": [ { "expiresAt": { "$exists": false } }, { "expiresAt": null }, { "expiresAt": { "$gt": now } }, ], }; let mut cursor = self.collection.find(filter, None).await?; let mut out = Vec::new(); while let Some(s) = cursor.next().await { out.push(s?); } Ok(out) } /// Hard-delete every share for the profile whose recipient is NOT in /// `keep_recipient_ids` — the hard-revoke action in Phase C rekey. Recipients /// omitted from the rekey call lose access at the key level (their cached /// old DEK won't match the rotated one) AND the server stops serving them. /// Returns the number deleted. pub async fn delete_for_profile_excluding( &self, profile_id: &str, keep_recipient_ids: &[String], ) -> mongodb::error::Result { let mut filter = doc! { "profileId": profile_id }; if !keep_recipient_ids.is_empty() { filter.insert("recipientUserId", doc! { "$nin": keep_recipient_ids }); } let res = self.collection.delete_many(filter, None).await?; Ok(res.deleted_count) } /// Hard-delete (soft-revoke) the (profile, recipient) share. Returns true /// if a doc was deleted. pub async fn delete( &self, profile_id: &str, recipient_user_id: &str, ) -> mongodb::error::Result { let res = self .collection .delete_one( doc! { "profileId": profile_id, "recipientUserId": recipient_user_id }, None, ) .await?; Ok(res.deleted_count > 0) } /// Upsert: replace any existing (profile, recipient) share with the given /// one. Used when re-sharing (e.g. rotating the ephemeral key). Deletes /// existing rows for the pair first, then inserts. pub async fn upsert(&self, share: &ProfileShare) -> mongodb::error::Result<()> { let _ = self .collection .delete_one( doc! { "profileId": &share.profile_id, "recipientUserId": &share.recipient_user_id, }, None, ) .await?; self.collection.insert_one(share, None).await?; Ok(()) } }