An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.
Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
(find_for_recipient, find_for_profile, find_active [checks active +
expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
GET /users/public-key (public), and authorize_profile_read — the
share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
and health stats: when profile_id is specified, resolve the owner via
the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
permission}.rs, handlers/{shares,permissions}.rs, middleware/
permission.rs (dead), the shares collection field + methods in
mongodb_impl.rs, the shares index, and the 5 /api/shares +
/api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
isolation, share-to-self/nonexistent/keyless rejections, expired
share treated as absent.
Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
identity private key), shareProfile, revokeShare, loadProfileShares.
Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).
Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.
⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.
Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
241 lines
9 KiB
Rust
241 lines
9 KiB
Rust
use anyhow::Result;
|
|
use mongodb::bson::oid::ObjectId;
|
|
use mongodb::{bson::doc, options::ClientOptions, Client, Collection, Database};
|
|
use std::time::Duration;
|
|
|
|
use crate::models::{
|
|
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
|
|
user::{User, UserRepository},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub struct MongoDb {
|
|
database: Database,
|
|
pub users: Collection<User>,
|
|
pub medications: Collection<Medication>,
|
|
pub medication_doses: Collection<MedicationDose>,
|
|
}
|
|
|
|
impl MongoDb {
|
|
pub async fn new(uri: &str, db_name: &str) -> Result<Self> {
|
|
eprintln!("[MongoDB] Starting connection to: {}", uri);
|
|
|
|
// Parse the URI first
|
|
let mut client_options = match ClientOptions::parse(uri).await {
|
|
Ok(opts) => {
|
|
eprintln!("[MongoDB] URI parsed successfully");
|
|
opts
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[MongoDB] ERROR: Failed to parse URI: {}", e);
|
|
|
|
let error_msg = e.to_string().to_lowercase();
|
|
if error_msg.contains("dns")
|
|
|| error_msg.contains("resolution")
|
|
|| error_msg.contains("lookup")
|
|
{
|
|
eprintln!("[MongoDB] DNS RESOLUTION ERROR DETECTED!");
|
|
eprintln!("[MongoDB] Cannot resolve hostname in: {}", uri);
|
|
eprintln!("[MongoDB] Error: {}", e);
|
|
}
|
|
eprintln!(
|
|
"[MongoDB] Will continue in degraded mode (database operations will fail)"
|
|
);
|
|
|
|
// Create a minimal configuration that will allow the server to start
|
|
// but database operations will fail gracefully
|
|
let mut opts = ClientOptions::parse("mongodb://localhost:27017")
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow::anyhow!("Failed to create fallback client options: {}", e)
|
|
})?;
|
|
opts.server_selection_timeout = Some(Duration::from_secs(1));
|
|
opts.connect_timeout = Some(Duration::from_secs(1));
|
|
|
|
let client = Client::with_options(opts)
|
|
.map_err(|e| anyhow::anyhow!("Failed to create MongoDB client: {}", e))?;
|
|
|
|
let database = client.database(db_name);
|
|
|
|
return Ok(Self {
|
|
users: database.collection("users"),
|
|
medications: database.collection("medications"),
|
|
medication_doses: database.collection("medication_doses"),
|
|
database,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Set connection timeout with retry logic
|
|
client_options.server_selection_timeout = Some(Duration::from_secs(10));
|
|
client_options.connect_timeout = Some(Duration::from_secs(10));
|
|
|
|
eprintln!("[MongoDB] Connecting to server...");
|
|
|
|
let client = match Client::with_options(client_options) {
|
|
Ok(c) => {
|
|
eprintln!("[MongoDB] Client created successfully");
|
|
c
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[MongoDB] ERROR: Failed to create client: {}", e);
|
|
eprintln!("[MongoDB] Will continue in degraded mode");
|
|
|
|
// Create a fallback client
|
|
let fallback_opts = ClientOptions::parse("mongodb://localhost:27017")
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow::anyhow!("Failed to create fallback client options: {}", e)
|
|
})?;
|
|
let fallback_client = Client::with_options(fallback_opts)
|
|
.map_err(|e| anyhow::anyhow!("Failed to create MongoDB client: {}", e))?;
|
|
let database = fallback_client.database(db_name);
|
|
|
|
return Ok(Self {
|
|
users: database.collection("users"),
|
|
medications: database.collection("medications"),
|
|
medication_doses: database.collection("medication_doses"),
|
|
database,
|
|
});
|
|
}
|
|
};
|
|
|
|
eprintln!("[MongoDB] Client created, selecting database...");
|
|
|
|
let database = client.database(db_name);
|
|
|
|
eprintln!("[MongoDB] Database selected: {}", db_name);
|
|
|
|
Ok(Self {
|
|
users: database.collection("users"),
|
|
medications: database.collection("medications"),
|
|
medication_doses: database.collection("medication_doses"),
|
|
database,
|
|
})
|
|
}
|
|
|
|
pub async fn health_check(&self) -> Result<String> {
|
|
eprintln!("[MongoDB] Health check: pinging database...");
|
|
match self.database.run_command(doc! { "ping": 1 }, None).await {
|
|
Ok(_) => {
|
|
eprintln!("[MongoDB] Health check: OK");
|
|
Ok("OK".to_string())
|
|
}
|
|
Err(e) => {
|
|
eprintln!("[MongoDB] Health check: FAILED - {}", e);
|
|
// Return OK anyway to allow the server to continue running
|
|
// The actual database operations will fail when needed
|
|
Ok("DEGRADED".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a reference to the underlying MongoDB Database
|
|
/// This is needed for security services in Phase 2.6
|
|
pub fn get_database(&self) -> Database {
|
|
self.database.clone()
|
|
}
|
|
|
|
// ===== User Methods =====
|
|
|
|
pub async fn create_user(&self, user: &User) -> Result<Option<ObjectId>> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
Ok(repo.create(user).await?)
|
|
}
|
|
|
|
pub async fn find_user_by_email(&self, email: &str) -> Result<Option<User>> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
Ok(repo.find_by_email(email).await?)
|
|
}
|
|
|
|
pub async fn find_user_by_id(&self, id: &ObjectId) -> Result<Option<User>> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
Ok(repo.find_by_id(id).await?)
|
|
}
|
|
|
|
pub async fn update_user(&self, user: &User) -> Result<()> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
repo.update(user).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_last_active(&self, user_id: &ObjectId) -> Result<()> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
repo.update_last_active(user_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_user(&self, user_id: &ObjectId) -> Result<()> {
|
|
let repo = UserRepository::new(self.users.clone());
|
|
repo.delete(user_id).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ===== Medication Methods (Fixed for Phase 2.8) =====
|
|
|
|
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
|
|
let repo = MedicationRepository::new(self.medications.clone());
|
|
let created = repo
|
|
.create(medication.clone())
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to create medication: {}", e))?;
|
|
Ok(created.id)
|
|
}
|
|
|
|
pub async fn get_medication(&self, id: &str) -> Result<Option<Medication>> {
|
|
let object_id = ObjectId::parse_str(id)?;
|
|
let repo = MedicationRepository::new(self.medications.clone());
|
|
repo.find_by_id(&object_id)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to get medication: {}", e))
|
|
}
|
|
|
|
pub async fn list_medications(
|
|
&self,
|
|
user_id: &str,
|
|
profile_id: Option<&str>,
|
|
) -> Result<Vec<Medication>> {
|
|
let repo = MedicationRepository::new(self.medications.clone());
|
|
if let Some(profile_id) = profile_id {
|
|
Ok(repo
|
|
.find_by_user_and_profile(user_id, profile_id)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to list medications by profile: {}", e))?)
|
|
} else {
|
|
Ok(repo
|
|
.find_by_user(user_id)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to list medications: {}", e))?)
|
|
}
|
|
}
|
|
|
|
pub async fn update_medication(
|
|
&self,
|
|
id: &str,
|
|
updates: UpdateMedicationRequest,
|
|
) -> Result<Option<Medication>> {
|
|
let object_id = ObjectId::parse_str(id)?;
|
|
let repo = MedicationRepository::new(self.medications.clone());
|
|
repo.update(&object_id, updates)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to update medication: {}", e))
|
|
}
|
|
|
|
pub async fn delete_medication(&self, id: &str) -> Result<bool> {
|
|
let object_id = ObjectId::parse_str(id)?;
|
|
let repo = MedicationRepository::new(self.medications.clone());
|
|
repo.delete(&object_id)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to delete medication: {}", e))
|
|
}
|
|
|
|
pub async fn log_medication_dose(&self, dose: &MedicationDose) -> Result<Option<ObjectId>> {
|
|
// Insert the dose into the medication_doses collection
|
|
let result = self
|
|
.medication_doses
|
|
.insert_one(dose.clone(), None)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Failed to log dose: {}", e))?;
|
|
Ok(result.inserted_id.as_object_id())
|
|
}
|
|
}
|