diff --git a/backend/src/app.rs b/backend/src/app.rs index 27c39b0..fc19592 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_account)) - .route("/api/users/me", put(handlers::update_account)) + .route("/api/users/me", get(handlers::get_profile)) + .route("/api/users/me", put(handlers::update_profile)) .route("/api/users/me", delete(handlers::delete_account)) .route("/api/users/me/change-password", post(handlers::change_password)) // User settings @@ -54,12 +54,9 @@ 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 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)) + // Profile management (Phase 3c) + .route("/api/profiles/me", get(handlers::get_my_profile)) + .route("/api/profiles/me", put(handlers::update_my_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 efe5383..04e3bbd 100644 --- a/backend/src/auth/password.rs +++ b/backend/src/auth/password.rs @@ -1,9 +1,6 @@ use anyhow::Result; use pbkdf2::{ - password_hash::{ - rand_core::OsRng, Error as PasswordHashError, PasswordHash, PasswordHasher, - PasswordVerifier, SaltString, - }, + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Pbkdf2, }; @@ -18,24 +15,14 @@ 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))?; - 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)), - } + Pbkdf2 + .verify_password(password.as_bytes(), &parsed_hash) + .map(|_| true) + .map_err(|e| anyhow::anyhow!("Password verification failed: {}", e)) } } diff --git a/backend/src/handlers/appointments.rs b/backend/src/handlers/appointments.rs index 9b8d709..9d9f054 100644 --- a/backend/src/handlers/appointments.rs +++ b/backend/src/handlers/appointments.rs @@ -20,8 +20,6 @@ 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( @@ -70,11 +68,7 @@ pub async fn list_appointments( let repo = AppointmentRepository::new(database.collection("appointments")); match repo - .find_by_user_filtered( - &claims.sub, - query.status.as_deref(), - query.profile_id.as_deref(), - ) + .find_by_user_filtered(&claims.sub, query.status.as_deref()) .await { Ok(appointments) => { diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 1b7a2d4..9935fd5 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -32,15 +32,6 @@ 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)] @@ -154,29 +145,17 @@ pub async fn register( .await; } - // 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); - } + // 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); } id diff --git a/backend/src/handlers/health_stats.rs b/backend/src/handlers/health_stats.rs index aa62870..ca902cd 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, Query, State}, + extract::{Path, State}, http::StatusCode, response::IntoResponse, Extension, Json, @@ -14,16 +14,9 @@ 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, @@ -48,7 +41,6 @@ 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 @@ -78,7 +70,6 @@ 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, @@ -90,10 +81,7 @@ pub async fn list_health_stats( .into_response() } }; - match repo - .find_by_user_filtered(&claims.sub, query.profile_id.as_deref()) - .await - { + match repo.find_by_user(&claims.sub).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 e90ba39..521d0b1 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::{create_profile, delete_profile, get_profile, list_profiles, update_profile}; +pub use profile::{get_my_profile, update_my_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_account, get_settings, update_account, update_settings, + change_password, delete_account, get_profile, get_settings, update_profile, update_settings, }; diff --git a/backend/src/handlers/profile.rs b/backend/src/handlers/profile.rs index c3edb21..ea3d37e 100644 --- a/backend/src/handlers/profile.rs +++ b/backend/src/handlers/profile.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Extension, Path, State}, + extract::{Extension, State}, http::StatusCode, Json, }; @@ -12,21 +12,17 @@ use crate::{ models::profile::{Profile, ProfileRepository}, }; -/// The profile as exposed to clients. Display name + wrapped profile DEK are -/// returned as opaque client-encrypted blobs (the server cannot read either). +/// The profile as exposed to clients. The display name is returned as an opaque +/// client-encrypted blob (the server cannot read it). #[derive(Debug, Serialize)] pub struct ProfileResponse { pub profile_id: String, - pub owner_account_id: String, + pub user_id: String, + /// Opaque client-encrypted display name. 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, } @@ -35,212 +31,98 @@ impl From for ProfileResponse { fn from(p: Profile) -> Self { Self { profile_id: p.profile_id, - owner_account_id: p.owner_account_id, + user_id: p.user_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, } } } -/// 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 { +/// 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 { let now = DateTime::now(); Profile { id: None, - // 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(), + profile_id: format!("profile_{user_id}"), + user_id: user_id.to_string(), family_id: None, - 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(), + name: String::new(), + name_iv: String::new(), + name_auth_tag: String::new(), 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, } } -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. -// --------------------------------------------------------------------------- - +/// PUT /profiles/me body — the client sends the encrypted name blob. #[derive(Debug, Deserialize)] -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 struct UpdateProfileNameRequest { 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, } -fn default_kind_value() -> String { - "human".to_string() -} - -pub async fn create_profile( +/// 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( State(state): State, Extension(claims): Extension, - 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, - }; - - 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)))) -} - -// --------------------------------------------------------------------------- -// GET /api/profiles/:id — fetch one owned profile -// --------------------------------------------------------------------------- - -pub async fn get_profile( - State(state): State, - Extension(claims): Extension, - Path(profile_id): Path, ) -> Result, (StatusCode, Json)> { - let repo = profile_repo(&state); - match repo - .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" })), - )), + 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); - Err(( + return Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "database error" })), - )) + )); } - } + }; + + Ok(Json(ProfileResponse::from(profile))) } -// --------------------------------------------------------------------------- -// 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( +/// PUT /api/profiles/me — update the profile's encrypted display-name blob. +pub async fn update_my_profile( State(state): State, Extension(claims): Extension, - Path(profile_id): Path, - Json(req): Json, + Json(req): Json, ) -> Result, (StatusCode, Json)> { - let repo = profile_repo(&state); + let database = state.db.get_database(); + let repo = ProfileRepository::new(database.collection("profiles")); + match repo - .update_profile( - &profile_id, + .update_encrypted_name( &claims.sub, &req.name_data, &req.name_iv, &req.name_auth_tag, - &req.kind, - &req.relationship, ) .await { @@ -258,29 +140,3 @@ pub async fn update_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 9b3ea33..0dc0660 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_account( +pub async fn get_profile( State(state): State, Extension(claims): Extension, ) -> impl IntoResponse { @@ -79,7 +79,7 @@ pub async fn get_account( } } -pub async fn update_account( +pub async fn update_profile( State(state): State, Extension(claims): Extension, Json(req): Json, diff --git a/backend/src/models/appointment.rs b/backend/src/models/appointment.rs index 8ba2140..d6d5efb 100644 --- a/backend/src/models/appointment.rs +++ b/backend/src/models/appointment.rs @@ -126,21 +126,16 @@ impl AppointmentRepository { Ok(appointment) } - /// List a user's appointments, optionally filtered by top-level `status` - /// and/or `profileId`. + /// List a user's appointments, optionally filtered by top-level `status`. 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 4b11669..5f28485 100644 --- a/backend/src/models/health_stats.rs +++ b/backend/src/models/health_stats.rs @@ -15,9 +15,6 @@ 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, @@ -30,7 +27,6 @@ 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, } @@ -40,7 +36,6 @@ 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, } @@ -75,21 +70,6 @@ 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 288e4d7..3881064 100644 --- a/backend/src/models/profile.rs +++ b/backend/src/models/profile.rs @@ -1,76 +1,35 @@ -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, - /// "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")] + #[serde(rename = "role")] pub role: String, - #[serde(rename = "permissions", default)] + #[serde(rename = "permissions")] 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, } @@ -85,7 +44,6 @@ impl ProfileRepository { Ok(()) } - /// Look up a profile by its application-level profile id. pub async fn find_by_profile_id( &self, profile_id: &str, @@ -95,77 +53,32 @@ impl ProfileRepository { .await } - /// 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> { + /// Look up a profile by its owning user id. + pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result> { self.collection - .find_one( - doc! { "profileId": profile_id, "ownerAccountId": owner }, - None, - ) + .find_one(doc! { "userId": user_id }, None) .await } - /// 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( + /// Update the profile's display name (opaque client-encrypted blob). + pub async fn update_encrypted_name( &self, - profile_id: &str, - owner: &str, + user_id: &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! { "profileId": profile_id, "ownerAccountId": owner }, + doc! { "userId": user_id }, 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 deleted file mode 100644 index 2a9a470..0000000 --- a/backend/tests/profile_tests.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! 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 50c3c65..1e38c41 100644 --- a/backend/tests/zk_integration_tests.rs +++ b/backend/tests/zk_integration_tests.rs @@ -179,7 +179,6 @@ 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 ed6d8cf..d3ee7b0 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, useProfileStore } from '../../store/useStore'; +import { useAppointmentStore, useAuthStore } 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 activeProfileId = useProfileStore((s) => s.activeProfileId); + const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => { const [saving, setSaving] = useState(false); useEffect(() => { - if (activeProfileId) loadAppointments(); - }, [loadAppointments, activeProfileId]); + loadAppointments(); + }, [loadAppointments]); const openCreate = () => { setCreateForm({ ...emptyCreate, - profile_id: activeProfileId ?? 'default', + profile_id: `profile_${user?.user_id ?? '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 93c5d11..b70c813 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.test.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.test.tsx @@ -38,8 +38,6 @@ 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 fcdfc09..e80679c 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, useProfileStore } from '../../store/useStore'; +import { useMedicationStore, useAuthStore } 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 activeProfileId = useProfileStore((s) => s.activeProfileId); + const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreate); @@ -69,16 +69,17 @@ 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(() => { - if (activeProfileId) loadMedications(); - }, [loadMedications, activeProfileId]); + loadMedications(); + }, [loadMedications]); 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: activeProfileId ?? 'default', + profile_id: profileId, }); setCreateOpen(true); }; diff --git a/web/normogen-web/src/components/profile/ProfileEditor.tsx b/web/normogen-web/src/components/profile/ProfileEditor.tsx index 8876081..7f258f5 100644 --- a/web/normogen-web/src/components/profile/ProfileEditor.tsx +++ b/web/normogen-web/src/components/profile/ProfileEditor.tsx @@ -7,61 +7,34 @@ 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 { - profiles, - activeProfileId, - isLoading, - error, - loadProfiles, - updateProfile, - createProfile, - deleteProfile, - clearError, - } = useProfileStore(); + const { profile, isLoading, error, loadProfile, updateName, 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); - // Create-profile dialog state - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(''); - const [newKind, setNewKind] = useState('human'); - const [newRel, setNewRel] = useState(''); + useEffect(() => { + loadProfile(); + }, [loadProfile]); useEffect(() => { - loadProfiles(); - }, [loadProfiles]); - - useEffect(() => { - if (active) { - setName(active.name); - setKind(active.kind || 'human'); - setRelationship(active.relationship || ''); - } - }, [active]); + if (profile?.name) setName(profile.name); + }, [profile]); const handleSave = async () => { - if (!active) return; setSaving(true); try { - await updateProfile(active.profile_id, { name, kind, relationship }); + await updateName(name); setEditing(false); } catch { /* error surfaced via store */ @@ -70,43 +43,11 @@ 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 ( - - Profiles - - + + Profile + {error && ( @@ -114,11 +55,11 @@ export const ProfileEditor: FC = () => { )} - {isLoading && profiles.length === 0 ? ( + {isLoading && !profile ? ( - ) : active ? ( + ) : ( @@ -143,131 +84,39 @@ export const ProfileEditor: FC = () => { > {saving ? : 'Save'} - ) : ( - {active.name || user?.username || '—'} + {profile?.name ?? user?.username ?? '—'} )} - {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 }} - /> - - - )} + + + Account + + {user?.email} + - {!editing && ( - <> - - Kind / relationship - - - {active.relationship && ( - - )} - - - - Account - {user?.email} - - - Profile ID: {active.profile_id} - - - - - - )} - - - - ) : ( - - No profiles yet. Create one to start tracking health data. - - )} + + + Role + + + + + - {creating && ( - - - New profile - - setNewName(e.target.value)} - /> - setNewKind(e.target.value)} - > - Human - Pet - - setNewRel(e.target.value)} - placeholder="child, spouse, parent, pet, ..." - /> - - - - + {profile?.profile_id && ( + + Profile ID: {profile.profile_id} + + )} diff --git a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx b/web/normogen-web/src/components/profile/ProfileSwitcher.tsx deleted file mode 100644 index 08ec1e2..0000000 --- a/web/normogen-web/src/components/profile/ProfileSwitcher.tsx +++ /dev/null @@ -1,49 +0,0 @@ -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 5ba47a0..ff6c3e7 100644 --- a/web/normogen-web/src/crypto/crypto.e2e.test.ts +++ b/web/normogen-web/src/crypto/crypto.e2e.test.ts @@ -6,8 +6,6 @@ import { rewrapDek, encryptJson, decryptJson, - encrypt, - decrypt, setEncKey, clearEncKey, hasEncKey, @@ -17,12 +15,6 @@ 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 @@ -232,53 +224,3 @@ 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 7f12d62..afdf0aa 100644 --- a/web/normogen-web/src/crypto/index.ts +++ b/web/normogen-web/src/crypto/index.ts @@ -11,9 +11,6 @@ export { generateIdentityKeyPair, wrapIdentityPrivateKey, unwrapIdentityPrivateKey, - generateProfileDek, - wrapProfileDek, - unwrapProfileDek, setEncKey, getEncKey, clearEncKey, @@ -22,12 +19,6 @@ 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 c946be3..69d49fb 100644 --- a/web/normogen-web/src/crypto/keys.ts +++ b/web/normogen-web/src/crypto/keys.ts @@ -114,39 +114,6 @@ 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 // --------------------------------------------------------------------------- @@ -320,43 +287,6 @@ 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 4f22b01..dfabf52 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -6,7 +6,6 @@ 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; @@ -36,7 +35,6 @@ 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 df31074..344586c 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -18,8 +18,6 @@ import { MedicationWireResponse, AppointmentWireResponse, ProfileWireResponse, - CreateProfileRequest, - UpdateProfileRequest, HealthStatWireResponse, UpdateHealthStatRequest, EncryptedFieldWire, @@ -234,37 +232,25 @@ class ApiService { }); } - // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ---- + // ---- Profile (zero-knowledge: opaque encrypted name) ---- - async listProfiles(): Promise { - const response = await this.client.get('/profiles'); + async getProfile(): Promise { + const response = await this.client.get('/profiles/me'); return response.data; } - async createProfile(req: CreateProfileRequest): Promise { - const response = await this.client.post('/profiles', req); + async updateProfileName(nameData: string, nameIv: string): Promise { + const response = await this.client.put('/profiles/me', { + name_data: nameData, + name_iv: nameIv, + }); 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(profileId?: string): Promise { - const params = profileId ? { profile_id: profileId } : undefined; - const response = await this.client.get('/medications', { params }); + async getMedications(): Promise { + const response = await this.client.get('/medications'); return response.data; } @@ -301,12 +287,9 @@ class ApiService { // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string, profileId?: string): Promise { - const params: Record = {}; - if (status) params.status = status; - if (profileId) params.profile_id = profileId; + async getAppointments(status?: string): Promise { const response = await this.client.get('/appointments', { - params: Object.keys(params).length ? params : undefined, + params: status ? { status } : undefined, }); return response.data; } @@ -349,9 +332,8 @@ class ApiService { // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- - async getHealthStats(profileId?: string): Promise { - const params = profileId ? { profile_id: profileId } : undefined; - const response = await this.client.get('/health-stats', { params }); + async getHealthStats(): Promise { + const response = await this.client.get('/health-stats'); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 26cb6d7..059b750 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -3,9 +3,6 @@ import { deriveAuthAndEncKeys, setEncKey, clearEncKey, - setActiveProfileId, - setProfileDek, - clearProfileDeks, encryptJson, setupEncryption, unlockWithPassword, @@ -25,10 +22,6 @@ 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; @@ -37,10 +30,6 @@ 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: [], @@ -55,7 +44,6 @@ describe('useMedicationStore', () => { afterEach(() => { clearEncKey(); - clearProfileDeks(); }); it('loadMedications decrypts wire responses into domain medications', async () => { @@ -86,7 +74,6 @@ 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 a88b309..7b96b9f 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -9,22 +9,11 @@ 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, @@ -117,26 +106,11 @@ interface InteractionState { } interface ProfileState { - profiles: Profile[]; - activeProfileId: string | null; + profile: Profile | null; isLoading: boolean; error: string | null; - /** 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; + loadProfile: () => Promise; + updateName: (name: string) => Promise; clearError: () => void; } @@ -251,14 +225,6 @@ 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, @@ -271,16 +237,7 @@ 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, @@ -348,7 +305,6 @@ export const useAuthStore = create()( logout: async () => { clearEncKey(); clearIdentityPrivate(); - clearProfileDeks(); await apiService.logout(); set({ user: null, @@ -413,14 +369,14 @@ export const useMedicationStore = create()( adherence: {}, loadMedications: async () => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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(getActiveProfileId() ?? undefined); + const wireMeds = await apiService.getMedications(); // Decrypt each opaque blob into domain Medication objects. const medications = await Promise.all( wireMeds.map(async (w) => { @@ -460,7 +416,7 @@ export const useMedicationStore = create()( }, createMedication: async (data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -510,7 +466,7 @@ export const useMedicationStore = create()( }, updateMedication: async (id: string, data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -609,14 +565,14 @@ export const useHealthStore = create()( error: null, loadStats: async () => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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(getActiveProfileId() ?? undefined); + const wireStats = await apiService.getHealthStats(); const stats = await Promise.all( wireStats.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -643,7 +599,7 @@ export const useHealthStore = create()( }, createStat: async (data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -651,7 +607,6 @@ 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, @@ -659,7 +614,6 @@ export const useHealthStore = create()( notes: d.notes, }, key); const wire = await apiService.createHealthStat({ - profile_id: profileId ?? '', encrypted_data, recorded_at: d.measured_at, }); @@ -686,7 +640,7 @@ export const useHealthStore = create()( }, updateStat: async (id: string, data: any) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -798,158 +752,71 @@ export const useInteractionStore = create()( })) ); -// Profile Store (Phase A2: multi-profile + per-profile DEKs) +// Profile Store (Phase 3c) export const useProfileStore = create()( devtools((set, get) => ({ - profiles: [], - activeProfileId: null, + profile: null, isLoading: false, error: null, - loadProfiles: async () => { - const accountDek = getEncKey(); - if (!accountDek) { - set({ error: 'No account key — log in to decrypt profiles', isLoading: false }); + loadProfile: async () => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { - 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 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 = ''; } } - // 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 profiles', - isLoading: false, - }); - } - }, - - 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 { - // 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, - owner_account_id: wire.owner_account_id, + user_id: wire.user_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((state) => ({ - profiles: [...state.profiles, profile], - activeProfileId: wire.profile_id, - isLoading: false, - })); + set({ profile, isLoading: false }); } catch (error: any) { set({ - error: error.message || 'Failed to create profile', + error: error.message || 'Failed to load 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'); + updateName: async (name) => { + const key = getEncKey(); + if (!key) { + set({ error: 'No encryption key — cannot encrypt data' }); + throw new Error('No encryption 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, - })); + 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 */ } + const profile: Profile = { + profile_id: wire.profile_id, + user_id: wire.user_id, + name: decryptedName, + role: wire.role, + permissions: wire.permissions, + created_at: wire.created_at, + updated_at: wire.updated_at, + }; + set({ profile, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to update profile', @@ -959,27 +826,6 @@ 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 }), })), ); @@ -992,14 +838,14 @@ export const useAppointmentStore = create()( error: null, loadAppointments: async (status) => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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, getActiveProfileId() ?? undefined); + const wireAppts = await apiService.getAppointments(status); const appointments = await Promise.all( wireAppts.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); @@ -1032,7 +878,7 @@ export const useAppointmentStore = create()( }, createAppointment: async (data) => { - const key = getActiveProfileDek(); + const key = getEncKey(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); @@ -1078,7 +924,7 @@ export const useAppointmentStore = create()( }, updateAppointment: async (id, data) => { - const key = getActiveProfileDek(); + const key = getEncKey(); 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 9411408..9c09d34 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -71,13 +71,6 @@ 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 @@ -247,13 +240,11 @@ 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; } @@ -333,44 +324,18 @@ export interface UpdateAppointmentRequest { status?: string; } -// Profile wire response (opaque encrypted name + wrapped profile DEK) +// Profile wire response (opaque encrypted name) export interface ProfileWireResponse { profile_id: string; - owner_account_id: string; + user_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; @@ -403,10 +368,8 @@ export interface AdherenceStats { // Profile Types — match backend ProfileResponse. export interface Profile { profile_id: string; - owner_account_id: string; + user_id: string; name: string; - kind: string; - relationship: string; role: string; permissions: string[]; created_at: string;