diff --git a/backend/src/app.rs b/backend/src/app.rs index fc19592..27c39b0 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -40,8 +40,8 @@ pub fn build_app(state: AppState) -> Router { // Build protected routes (auth required) let protected_routes = Router::new() // User profile management - .route("/api/users/me", get(handlers::get_profile)) - .route("/api/users/me", put(handlers::update_profile)) + .route("/api/users/me", get(handlers::get_account)) + .route("/api/users/me", put(handlers::update_account)) .route("/api/users/me", delete(handlers::delete_account)) .route("/api/users/me/change-password", post(handlers::change_password)) // User settings @@ -54,9 +54,12 @@ pub fn build_app(state: AppState) -> Router { .route("/api/shares/:id", delete(handlers::delete_share)) // Permission checking .route("/api/permissions/check", post(handlers::check_permission)) - // Profile management (Phase 3c) - .route("/api/profiles/me", get(handlers::get_my_profile)) - .route("/api/profiles/me", put(handlers::update_my_profile)) + // Profile management (Phase A2: multi-profile + per-profile DEKs) + .route("/api/profiles", get(handlers::list_profiles)) + .route("/api/profiles", post(handlers::create_profile)) + .route("/api/profiles/:id", get(handlers::get_profile)) + .route("/api/profiles/:id", put(handlers::update_profile)) + .route("/api/profiles/:id", delete(handlers::delete_profile)) // Session management (Phase 2.6) .route("/api/sessions", get(handlers::get_sessions)) .route("/api/sessions/:id", delete(handlers::revoke_session)) diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs index 04e3bbd..efe5383 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -1,6 +1,9 @@ use anyhow::Result; use pbkdf2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + password_hash::{ + rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher, + PasswordVerifier, SaltString, + }, Pbkdf2, }; @@ -15,14 +18,24 @@ impl PasswordService { Ok(password_hash.to_string()) } + /// Verify a password against a stored PHC string. + /// + /// Returns `Ok(true)` on a match, `Ok(false)` on a mismatch (wrong + /// password — an expected outcome, not an error), and `Err(...)` only for + /// genuine failures (unparseable hash, malformed input, etc.). Returning + /// `Ok(false)` for a wrong password lets the login handler distinguish + /// "invalid credentials" (401) from a real backend fault (500). pub fn verify_password(password: &str, hash: &str) -> Result { let parsed_hash = PasswordHash::new(hash) .map_err(|e| anyhow::anyhow!("Failed to parse password hash: {}", e))?; - Pbkdf2 - .verify_password(password.as_bytes(), &parsed_hash) - .map(|_| true) - .map_err(|e| anyhow::anyhow!("Password verification failed: {}", e)) + match Pbkdf2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(()) => Ok(true), + // `Error::Password` is the password-hash crate's signal for a + // mismatch — the one expected "failure" during normal auth. + Err(PasswordHashError::Password) => Ok(false), + Err(e) => Err(anyhow::anyhow!("Password verification failed: {}", e)), + } } } diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs index 9d9f054..9b8d709 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -20,6 +20,8 @@ use mongodb::bson::DateTime; pub struct AppointmentListQuery { /// Filter by status (upcoming/completed/cancelled). Optional. pub status: Option, + /// Filter by profile id. Optional. + pub profile_id: Option, } pub async fn create_appointment( @@ -68,7 +70,11 @@ pub async fn list_appointments( let repo = AppointmentRepository::new(database.collection("appointments")); match repo - .find_by_user_filtered(&claims.sub, query.status.as_deref()) + .find_by_user_filtered( + &claims.sub, + query.status.as_deref(), + query.profile_id.as_deref(), + ) .await { Ok(appointments) => { diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 9935fd5..1b7a2d4 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -32,6 +32,15 @@ pub struct RegisterRequest { /// Account X25519 identity private key, wrapped under the account DEK. pub identity_private_key_wrapped: Option, pub identity_private_key_wrapped_iv: Option, + /// Default-profile display name (opaque, encrypted under the profile DEK) + /// and the profile DEK wrapped under the account DEK. When present, the + /// register flow auto-creates a "self" profile; when absent, no profile is + /// created and the client is expected to POST /api/profiles on first run. + pub default_profile_name_data: Option, + pub default_profile_name_iv: Option, + pub default_profile_name_auth_tag: Option, + pub default_wrapped_profile_dek: Option, + pub default_wrapped_profile_dek_iv: Option, } #[derive(Debug, Serialize)] @@ -145,17 +154,29 @@ pub async fn register( .await; } - // Auto-create a default profile for the new user. The profile_id is - // deterministic (profile_) and is the contract the frontend - // uses for medication creation. Best-effort: registration still - // succeeds if this fails (GET /profiles/me lazily creates one). - let database = state.db.get_database(); - let profile_repo = - crate::models::profile::ProfileRepository::new(database.collection("profiles")); - let profile = - crate::handlers::profile::build_default_profile(&id.to_string(), &req.username); - if let Err(e) = profile_repo.create(&profile).await { - tracing::warn!("Failed to auto-create profile for {}: {}", id, e); + // Auto-create the default "self" profile for the new user, but only + // when the client provided a wrapped profile DEK. The profile_id is + // deterministic (profile_). Best-effort: registration still + // succeeds if this fails or if no wrapped DEK was provided (the + // client creates the profile via POST /api/profiles on first run). + if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = ( + req.default_wrapped_profile_dek.as_ref(), + req.default_wrapped_profile_dek_iv.as_ref(), + ) { + let database = state.db.get_database(); + let profile_repo = + crate::models::profile::ProfileRepository::new(database.collection("profiles")); + let profile = crate::handlers::profile::build_default_profile( + &id.to_string(), + wrapped_dek, + wrapped_dek_iv, + req.default_profile_name_data.as_deref().unwrap_or(""), + req.default_profile_name_iv.as_deref().unwrap_or(""), + req.default_profile_name_auth_tag.as_deref().unwrap_or(""), + ); + if let Err(e) = profile_repo.create(&profile).await { + tracing::warn!("Failed to auto-create profile for {}: {}", id, e); + } } id diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index ca902cd..aa62870 100644 --- a/backend/src/handlers/health_stats.rs +++ b/backend/src/handlers/health_stats.rs @@ -3,7 +3,7 @@ use crate::config::AppState; use crate::models::health_stats::{HealthStatResponse, HealthStatistic}; use crate::models::medication::EncryptedFieldWire; use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -14,9 +14,16 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct CreateHealthStatRequest { pub encrypted_data: EncryptedFieldWire, + /// Which profile this stat belongs to. + pub profile_id: String, pub recorded_at: Option, } +#[derive(Debug, Deserialize, Default)] +pub struct ListHealthStatsQuery { + pub profile_id: Option, +} + #[derive(Debug, Deserialize)] pub struct UpdateHealthStatRequest { pub encrypted_data: EncryptedFieldWire, @@ -41,6 +48,7 @@ pub async fn create_health_stat( let stat = HealthStatistic { id: None, user_id: claims.sub.clone(), + profile_id: req.profile_id, encrypted_data: req.encrypted_data, recorded_at: req .recorded_at @@ -70,6 +78,7 @@ pub async fn create_health_stat( pub async fn list_health_stats( State(state): State, Extension(claims): Extension, + Query(query): Query, ) -> impl IntoResponse { let repo = match state.health_stats_repo.as_ref() { Some(r) => r, @@ -81,7 +90,10 @@ pub async fn list_health_stats( .into_response() } }; - match repo.find_by_user(&claims.sub).await { + match repo + .find_by_user_filtered(&claims.sub, query.profile_id.as_deref()) + .await + { Ok(stats) => { let resp: Vec = stats.into_iter().map(Into::into).collect(); (StatusCode::OK, Json(resp)).into_response() diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 521d0b1..e90ba39 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -25,9 +25,9 @@ pub use medications::{ log_dose, update_medication, }; pub use permissions::check_permission; -pub use profile::{get_my_profile, update_my_profile}; +pub use profile::{create_profile, delete_profile, get_profile, list_profiles, update_profile}; pub use sessions::{get_sessions, revoke_all_sessions, revoke_session}; pub use shares::{create_share, delete_share, list_shares, update_share}; pub use users::{ - change_password, delete_account, get_profile, get_settings, update_profile, update_settings, + change_password, delete_account, get_account, get_settings, update_account, update_settings, }; diff --git a/backend/src/handlers/profile.rs b/backend/src/handlers/profile.rs index ea3d37e..c3edb21 100644 --- a/backend/src/handlers/profile.rs +++ b/backend/src/handlers/profile.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, State}, + extract::{Extension, Path, State}, http::StatusCode, Json, }; @@ -12,17 +12,21 @@ use crate::{ models::profile::{Profile, ProfileRepository}, }; -/// The profile as exposed to clients. The display name is returned as an opaque -/// client-encrypted blob (the server cannot read it). +/// The profile as exposed to clients. Display name + wrapped profile DEK are +/// returned as opaque client-encrypted blobs (the server cannot read either). #[derive(Debug, Serialize)] pub struct ProfileResponse { pub profile_id: String, - pub user_id: String, - /// Opaque client-encrypted display name. + pub owner_account_id: String, pub name_data: String, pub name_iv: String, + pub kind: String, + pub relationship: String, pub role: String, pub permissions: Vec, + /// Profile DEK wrapped under the owner's account DEK (opaque). + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, pub created_at: DateTime, pub updated_at: DateTime, } @@ -31,98 +35,212 @@ impl From for ProfileResponse { fn from(p: Profile) -> Self { Self { profile_id: p.profile_id, - user_id: p.user_id, + owner_account_id: p.owner_account_id, name_data: p.name, name_iv: p.name_iv, + kind: p.kind, + relationship: p.relationship, role: p.role, permissions: p.permissions, + wrapped_profile_dek: p.wrapped_profile_dek, + wrapped_profile_dek_iv: p.wrapped_profile_dek_iv, created_at: p.created_at, updated_at: p.updated_at, } } } -/// Create the default "patient" profile for a freshly registered user. Called -/// from `register`. The profile_id is deterministic: `profile_` — this -/// is the contract the frontend relies on for medication creation. The name is -/// empty (not encrypted) because the server has no encryption key; the client -/// sets it via PUT /profiles/me after deriving its key. -pub fn build_default_profile(user_id: &str, _name: &str) -> Profile { +/// Build a default "self" profile for a freshly registered user. Called from +/// `register` when the client provides a wrapped profile DEK for the default +/// profile. The server has no encryption key, so the name blob and wrapped +/// profile DEK are taken verbatim from the caller (the client generated the +/// profile DEK and wrapped it under the account DEK). +pub fn build_default_profile( + owner_account_id: &str, + wrapped_profile_dek: &str, + wrapped_profile_dek_iv: &str, + name_data: &str, + name_iv: &str, + name_auth_tag: &str, +) -> Profile { let now = DateTime::now(); Profile { id: None, - profile_id: format!("profile_{user_id}"), - user_id: user_id.to_string(), + // profile_ keeps the deterministic-id contract the frontend + // used pre-A2 for the self profile. + profile_id: format!("profile_{owner_account_id}"), + owner_account_id: owner_account_id.to_string(), + user_id: owner_account_id.to_string(), family_id: None, - name: String::new(), - name_iv: String::new(), - name_auth_tag: String::new(), + name: name_data.to_string(), + name_iv: name_iv.to_string(), + name_auth_tag: name_auth_tag.to_string(), + kind: "human".to_string(), + relationship: "self".to_string(), role: "patient".to_string(), permissions: vec!["read:self".to_string(), "write:self".to_string()], + wrapped_profile_dek: wrapped_profile_dek.to_string(), + wrapped_profile_dek_iv: wrapped_profile_dek_iv.to_string(), created_at: now, updated_at: now, } } -/// PUT /profiles/me body — the client sends the encrypted name blob. +fn profile_repo(state: &AppState) -> ProfileRepository { + ProfileRepository::new(state.db.get_database().collection("profiles")) +} + +// --------------------------------------------------------------------------- +// GET /api/profiles — list all profiles owned by the current account +// --------------------------------------------------------------------------- + +pub async fn list_profiles( + State(state): State, + Extension(claims): Extension, +) -> Result>, (StatusCode, Json)> { + let repo = profile_repo(&state); + match repo.find_all_by_owner(&claims.sub).await { + Ok(profiles) => { + let resp: Vec = profiles.into_iter().map(Into::into).collect(); + Ok(Json(resp)) + } + Err(e) => { + tracing::error!("Profile list failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// POST /api/profiles — create a new profile (child, pet, ...). +// The client generates the profile DEK, wraps it under the account DEK, and +// sends the wrapped blob + the encrypted display name. +// --------------------------------------------------------------------------- + #[derive(Debug, Deserialize)] -pub struct UpdateProfileNameRequest { +pub struct CreateProfileRequest { + /// Optional explicit profile id; defaults to a fresh UUID if absent. + #[serde(default)] + pub profile_id: Option, + #[serde(default = "default_kind_value")] + pub kind: String, + #[serde(default)] + pub relationship: String, pub name_data: String, pub name_iv: String, #[serde(default)] pub name_auth_tag: String, + /// Profile DEK wrapped under the account DEK (opaque ciphertext). + pub wrapped_profile_dek: String, + pub wrapped_profile_dek_iv: String, } -/// GET /api/profiles/me — the current user's profile. If for some reason the -/// auto-created profile is missing, lazily create it. -pub async fn get_my_profile( +fn default_kind_value() -> String { + "human".to_string() +} + +pub async fn create_profile( State(state): State, Extension(claims): Extension, -) -> Result, (StatusCode, Json)> { - let database = state.db.get_database(); - let repo = ProfileRepository::new(database.collection("profiles")); - - let profile = match repo.find_by_user_id(&claims.sub).await { - Ok(Some(p)) => p, - Ok(None) => { - // Lazily create if missing (e.g. users registered before this code shipped). - let p = build_default_profile(&claims.sub, &claims.sub); - if let Err(e) = repo.create(&p).await { - tracing::error!("Failed to lazily create profile: {}", e); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "failed to load profile" })), - )); - } - p - } - Err(e) => { - tracing::error!("Profile lookup failed: {}", e); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ "error": "database error" })), - )); - } + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let now = DateTime::now(); + let profile = Profile { + id: None, + profile_id: req + .profile_id + .unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())), + owner_account_id: claims.sub.clone(), + user_id: claims.sub.clone(), + family_id: None, + name: req.name_data, + name_iv: req.name_iv, + name_auth_tag: req.name_auth_tag, + kind: req.kind, + relationship: req.relationship, + role: "patient".to_string(), + permissions: vec!["read:self".to_string(), "write:self".to_string()], + wrapped_profile_dek: req.wrapped_profile_dek, + wrapped_profile_dek_iv: req.wrapped_profile_dek_iv, + created_at: now, + updated_at: now, }; - Ok(Json(ProfileResponse::from(profile))) + let repo = profile_repo(&state); + if let Err(e) = repo.create(&profile).await { + tracing::error!("Profile create failed: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )); + } + Ok((StatusCode::CREATED, Json(ProfileResponse::from(profile)))) } -/// PUT /api/profiles/me — update the profile's encrypted display-name blob. -pub async fn update_my_profile( +// --------------------------------------------------------------------------- +// GET /api/profiles/:id — fetch one owned profile +// --------------------------------------------------------------------------- + +pub async fn get_profile( State(state): State, Extension(claims): Extension, - Json(req): Json, + Path(profile_id): Path, ) -> Result, (StatusCode, Json)> { - let database = state.db.get_database(); - let repo = ProfileRepository::new(database.collection("profiles")); - + let repo = profile_repo(&state); match repo - .update_encrypted_name( + .find_by_profile_id_owned(&profile_id, &claims.sub) + .await + { + Ok(Some(p)) => Ok(Json(ProfileResponse::from(p))), + Ok(None) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile lookup failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} + +// --------------------------------------------------------------------------- +// PUT /api/profiles/:id — update an owned profile's mutable fields +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct UpdateProfileRequest { + pub name_data: String, + pub name_iv: String, + #[serde(default)] + pub name_auth_tag: String, + #[serde(default = "default_kind_value")] + pub kind: String, + #[serde(default)] + pub relationship: String, +} + +pub async fn update_profile( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let repo = profile_repo(&state); + match repo + .update_profile( + &profile_id, &claims.sub, &req.name_data, &req.name_iv, &req.name_auth_tag, + &req.kind, + &req.relationship, ) .await { @@ -140,3 +258,29 @@ pub async fn update_my_profile( } } } + +// --------------------------------------------------------------------------- +// DELETE /api/profiles/:id — delete an owned profile +// --------------------------------------------------------------------------- + +pub async fn delete_profile( + State(state): State, + Extension(claims): Extension, + Path(profile_id): Path, +) -> Result)> { + let repo = profile_repo(&state); + match repo.delete_profile(&profile_id, &claims.sub).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "profile not found" })), + )), + Err(e) => { + tracing::error!("Profile delete failed: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + )) + } + } +} diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 0dc0660..9b3ea33 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -39,7 +39,7 @@ pub struct UpdateProfileRequest { pub username: Option, } -pub async fn get_profile( +pub async fn get_account( State(state): State, Extension(claims): Extension, ) -> impl IntoResponse { @@ -79,7 +79,7 @@ pub async fn get_profile( } } -pub async fn update_profile( +pub async fn update_account( State(state): State, Extension(claims): Extension, Json(req): Json, diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index d6d5efb..8ba2140 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -126,16 +126,21 @@ impl AppointmentRepository { Ok(appointment) } - /// List a user's appointments, optionally filtered by top-level `status`. + /// List a user's appointments, optionally filtered by top-level `status` + /// and/or `profileId`. pub async fn find_by_user_filtered( &self, user_id: &str, status: Option<&str>, + profile_id: Option<&str>, ) -> Result, Box> { let mut filter = doc! { "userId": user_id }; if let Some(s) = status { filter.insert("status", s); } + if let Some(p) = profile_id { + filter.insert("profileId", p); + } let mut cursor = self.collection.find(filter, None).await?; let mut appointments = Vec::new(); while let Some(appt) = cursor.next().await { diff --git a/backend/src/models/health_stats.rs b/backend/src/models/health_stats.rs index 5f28485..4b11669 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -15,6 +15,9 @@ pub struct HealthStatistic { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, pub user_id: String, + /// Which profile this stat belongs to (plaintext, filterable). + #[serde(rename = "profileId", default)] + pub profile_id: String, /// Opaque client-encrypted blob (value, unit, type, notes inside). #[serde(rename = "encryptedData")] pub encrypted_data: EncryptedFieldWire, @@ -27,6 +30,7 @@ pub struct HealthStatistic { pub struct HealthStatResponse { pub id: String, pub user_id: String, + pub profile_id: String, pub encrypted_data: EncryptedFieldWire, pub recorded_at: String, } @@ -36,6 +40,7 @@ impl From for HealthStatResponse { HealthStatResponse { id: s.id.map(|o| o.to_hex()).unwrap_or_default(), user_id: s.user_id, + profile_id: s.profile_id, encrypted_data: s.encrypted_data, recorded_at: s.recorded_at, } @@ -70,6 +75,21 @@ impl HealthStatisticsRepository { cursor.try_collect().await } + /// All stats for a user, optionally filtered by profile_id at the Mongo + /// level (real top-level document field). + pub async fn find_by_user_filtered( + &self, + user_id: &str, + profile_id: Option<&str>, + ) -> Result, MongoError> { + let mut filter = doc! { "user_id": user_id }; + if let Some(pid) = profile_id { + filter.insert("profileId", pid); + } + let cursor = self.collection.find(filter, None).await?; + cursor.try_collect().await + } + pub async fn find_by_id(&self, id: &ObjectId) -> Result, MongoError> { let filter = doc! { "_id": id }; self.collection.find_one(filter, None).await diff --git a/backend/src/models/profile.rs b/backend/src/models/profile.rs index 3881064..288e4d7 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -1,35 +1,76 @@ +use futures::stream::StreamExt; use mongodb::{ bson::{doc, oid::ObjectId, DateTime}, Collection, }; use serde::{Deserialize, Serialize}; +/// A profile (a "subject of care" — a person or pet whose health data is +/// tracked). One account owns zero or more profiles. All health data +/// (medications, appointments, health stats) is scoped to a profile and +/// encrypted under that profile's DEK. +/// +/// Zero-knowledge layers (both opaque to the server): +/// - `name` / `wrapped_profile_dek` are AES-256-GCM ciphertext blobs the +/// server stores verbatim and cannot read. +/// - `name` is the profile's display name encrypted under the profile DEK. +/// - `wrapped_profile_dek` is the profile DEK encrypted under the owner's +/// account DEK. The client unwraps it at login/unlock (after unwrapping +/// the account DEK) to get the profile DEK. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(rename = "profileId")] pub profile_id: String, + /// Owner account id (Mongo ObjectId hex). Replaces the 1:1 `user_id` + /// semantics from the pre-A2 single-profile model. + #[serde(rename = "ownerAccountId")] + pub owner_account_id: String, + /// Kept populated = owner_account_id for backward compat with existing + /// medication/appointment filters that key on `userId`. Same value; do + /// not rely on it for ownership — use `owner_account_id`. #[serde(rename = "userId")] pub user_id: String, #[serde(rename = "familyId")] pub family_id: Option, + /// Display name — opaque, encrypted under the profile DEK. #[serde(rename = "name")] pub name: String, #[serde(rename = "nameIv")] pub name_iv: String, #[serde(rename = "nameAuthTag")] pub name_auth_tag: String, - #[serde(rename = "role")] + /// "human" | "pet" — what kind of subject this profile tracks. + #[serde(rename = "kind", default = "default_kind")] + pub kind: String, + /// Freeform relationship to the owner: "self", "child", "spouse", + /// "parent", "pet", ... + #[serde(rename = "relationship", default)] + pub relationship: String, + #[serde(rename = "role", default = "default_role")] pub role: String, - #[serde(rename = "permissions")] + #[serde(rename = "permissions", default)] pub permissions: Vec, + /// Profile DEK wrapped under the owner's account DEK (opaque ciphertext). + #[serde(rename = "wrappedProfileDek")] + pub wrapped_profile_dek: String, + #[serde(rename = "wrappedProfileDekIv")] + pub wrapped_profile_dek_iv: String, #[serde(rename = "createdAt")] pub created_at: DateTime, #[serde(rename = "updatedAt")] pub updated_at: DateTime, } +fn default_kind() -> String { + "human".to_string() +} + +fn default_role() -> String { + "patient".to_string() +} + pub struct ProfileRepository { collection: Collection, } @@ -44,6 +85,7 @@ impl ProfileRepository { Ok(()) } + /// Look up a profile by its application-level profile id. pub async fn find_by_profile_id( &self, profile_id: &str, @@ -53,32 +95,77 @@ impl ProfileRepository { .await } - /// Look up a profile by its owning user id. - pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result> { + /// Look up a profile by id AND owner — used for ownership-scoped access. + /// Returns None if the profile doesn't exist or doesn't belong to `owner`. + pub async fn find_by_profile_id_owned( + &self, + profile_id: &str, + owner: &str, + ) -> mongodb::error::Result> { self.collection - .find_one(doc! { "userId": user_id }, None) + .find_one( + doc! { "profileId": profile_id, "ownerAccountId": owner }, + None, + ) .await } - /// Update the profile's display name (opaque client-encrypted blob). - pub async fn update_encrypted_name( + /// All profiles owned by an account. + pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result> { + let mut cursor = self + .collection + .find(doc! { "ownerAccountId": owner }, None) + .await?; + let mut out = Vec::new(); + while let Some(p) = cursor.next().await { + out.push(p?); + } + Ok(out) + } + + /// Replace a profile's mutable fields (display name blob, kind, + /// relationship), keyed by (profile_id, owner). Returns the updated doc + /// or None if the profile doesn't exist / isn't owned by `owner`. + pub async fn update_profile( &self, - user_id: &str, + profile_id: &str, + owner: &str, name_data: &str, name_iv: &str, name_auth_tag: &str, + kind: &str, + relationship: &str, ) -> mongodb::error::Result> { self.collection .find_one_and_update( - doc! { "userId": user_id }, + doc! { "profileId": profile_id, "ownerAccountId": owner }, doc! { "$set": { "name": name_data, "nameIv": name_iv, "nameAuthTag": name_auth_tag, + "kind": kind, + "relationship": relationship, "updatedAt": DateTime::now() }}, None, ) .await } + + /// Delete a profile keyed by (profile_id, owner). Returns true if a doc + /// was deleted, false if it didn't exist or wasn't owned by `owner`. + pub async fn delete_profile( + &self, + profile_id: &str, + owner: &str, + ) -> mongodb::error::Result { + let res = self + .collection + .delete_one( + doc! { "profileId": profile_id, "ownerAccountId": owner }, + None, + ) + .await?; + Ok(res.deleted_count > 0) + } } diff --git a/backend/tests/profile_tests.rs b/backend/tests/profile_tests.rs new file mode 100644 index 0000000..2a9a470 --- /dev/null +++ b/backend/tests/profile_tests.rs @@ -0,0 +1,255 @@ +//! Multi-profile integration tests (Phase A2). +//! +//! Exercises the /api/profiles CRUD endpoints: list, create, get, update, +//! delete — all ownership-scoped. A second registered user must not be able +//! to read or modify the first user's profiles. +//! +//! Requires a live MongoDB; skips gracefully otherwise (see common/mod.rs). + +mod common; + +use serde_json::{json, Value}; + +macro_rules! require_app { + ($app:expr) => { + match $app { + Some(x) => x, + None => { + eprintln!("[integration] skipped (MongoDB unavailable)"); + return; + } + } + }; +} + +/// Register a user (sending no default-profile fields → no profile auto-created) +/// and return the access token. +async fn register(app: &axum::Router, email: &str, password: &str) -> String { + let (status, body) = common::send_json( + app, + "POST", + "/api/auth/register", + Some(json!({ "email": email, "username": "tester", "password": password })), + None, + ) + .await; + assert_eq!(status, 201, "register precondition failed, body: {body}"); + body["token"].as_str().unwrap().to_string() +} + +fn unique_email() -> String { + format!("test_{}@example.com", uuid::Uuid::new_v4()) +} + +/// Body for creating a profile with a fake wrapped DEK (the server stores it +/// verbatim; it never inspects the contents). +fn create_body(kind: &str, relationship: &str) -> Value { + json!({ + "kind": kind, + "relationship": relationship, + "name_data": "base64-encrypted-name", + "name_iv": "base64-iv", + "wrapped_profile_dek": "base64-wrapped-profile-dek", + "wrapped_profile_dek_iv": "base64-dek-iv", + }) +} + +#[tokio::test] +async fn list_starts_empty_without_default_profile() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token = register(&app, &unique_email(), "supersecret").await; + + let (status, body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + assert_eq!(status, 200, "list should return 200, body: {body}"); + assert_eq!( + body.as_array().unwrap().len(), + 0, + "no default profile expected" + ); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn create_then_list_then_get_update_delete() { + let (app, db_name) = require_app!(common::app_for_test().await); + let token = register(&app, &unique_email(), "supersecret").await; + + // Create a profile. + let (status, body) = common::send_json( + &app, + "POST", + "/api/profiles", + Some(create_body("human", "self")), + Some(&token), + ) + .await; + assert_eq!(status, 201, "create should return 201, body: {body}"); + let profile_id = body["profile_id"].as_str().unwrap().to_string(); + assert_eq!(body["kind"], "human"); + assert_eq!(body["relationship"], "self"); + assert_eq!(body["wrapped_profile_dek"], "base64-wrapped-profile-dek"); + + // List now has one. + let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + assert_eq!(list_body.as_array().unwrap().len(), 1); + + // Get by id. + let (status, get_body) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 200); + assert_eq!(get_body["profile_id"], profile_id); + + // Update the display-name blob + relationship. + let (status, _) = common::send_json( + &app, + "PUT", + &format!("/api/profiles/{profile_id}"), + Some(json!({ + "name_data": "new-name-blob", + "name_iv": "new-iv", + "kind": "pet", + "relationship": "dog", + })), + Some(&token), + ) + .await; + assert_eq!(status, 200, "update should return 200"); + + // Delete. + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 204, "delete should return 204"); + + // Get now 404s. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token), + ) + .await; + assert_eq!(status, 404); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn other_user_cannot_access_profile() { + // Ownership isolation: user B must not GET/UPDATE/DELETE user A's profile. + let (app, db_name) = require_app!(common::app_for_test().await); + let token_a = register(&app, &unique_email(), "supersecret").await; + let token_b = register(&app, &unique_email(), "supersecret").await; + + // A creates a profile. + let (_, body) = common::send_json( + &app, + "POST", + "/api/profiles", + Some(create_body("human", "child")), + Some(&token_a), + ) + .await; + let profile_id = body["profile_id"].as_str().unwrap().to_string(); + + // B cannot GET it (404, because ownership filter excludes it). + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not read A's profile"); + + // B cannot UPDATE it. + let (status, _) = common::send_json( + &app, + "PUT", + &format!("/api/profiles/{profile_id}"), + Some(json!({ + "name_data": "x", "name_iv": "y", "kind": "human", "relationship": "self" + })), + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not update A's profile"); + + // B cannot DELETE it. + let (status, _) = common::send_json( + &app, + "DELETE", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_b), + ) + .await; + assert_eq!(status, 404, "B must not delete A's profile"); + + // A still sees it intact. + let (status, _) = common::send_json( + &app, + "GET", + &format!("/api/profiles/{profile_id}"), + None, + Some(&token_a), + ) + .await; + assert_eq!(status, 200, "A's profile must be untouched"); + + common::drop_test_db(&db_name).await; +} + +#[tokio::test] +async fn register_with_default_profile_creates_self_profile() { + // When register carries default_wrapped_profile_dek, the self profile is + // auto-created and shows up in GET /api/profiles. + let (app, db_name) = require_app!(common::app_for_test().await); + let email = unique_email(); + + let (_, body) = common::send_json( + &app, + "POST", + "/api/auth/register", + Some(json!({ + "email": email, + "username": "tester", + "password": "supersecret", + "default_profile_name_data": "name-blob", + "default_profile_name_iv": "name-iv", + "default_wrapped_profile_dek": "dek-blob", + "default_wrapped_profile_dek_iv": "dek-iv", + })), + None, + ) + .await; + let token = body["token"].as_str().unwrap().to_string(); + + let (_, list_body) = common::send_json(&app, "GET", "/api/profiles", None, Some(&token)).await; + let arr = list_body.as_array().unwrap(); + assert_eq!(arr.len(), 1, "default self profile should exist"); + assert_eq!(arr[0]["relationship"], "self"); + assert_eq!(arr[0]["kind"], "human"); + assert_eq!(arr[0]["wrapped_profile_dek"], "dek-blob"); + // Deterministic id contract: profile_. + assert!(arr[0]["profile_id"] + .as_str() + .unwrap() + .starts_with("profile_")); + + common::drop_test_db(&db_name).await; +} diff --git a/backend/tests/zk_integration_tests.rs b/backend/tests/zk_integration_tests.rs index 1e38c41..50c3c65 100644 --- a/backend/tests/zk_integration_tests.rs +++ b/backend/tests/zk_integration_tests.rs @@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() { "POST", "/api/health-stats", Some(json!({ + "profile_id": "default", "encrypted_data": { "data": opaque_data, "iv": "aXY=" }, "recorded_at": "2026-07-01T10:00:00Z", })), diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx index d3ee7b0..ed6d8cf 100644 --- a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx +++ b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx @@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { format } from 'date-fns'; -import { useAppointmentStore, useAuthStore } from '../../store/useStore'; +import { useAppointmentStore, useProfileStore } from '../../store/useStore'; import type { Appointment } from '../../types/api'; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; @@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => { deleteAppointment, clearError, } = useAppointmentStore(); - const user = useAuthStore((s) => s.user); + const activeProfileId = useProfileStore((s) => s.activeProfileId); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => { const [saving, setSaving] = useState(false); useEffect(() => { - loadAppointments(); - }, [loadAppointments]); + if (activeProfileId) loadAppointments(); + }, [loadAppointments, activeProfileId]); const openCreate = () => { setCreateForm({ ...emptyCreate, - profile_id: `profile_${user?.user_id ?? 'default'}`, + profile_id: activeProfileId ?? 'default', }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/medication/MedicationManager.test.tsx b/web/normogen-web/src/components/medication/MedicationManager.test.tsx index b70c813..93c5d11 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.test.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.test.tsx @@ -38,6 +38,8 @@ describe('MedicationManager', () => { resetMockStore(); setMockStore({ useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} }, + // The load effect is gated on activeProfileId (Phase A2); provide one. + useProfileStore: { activeProfileId: 'profile_u1' }, }); baseActions.loadMedications.mockClear(); }); diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx index e80679c..fcdfc09 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -24,7 +24,7 @@ import { import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; -import { useMedicationStore, useAuthStore } from '../../store/useStore'; +import { useMedicationStore, useProfileStore } from '../../store/useStore'; import type { Medication } from '../../types/api'; import { DoseLogger } from './DoseLogger'; @@ -60,7 +60,7 @@ export const MedicationManager: FC = () => { deleteMedication, clearError, } = useMedicationStore(); - const user = useAuthStore((s) => s.user); + const activeProfileId = useProfileStore((s) => s.activeProfileId); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -69,17 +69,16 @@ export const MedicationManager: FC = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); + // Reload medications when the active profile changes — each profile's data + // is encrypted under its own DEK and filtered server-side by profile_id. useEffect(() => { - loadMedications(); - }, [loadMedications]); + if (activeProfileId) loadMedications(); + }, [loadMedications, activeProfileId]); const openCreate = () => { - // profile_id is deterministic: profile_. The backend auto-creates - // this profile on register, so the id always resolves to a real profile. - const profileId = user?.profile_id ?? `profile_${user?.user_id ?? 'default'}`; setCreateForm({ ...emptyCreate, - profile_id: profileId, + profile_id: activeProfileId ?? 'default', }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx index 7f258f5..8876081 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -7,34 +7,61 @@ import { CircularProgress, Alert, Chip, + MenuItem, Stack, TextField, Typography, } from '@mui/material'; import SaveIcon from '@mui/icons-material/Save'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; import { useProfileStore, useAuthStore } from '../../store/useStore'; export const ProfileEditor: FC = () => { - const { profile, isLoading, error, loadProfile, updateName, clearError } = - useProfileStore(); + const { + profiles, + activeProfileId, + isLoading, + error, + loadProfiles, + updateProfile, + createProfile, + deleteProfile, + clearError, + } = useProfileStore(); const user = useAuthStore((s) => s.user); + const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null; + const [name, setName] = useState(''); + const [kind, setKind] = useState('human'); + const [relationship, setRelationship] = useState(''); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); - useEffect(() => { - loadProfile(); - }, [loadProfile]); + // Create-profile dialog state + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [newKind, setNewKind] = useState('human'); + const [newRel, setNewRel] = useState(''); useEffect(() => { - if (profile?.name) setName(profile.name); - }, [profile]); + loadProfiles(); + }, [loadProfiles]); + + useEffect(() => { + if (active) { + setName(active.name); + setKind(active.kind || 'human'); + setRelationship(active.relationship || ''); + } + }, [active]); const handleSave = async () => { + if (!active) return; setSaving(true); try { - await updateName(name); + await updateProfile(active.profile_id, { name, kind, relationship }); setEditing(false); } catch { /* error surfaced via store */ @@ -43,11 +70,43 @@ export const ProfileEditor: FC = () => { } }; + const handleCreate = async () => { + setSaving(true); + try { + await createProfile({ name: newName, kind: newKind, relationship: newRel }); + setCreating(false); + setNewName(''); + setNewKind('human'); + setNewRel(''); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!active) return; + if (profiles.length <= 1) { + alert('You must have at least one profile. Create another before deleting this one.'); + return; + } + if (!confirm(`Delete the "${active.name}" profile? Its encrypted data cannot be recovered.`)) return; + try { + await deleteProfile(active.profile_id); + } catch { + /* error surfaced via store */ + } + }; + return ( - - Profile - + + Profiles + + {error && ( @@ -55,11 +114,11 @@ export const ProfileEditor: FC = () => { )} - {isLoading && !profile ? ( + {isLoading && profiles.length === 0 ? ( - ) : ( + ) : active ? ( @@ -84,39 +143,131 @@ export const ProfileEditor: FC = () => { > {saving ? : 'Save'} - ) : ( - {profile?.name ?? user?.username ?? '—'} + {active.name || user?.username || '—'} )} - - - Account - - {user?.email} - - - - - Role - - - - - - - {profile?.profile_id && ( - - Profile ID: {profile.profile_id} - + {editing && ( + <> + + Kind + setKind(e.target.value)} + sx={{ mt: 0.5 }} + > + Human + Pet + + + + + Relationship + + setRelationship(e.target.value)} + placeholder="self, child, spouse, parent, pet, ..." + sx={{ mt: 0.5 }} + /> + + )} + + {!editing && ( + <> + + Kind / relationship + + + {active.relationship && ( + + )} + + + + Account + {user?.email} + + + Profile ID: {active.profile_id} + + + + + + )} + + + + ) : ( + + No profiles yet. Create one to start tracking health data. + + )} + + {creating && ( + + + New profile + + setNewName(e.target.value)} + /> + setNewKind(e.target.value)} + > + Human + Pet + + setNewRel(e.target.value)} + placeholder="child, spouse, parent, pet, ..." + /> + + + + diff --git a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx new file mode 100644 index 0000000..08ec1e2 --- /dev/null +++ b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx @@ -0,0 +1,49 @@ +import { useEffect, type FC } from 'react'; +import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material'; +import { useProfileStore } from '../../store/useStore'; + +/** A compact dropdown for switching the active profile (the subject of care + * whose data the dashboard shows). Sits in the AppBar. Triggers `loadProfiles` + * on mount so the list + per-profile DEKs are ready. */ +export const ProfileSwitcher: FC = () => { + const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } = + useProfileStore(); + + useEffect(() => { + loadProfiles(); + }, [loadProfiles]); + + if (profiles.length === 0) { + return isLoading ? ( + + + + ) : ( + + No profile + + ); + } + + return ( + + ); +}; + +export default ProfileSwitcher; diff --git a/web/normogen-web/src/crypto/crypto.e2e.test.ts b/web/normogen-web/src/crypto/crypto.e2e.test.ts index ff6c3e7..5ba47a0 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -6,6 +6,8 @@ import { rewrapDek, encryptJson, decryptJson, + encrypt, + decrypt, setEncKey, clearEncKey, hasEncKey, @@ -15,6 +17,12 @@ import { setIdentityPrivate, clearIdentityPrivate, hasIdentityPrivate, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, + setProfileDek, + getProfileDek, + clearProfileDeks, } from './index'; /** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some @@ -224,3 +232,53 @@ describe('account X25519 identity keypair (Phase A1)', () => { expect(aHex).not.toBe(bHex); }); }); + +describe('per-profile DEK isolation (Phase A2)', () => { + const password = 'my-secret-password'; + + it('two profiles get distinct DEKs, each wrapped under the account DEK', async () => { + // One account DEK (unlocked from the password). + const setup = await setupEncryption(password); + const accountDek = setup.dek; + + // Two profiles, each with its own random DEK. + const childDek = await generateProfileDek(); + const petDek = await generateProfileDek(); + + // Each profile DEK is wrapped under the account DEK for storage. + const childWrapped = await wrapProfileDek(childDek, accountDek); + const petWrapped = await wrapProfileDek(petDek, accountDek); + + // Both wrapped blobs decrypt cleanly under the account DEK. + const childUnwrapped = await unwrapProfileDek(childWrapped, accountDek); + const petUnwrapped = await unwrapProfileDek(petWrapped, accountDek); + expect(childUnwrapped).toBeDefined(); + expect(petUnwrapped).toBeDefined(); + + // Data encrypted under the child profile's DEK cannot be decrypted by the + // pet profile's DEK (and vice versa) — this is the per-profile isolation + // guarantee that makes the parent/child and pet cases safe. + const childData = await encrypt('child-medication', childDek); + await expect(decrypt(childData, petDek)).rejects.toThrow(); + const petData = await encrypt('pet-weight', petDek); + await expect(decrypt(petData, childDek)).rejects.toThrow(); + + // ...but each decrypts with its own key. + expect(await decrypt(childData, childDek)).toBe('child-medication'); + expect(await decrypt(petData, petDek)).toBe('pet-weight'); + }); + + it('the in-memory profile-DEK store maps profile ids to DEKs', async () => { + const childDek = await generateProfileDek(); + const petDek = await generateProfileDek(); + setProfileDek('profile_child', childDek); + setProfileDek('profile_pet', petDek); + // Each id resolves to the DEK that was stored under it; unknown ids resolve to null. + expect(getProfileDek('profile_child')).toBe(childDek); + expect(getProfileDek('profile_pet')).toBe(petDek); + expect(getProfileDek('profile_unknown')).toBeNull(); + // Clearing drops everything. + clearProfileDeks(); + expect(getProfileDek('profile_child')).toBeNull(); + }); +}); diff --git a/web/normogen-web/src/crypto/index.ts b/web/normogen-web/src/crypto/index.ts index afdf0aa..7f12d62 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -11,6 +11,9 @@ export { generateIdentityKeyPair, wrapIdentityPrivateKey, unwrapIdentityPrivateKey, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, setEncKey, getEncKey, clearEncKey, @@ -19,6 +22,12 @@ export { getIdentityPrivate, clearIdentityPrivate, hasIdentityPrivate, + setProfileDek, + getProfileDek, + setActiveProfileId, + getActiveProfileId, + getActiveProfileDek, + clearProfileDeks, AUTH_SALT, PASSWORD_KEK_SALT, RECOVERY_KEK_SALT, diff --git a/web/normogen-web/src/crypto/keys.ts b/web/normogen-web/src/crypto/keys.ts index 69d49fb..c946be3 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -114,6 +114,39 @@ export async function rewrapDek( return wrapDek(dek, newKek); } +// --------------------------------------------------------------------------- +// Per-profile DEK wrap/unwrap (Phase A2). +// +// Each profile owns a random AES-256-GCM DEK that encrypts all of that +// profile's data. The profile DEK is wrapped under the *account* DEK (not a +// KEK derived from a secret) and stored on the server. At login/unlock the +// client unwraps each profile DEK using the account DEK. See +// docs/adr/multi-person-sharing.md §1. +// --------------------------------------------------------------------------- + +/** Generate a fresh random profile DEK (a random AES-256-GCM key). */ +export async function generateProfileDek(): Promise { + return generateDek(); +} + +/** Wrap a profile DEK under the account DEK. The account DEK is a CryptoKey + * usable for AES-GCM encrypt/decrypt, so it serves directly as the wrap key. */ +export async function wrapProfileDek( + profileDek: CryptoKey, + accountDek: CryptoKey, +): Promise { + return wrapDek(profileDek, accountDek); +} + +/** Unwrap a profile DEK from its account-wrapped form. Inverse of + * wrapProfileDek. Throws on tamper / wrong account DEK. */ +export async function unwrapProfileDek( + payload: CipherPayload, + accountDek: CryptoKey, +): Promise { + return unwrapDek(payload, accountDek); +} + // --------------------------------------------------------------------------- // High-level flows // --------------------------------------------------------------------------- @@ -287,6 +320,43 @@ export function hasIdentityPrivate(): boolean { return currentIdentityPrivate !== null; } +// --------------------------------------------------------------------------- +// In-memory per-profile DEK store (Phase A2). One DEK per profile, plus a +// notion of the "active" profile whose DEK encrypts/decrypts the data the UI +// is currently showing. Same lifecycle as the account DEK: rebuilt from the +// server-stored wrapped forms on unlock, cleared on logout. +// --------------------------------------------------------------------------- + +const profileDeks: Map = new Map(); +let activeProfileId: string | null = null; + +export function setProfileDek(profileId: string, dek: CryptoKey): void { + profileDeks.set(profileId, dek); +} + +export function getProfileDek(profileId: string): CryptoKey | null { + return profileDeks.get(profileId) ?? null; +} + +export function setActiveProfileId(profileId: string): void { + activeProfileId = profileId; +} + +export function getActiveProfileId(): string | null { + return activeProfileId; +} + +/** The active profile's DEK — the key all encrypt/decrypt call sites use. */ +export function getActiveProfileDek(): CryptoKey | null { + if (activeProfileId === null) return null; + return profileDeks.get(activeProfileId) ?? null; +} + +export function clearProfileDeks(): void { + profileDeks.clear(); + activeProfileId = null; +} + // --------------------------------------------------------------------------- // Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password // enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword. diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index dfabf52..4f22b01 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ import { MedicationManager } from '../components/medication/MedicationManager'; import { HealthStats } from '../components/health/HealthStats'; import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; import { ProfileEditor } from '../components/profile/ProfileEditor'; +import { ProfileSwitcher } from '../components/profile/ProfileSwitcher'; import { AppointmentsManager } from '../components/appointments/AppointmentsManager'; type TabIndex = 0 | 1 | 2 | 3 | 4; @@ -35,6 +36,7 @@ export const Dashboard: FC = () => { Normogen + {user && ( {user.username} diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 344586c..df31074 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,6 +18,8 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, + CreateProfileRequest, + UpdateProfileRequest, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -232,25 +234,37 @@ class ApiService { }); } - // ---- Profile (zero-knowledge: opaque encrypted name) ---- + // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ---- - async getProfile(): Promise { - const response = await this.client.get('/profiles/me'); + async listProfiles(): Promise { + const response = await this.client.get('/profiles'); return response.data; } - async updateProfileName(nameData: string, nameIv: string): Promise { - const response = await this.client.put('/profiles/me', { - name_data: nameData, - name_iv: nameIv, - }); + async createProfile(req: CreateProfileRequest): Promise { + const response = await this.client.post('/profiles', req); return response.data; } + async getProfile(profileId: string): Promise { + const response = await this.client.get(`/profiles/${profileId}`); + return response.data; + } + + async updateProfile(profileId: string, req: UpdateProfileRequest): Promise { + const response = await this.client.put(`/profiles/${profileId}`, req); + return response.data; + } + + async deleteProfile(profileId: string): Promise { + await this.client.delete(`/profiles/${profileId}`); + } + // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- - async getMedications(): Promise { - const response = await this.client.get('/medications'); + async getMedications(profileId?: string): Promise { + const params = profileId ? { profile_id: profileId } : undefined; + const response = await this.client.get('/medications', { params }); return response.data; } @@ -287,9 +301,12 @@ class ApiService { // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string): Promise { + async getAppointments(status?: string, profileId?: string): Promise { + const params: Record = {}; + if (status) params.status = status; + if (profileId) params.profile_id = profileId; const response = await this.client.get('/appointments', { - params: status ? { status } : undefined, + params: Object.keys(params).length ? params : undefined, }); return response.data; } @@ -332,8 +349,9 @@ class ApiService { // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- - async getHealthStats(): Promise { - const response = await this.client.get('/health-stats'); + async getHealthStats(profileId?: string): Promise { + const params = profileId ? { profile_id: profileId } : undefined; + const response = await this.client.get('/health-stats', { params }); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 059b750..26cb6d7 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -3,6 +3,9 @@ import { deriveAuthAndEncKeys, setEncKey, clearEncKey, + setActiveProfileId, + setProfileDek, + clearProfileDeks, encryptJson, setupEncryption, unlockWithPassword, @@ -22,6 +25,10 @@ vi.mock('../services/api', () => ({ default: apiMock })); // Import the store AFTER the mock is registered. const { useMedicationStore } = await import('./useStore'); +// The active profile under test — the store encrypts/decrypts with the active +// profile's DEK, so tests must set both the profile id and its DEK. +const TEST_PROFILE_ID = 'profile_test'; + describe('useMedicationStore', () => { let encKey: CryptoKey; @@ -30,6 +37,10 @@ describe('useMedicationStore', () => { const { encKey: key } = await deriveAuthAndEncKeys('test-password'); encKey = key; setEncKey(encKey); + // Phase A2: the store uses the active profile's DEK, not the account DEK. + // Register the test key as the active profile's DEK. + setProfileDek(TEST_PROFILE_ID, encKey); + setActiveProfileId(TEST_PROFILE_ID); useMedicationStore.setState({ medications: [], @@ -44,6 +55,7 @@ describe('useMedicationStore', () => { afterEach(() => { clearEncKey(); + clearProfileDeks(); }); it('loadMedications decrypts wire responses into domain medications', async () => { @@ -74,6 +86,7 @@ describe('useMedicationStore', () => { it('loadMedications sets an error when no enc key is available', async () => { clearEncKey(); + clearProfileDeks(); await useMedicationStore.getState().loadMedications(); expect(useMedicationStore.getState().error).toMatch(/No encryption key/); }); diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 7b96b9f..a88b309 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -9,11 +9,22 @@ import { generateIdentityKeyPair, wrapIdentityPrivateKey, unwrapIdentityPrivateKey, + generateProfileDek, + wrapProfileDek, + unwrapProfileDek, + encrypt as encryptRaw, + decrypt as decryptRaw, setEncKey, clearEncKey, clearIdentityPrivate, + clearProfileDeks, setIdentityPrivate, getEncKey, + getActiveProfileDek, + getActiveProfileId, + setActiveProfileId, + setProfileDek, + getProfileDek, encryptJson, decryptJson, type CipherPayload, @@ -106,11 +117,26 @@ interface InteractionState { } interface ProfileState { - profile: Profile | null; + profiles: Profile[]; + activeProfileId: string | null; isLoading: boolean; error: string | null; - loadProfile: () => Promise; - updateName: (name: string) => Promise; + /** Fetch all owned profiles and unwrap each per-profile DEK under the account + * DEK into the in-memory crypto store. Sets the first profile active. */ + loadProfiles: () => Promise; + /** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */ + setActiveProfile: (profileId: string) => void; + createProfile: (input: { + name: string; + kind?: string; + relationship?: string; + }) => Promise; + updateProfile: (profileId: string, input: { + name: string; + kind?: string; + relationship?: string; + }) => Promise; + deleteProfile: (profileId: string) => Promise; clearError: () => void; } @@ -225,6 +251,14 @@ export const useAuthStore = create()( const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek); setIdentityPrivate(privateKey); + // Phase A2: generate the default "self" profile's DEK, wrap it + // under the account DEK, and encrypt the display name (the chosen + // username) under the profile DEK. The server stores both opaque + // blobs verbatim and auto-creates the self profile. + const defaultProfileDek = await generateProfileDek(); + const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek); + const defaultNameBlob = await encryptRaw(username, defaultProfileDek); + const response = await apiService.register({ username, email, @@ -237,7 +271,16 @@ export const useAuthStore = create()( identity_public_key: publicKey, identity_private_key_wrapped: wrappedPrivate.data, identity_private_key_wrapped_iv: wrappedPrivate.iv, + default_profile_name_data: defaultNameBlob.data, + default_profile_name_iv: defaultNameBlob.iv, + default_wrapped_profile_dek: defaultWrapped.data, + default_wrapped_profile_dek_iv: defaultWrapped.iv, }); + // Seed the in-memory profile-DEK store with the self profile and + // make it the active profile. + const selfProfileId = `profile_${response.user_id}`; + setProfileDek(selfProfileId, defaultProfileDek); + setActiveProfileId(selfProfileId); set({ user: { user_id: response.user_id, @@ -305,6 +348,7 @@ export const useAuthStore = create()( logout: async () => { clearEncKey(); clearIdentityPrivate(); + clearProfileDeks(); await apiService.logout(); set({ user: null, @@ -369,14 +413,14 @@ export const useMedicationStore = create()( adherence: {}, loadMedications: async () => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireMeds = await apiService.getMedications(); + const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined); // Decrypt each opaque blob into domain Medication objects. const medications = await Promise.all( wireMeds.map(async (w) => { @@ -416,7 +460,7 @@ export const useMedicationStore = create()( }, createMedication: async (data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -466,7 +510,7 @@ export const useMedicationStore = create()( }, updateMedication: async (id: string, data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -565,14 +609,14 @@ export const useHealthStore = create()( error: null, loadStats: async () => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireStats = await apiService.getHealthStats(); + const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined); const stats = await Promise.all( wireStats.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -599,7 +643,7 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -607,6 +651,7 @@ export const useHealthStore = create()( set({ isLoading: true, error: null }); try { const d = data as any; + const profileId = getActiveProfileId(); const encrypted_data = await encryptJson({ stat_type: d.stat_type, value: d.value, @@ -614,6 +659,7 @@ export const useHealthStore = create()( notes: d.notes, }, key); const wire = await apiService.createHealthStat({ + profile_id: profileId ?? '', encrypted_data, recorded_at: d.measured_at, }); @@ -640,7 +686,7 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -752,71 +798,158 @@ export const useInteractionStore = create()( })) ); -// Profile Store (Phase 3c) +// Profile Store (Phase A2: multi-profile + per-profile DEKs) export const useProfileStore = create()( devtools((set, get) => ({ - profile: null, + profiles: [], + activeProfileId: null, isLoading: false, error: null, - loadProfile: async () => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); + loadProfiles: async () => { + const accountDek = getEncKey(); + if (!accountDek) { + set({ error: 'No account key — log in to decrypt profiles', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wire = await apiService.getProfile(); - // Decrypt the profile name. - let name = ''; - if (wire.name_data && wire.name_iv) { - try { - name = await decryptJson({ data: wire.name_data, iv: wire.name_iv }, key); - } catch { name = ''; } + const wireList = await apiService.listProfiles(); + // Unwrap each profile's DEK under the account DEK, and decrypt each + // profile's display name under that profile's DEK. + const profiles: Profile[] = []; + for (const wire of wireList) { + let profileDek = getProfileDek(wire.profile_id); + if (!profileDek) { + try { + profileDek = await unwrapProfileDek( + { data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv }, + accountDek, + ); + setProfileDek(wire.profile_id, profileDek); + } catch { + // Could not unwrap this profile's DEK — skip it; the UI will + // show the profiles it could decrypt. + continue; + } + } + let name = ''; + if (wire.name_data && wire.name_iv && profileDek) { + try { + name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek); + } catch { name = ''; } + } + profiles.push({ + profile_id: wire.profile_id, + owner_account_id: wire.owner_account_id, + name, + kind: wire.kind, + relationship: wire.relationship, + role: wire.role, + permissions: wire.permissions, + created_at: wire.created_at, + updated_at: wire.updated_at, + }); } - const profile: Profile = { - profile_id: wire.profile_id, - user_id: wire.user_id, - name, - role: wire.role, - permissions: wire.permissions, - created_at: wire.created_at, - updated_at: wire.updated_at, - }; - set({ profile, isLoading: false }); + // Set the first profile active if none is selected (or if the active + // one is no longer present). + const currentActive = getActiveProfileId(); + const stillOwned = profiles.some((p) => p.profile_id === currentActive); + const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null); + if (newActive) setActiveProfileId(newActive); + set({ profiles, activeProfileId: newActive, isLoading: false }); } catch (error: any) { set({ - error: error.message || 'Failed to load profile', + error: error.message || 'Failed to load profiles', isLoading: false, }); } }, - updateName: async (name) => { - const key = getEncKey(); - if (!key) { - set({ error: 'No encryption key — cannot encrypt data' }); - throw new Error('No encryption key'); + setActiveProfile: (profileId) => { + setActiveProfileId(profileId); + set({ activeProfileId: profileId }); + }, + + createProfile: async ({ name, kind = 'human', relationship = '' }) => { + const accountDek = getEncKey(); + if (!accountDek) { + set({ error: 'No account key — cannot create profile' }); + throw new Error('No account key'); } set({ isLoading: true, error: null }); try { - const blob = await encryptJson(name, key); - const wire = await apiService.updateProfileName(blob.data, blob.iv); - let decryptedName = name; - try { - decryptedName = await decryptJson({ data: wire.name_data, iv: wire.name_iv }, key); - } catch { /* keep the input name */ } + // Generate a fresh profile DEK, wrap it under the account DEK, and + // encrypt the display name under the new profile DEK. + const profileDek = await generateProfileDek(); + const wrapped = await wrapProfileDek(profileDek, accountDek); + const nameBlob = await encryptRaw(name, profileDek); + const wire = await apiService.createProfile({ + kind, + relationship, + name_data: nameBlob.data, + name_iv: nameBlob.iv, + wrapped_profile_dek: wrapped.data, + wrapped_profile_dek_iv: wrapped.iv, + }); + // Cache the DEK in memory (we already have it) and set this profile + // active — the user just created it. + setProfileDek(wire.profile_id, profileDek); + setActiveProfileId(wire.profile_id); const profile: Profile = { profile_id: wire.profile_id, - user_id: wire.user_id, - name: decryptedName, + owner_account_id: wire.owner_account_id, + name, + kind: wire.kind, + relationship: wire.relationship, role: wire.role, permissions: wire.permissions, created_at: wire.created_at, updated_at: wire.updated_at, }; - set({ profile, isLoading: false }); + set((state) => ({ + profiles: [...state.profiles, profile], + activeProfileId: wire.profile_id, + isLoading: false, + })); + } catch (error: any) { + set({ + error: error.message || 'Failed to create profile', + isLoading: false, + }); + throw error; + } + }, + + updateProfile: async (profileId, { name, kind, relationship }) => { + const profileDek = getProfileDek(profileId); + if (!profileDek) { + set({ error: 'No profile key — switch to or unlock this profile first' }); + throw new Error('No profile key'); + } + set({ isLoading: true, error: null }); + try { + const nameBlob = await encryptRaw(name, profileDek); + const wire = await apiService.updateProfile(profileId, { + name_data: nameBlob.data, + name_iv: nameBlob.iv, + kind: kind ?? 'human', + relationship: relationship ?? '', + }); + set((state) => ({ + profiles: state.profiles.map((p) => + p.profile_id === profileId + ? { + ...p, + name, + kind: wire.kind, + relationship: wire.relationship, + updated_at: wire.updated_at, + } + : p, + ), + isLoading: false, + })); } catch (error: any) { set({ error: error.message || 'Failed to update profile', @@ -826,6 +959,27 @@ export const useProfileStore = create()( } }, + deleteProfile: async (profileId) => { + set({ isLoading: true, error: null }); + try { + await apiService.deleteProfile(profileId); + const remaining = get().profiles.filter((p) => p.profile_id !== profileId); + // If we deleted the active profile, fall back to the first remaining. + let newActive = getActiveProfileId(); + if (newActive === profileId) { + newActive = remaining[0]?.profile_id ?? null; + if (newActive) setActiveProfileId(newActive); + } + set({ profiles: remaining, activeProfileId: newActive, isLoading: false }); + } catch (error: any) { + set({ + error: error.message || 'Failed to delete profile', + isLoading: false, + }); + throw error; + } + }, + clearError: () => set({ error: null }), })), ); @@ -838,14 +992,14 @@ export const useAppointmentStore = create()( error: null, loadAppointments: async (status) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - const wireAppts = await apiService.getAppointments(status); + const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined); const appointments = await Promise.all( wireAppts.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -878,7 +1032,7 @@ export const useAppointmentStore = create()( }, createAppointment: async (data) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -924,7 +1078,7 @@ export const useAppointmentStore = create()( }, updateAppointment: async (id, data) => { - const key = getEncKey(); + const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 9c09d34..9411408 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -71,6 +71,13 @@ export interface RegisterRequest { identity_public_key?: string; identity_private_key_wrapped?: string; identity_private_key_wrapped_iv?: string; + /** Default "self" profile (Phase A2). Encrypted name blob + profile DEK + * wrapped under the account DEK. */ + default_profile_name_data?: string; + default_profile_name_iv?: string; + default_profile_name_auth_tag?: string; + default_wrapped_profile_dek?: string; + default_wrapped_profile_dek_iv?: string; } // Medication Types @@ -240,11 +247,13 @@ export interface HealthStat { export interface HealthStatWireResponse { id: string; user_id: string; + profile_id: string; encrypted_data: EncryptedFieldWire; recorded_at: string; } export interface CreateHealthStatRequest { + profile_id: string; encrypted_data: EncryptedFieldWire; recorded_at?: string; } @@ -324,18 +333,44 @@ export interface UpdateAppointmentRequest { status?: string; } -// Profile wire response (opaque encrypted name) +// Profile wire response (opaque encrypted name + wrapped profile DEK) export interface ProfileWireResponse { profile_id: string; - user_id: string; + owner_account_id: string; name_data: string; name_iv: string; + kind: string; + relationship: string; role: string; permissions: string[]; + /** Profile DEK wrapped under the account DEK (opaque ciphertext). */ + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; created_at: string; updated_at: string; } +// Request body for POST /profiles +export interface CreateProfileRequest { + profile_id?: string; + kind?: string; + relationship?: string; + name_data: string; + name_iv: string; + name_auth_tag?: string; + wrapped_profile_dek: string; + wrapped_profile_dek_iv: string; +} + +// Request body for PUT /profiles/:id +export interface UpdateProfileRequest { + name_data: string; + name_iv: string; + name_auth_tag?: string; + kind?: string; + relationship?: string; +} + // Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { id?: string; @@ -368,8 +403,10 @@ export interface AdherenceStats { // Profile Types — match backend ProfileResponse. export interface Profile { profile_id: string; - user_id: string; + owner_account_id: string; name: string; + kind: string; + relationship: string; role: string; permissions: string[]; created_at: string;