feat: per-profile DEKs + multi-profile (Phase A2, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s

Implements the 3-tier key model from the multi-person sharing ADR:
each account owns multiple profiles (a person or pet — a 'subject of
care'), and each profile has its own random AES-256-GCM DEK. All
health data is now encrypted under the active profile's DEK, not the
account-wide DEK. The account DEK wraps each profile DEK; the server
stores only opaque wrapped blobs.

Per the ADR: DB wipe, no migration (no real user data). This unblocks
Phase B (sharing) — there is now a per-profile key to wrap to a
recipient's X25519 public key.

Backend:
- Profile model: owner_account_id, kind (human/pet), relationship,
  wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner,
  find_by_profile_id_owned, update_profile, delete_profile — all
  ownership-scoped.
- Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE
  /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs
  get_profile/update_profile (the /api/users/me handlers) to
  get_account/update_account to resolve a name collision.
- Register accepts default_profile_* fields and auto-creates the self
  profile when the client provides a wrapped profile DEK.
- HealthStatistic + Appointment gain profile_id and ?profile_id=
  filtering (health stats previously had no profile binding).
- New profile_tests.rs: multi-profile CRUD + ownership isolation +
  register-with-default-profile. Fixed the zk health-stat test to
  send the now-required profile_id.

Frontend:
- crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek
  + in-memory per-profile DEK store with an active-profile concept.
- useProfileStore rewritten: holds profiles[], activeProfileId;
  loadProfiles unwraps each profile DEK; create/update/delete. All 11
  encrypt/decrypt sites switched from getEncKey() to
  getActiveProfileDek(). load actions pass ?profile_id= so only the
  active profile's rows come back.
- ProfileEditor rewritten for the new store (edit active profile,
  create/delete). New ProfileSwitcher in the Dashboard AppBar.
- MedicationManager / AppointmentsManager use the active profile id
  instead of the hardcoded profile_<user_id>.
- 2 new crypto tests for per-profile DEK isolation; updated store +
  component tests for the active-profile-DEK model.

Verification: backend cargo build/clippy/fmt green, tests compile
(integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 30/30 tests pass.

Closes nothing yet (Phase B/C/D remain). Refs #3.
This commit is contained in:
goose 2026-07-18 23:45:01 -03:00
parent 9807434c5f
commit eb2c2aa546
25 changed files with 1322 additions and 206 deletions

View file

@ -40,8 +40,8 @@ pub fn build_app(state: AppState) -> Router {
// Build protected routes (auth required) // Build protected routes (auth required)
let protected_routes = Router::new() let protected_routes = Router::new()
// User profile management // User profile management
.route("/api/users/me", get(handlers::get_profile)) .route("/api/users/me", get(handlers::get_account))
.route("/api/users/me", put(handlers::update_profile)) .route("/api/users/me", put(handlers::update_account))
.route("/api/users/me", delete(handlers::delete_account)) .route("/api/users/me", delete(handlers::delete_account))
.route("/api/users/me/change-password", post(handlers::change_password)) .route("/api/users/me/change-password", post(handlers::change_password))
// User settings // User settings
@ -54,9 +54,12 @@ pub fn build_app(state: AppState) -> Router {
.route("/api/shares/:id", delete(handlers::delete_share)) .route("/api/shares/:id", delete(handlers::delete_share))
// Permission checking // Permission checking
.route("/api/permissions/check", post(handlers::check_permission)) .route("/api/permissions/check", post(handlers::check_permission))
// Profile management (Phase 3c) // Profile management (Phase A2: multi-profile + per-profile DEKs)
.route("/api/profiles/me", get(handlers::get_my_profile)) .route("/api/profiles", get(handlers::list_profiles))
.route("/api/profiles/me", put(handlers::update_my_profile)) .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) // Session management (Phase 2.6)
.route("/api/sessions", get(handlers::get_sessions)) .route("/api/sessions", get(handlers::get_sessions))
.route("/api/sessions/:id", delete(handlers::revoke_session)) .route("/api/sessions/:id", delete(handlers::revoke_session))

View file

@ -20,6 +20,8 @@ use mongodb::bson::DateTime;
pub struct AppointmentListQuery { pub struct AppointmentListQuery {
/// Filter by status (upcoming/completed/cancelled). Optional. /// Filter by status (upcoming/completed/cancelled). Optional.
pub status: Option<String>, pub status: Option<String>,
/// Filter by profile id. Optional.
pub profile_id: Option<String>,
} }
pub async fn create_appointment( pub async fn create_appointment(
@ -68,7 +70,11 @@ pub async fn list_appointments(
let repo = AppointmentRepository::new(database.collection("appointments")); let repo = AppointmentRepository::new(database.collection("appointments"));
match repo 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 .await
{ {
Ok(appointments) => { Ok(appointments) => {

View file

@ -32,6 +32,15 @@ pub struct RegisterRequest {
/// Account X25519 identity private key, wrapped under the account DEK. /// Account X25519 identity private key, wrapped under the account DEK.
pub identity_private_key_wrapped: Option<String>, pub identity_private_key_wrapped: Option<String>,
pub identity_private_key_wrapped_iv: Option<String>, pub identity_private_key_wrapped_iv: Option<String>,
/// 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<String>,
pub default_profile_name_iv: Option<String>,
pub default_profile_name_auth_tag: Option<String>,
pub default_wrapped_profile_dek: Option<String>,
pub default_wrapped_profile_dek_iv: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -145,17 +154,29 @@ pub async fn register(
.await; .await;
} }
// Auto-create a default profile for the new user. The profile_id is // Auto-create the default "self" profile for the new user, but only
// deterministic (profile_<user_id>) and is the contract the frontend // when the client provided a wrapped profile DEK. The profile_id is
// uses for medication creation. Best-effort: registration still // deterministic (profile_<owner>). Best-effort: registration still
// succeeds if this fails (GET /profiles/me lazily creates one). // succeeds if this fails or if no wrapped DEK was provided (the
let database = state.db.get_database(); // client creates the profile via POST /api/profiles on first run).
let profile_repo = if let (Some(wrapped_dek), Some(wrapped_dek_iv)) = (
crate::models::profile::ProfileRepository::new(database.collection("profiles")); req.default_wrapped_profile_dek.as_ref(),
let profile = req.default_wrapped_profile_dek_iv.as_ref(),
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username); ) {
if let Err(e) = profile_repo.create(&profile).await { let database = state.db.get_database();
tracing::warn!("Failed to auto-create profile for {}: {}", id, e); 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 id

View file

@ -3,7 +3,7 @@ use crate::config::AppState;
use crate::models::health_stats::{HealthStatResponse, HealthStatistic}; use crate::models::health_stats::{HealthStatResponse, HealthStatistic};
use crate::models::medication::EncryptedFieldWire; use crate::models::medication::EncryptedFieldWire;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
Extension, Json, Extension, Json,
@ -14,9 +14,16 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateHealthStatRequest { pub struct CreateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire, pub encrypted_data: EncryptedFieldWire,
/// Which profile this stat belongs to.
pub profile_id: String,
pub recorded_at: Option<String>, pub recorded_at: Option<String>,
} }
#[derive(Debug, Deserialize, Default)]
pub struct ListHealthStatsQuery {
pub profile_id: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateHealthStatRequest { pub struct UpdateHealthStatRequest {
pub encrypted_data: EncryptedFieldWire, pub encrypted_data: EncryptedFieldWire,
@ -41,6 +48,7 @@ pub async fn create_health_stat(
let stat = HealthStatistic { let stat = HealthStatistic {
id: None, id: None,
user_id: claims.sub.clone(), user_id: claims.sub.clone(),
profile_id: req.profile_id,
encrypted_data: req.encrypted_data, encrypted_data: req.encrypted_data,
recorded_at: req recorded_at: req
.recorded_at .recorded_at
@ -70,6 +78,7 @@ pub async fn create_health_stat(
pub async fn list_health_stats( pub async fn list_health_stats(
State(state): State<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
Query(query): Query<ListHealthStatsQuery>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let repo = match state.health_stats_repo.as_ref() { let repo = match state.health_stats_repo.as_ref() {
Some(r) => r, Some(r) => r,
@ -81,7 +90,10 @@ pub async fn list_health_stats(
.into_response() .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) => { Ok(stats) => {
let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect(); let resp: Vec<HealthStatResponse> = stats.into_iter().map(Into::into).collect();
(StatusCode::OK, Json(resp)).into_response() (StatusCode::OK, Json(resp)).into_response()

View file

@ -25,9 +25,9 @@ pub use medications::{
log_dose, update_medication, log_dose, update_medication,
}; };
pub use permissions::check_permission; 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 sessions::{get_sessions, revoke_all_sessions, revoke_session};
pub use shares::{create_share, delete_share, list_shares, update_share}; pub use shares::{create_share, delete_share, list_shares, update_share};
pub use users::{ 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,
}; };

View file

@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::{Extension, State}, extract::{Extension, Path, State},
http::StatusCode, http::StatusCode,
Json, Json,
}; };
@ -12,17 +12,21 @@ use crate::{
models::profile::{Profile, ProfileRepository}, models::profile::{Profile, ProfileRepository},
}; };
/// The profile as exposed to clients. The display name is returned as an opaque /// The profile as exposed to clients. Display name + wrapped profile DEK are
/// client-encrypted blob (the server cannot read it). /// returned as opaque client-encrypted blobs (the server cannot read either).
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ProfileResponse { pub struct ProfileResponse {
pub profile_id: String, pub profile_id: String,
pub user_id: String, pub owner_account_id: String,
/// Opaque client-encrypted display name.
pub name_data: String, pub name_data: String,
pub name_iv: String, pub name_iv: String,
pub kind: String,
pub relationship: String,
pub role: String, pub role: String,
pub permissions: Vec<String>, pub permissions: Vec<String>,
/// 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 created_at: DateTime,
pub updated_at: DateTime, pub updated_at: DateTime,
} }
@ -31,98 +35,212 @@ impl From<Profile> for ProfileResponse {
fn from(p: Profile) -> Self { fn from(p: Profile) -> Self {
Self { Self {
profile_id: p.profile_id, profile_id: p.profile_id,
user_id: p.user_id, owner_account_id: p.owner_account_id,
name_data: p.name, name_data: p.name,
name_iv: p.name_iv, name_iv: p.name_iv,
kind: p.kind,
relationship: p.relationship,
role: p.role, role: p.role,
permissions: p.permissions, permissions: p.permissions,
wrapped_profile_dek: p.wrapped_profile_dek,
wrapped_profile_dek_iv: p.wrapped_profile_dek_iv,
created_at: p.created_at, created_at: p.created_at,
updated_at: p.updated_at, updated_at: p.updated_at,
} }
} }
} }
/// Create the default "patient" profile for a freshly registered user. Called /// Build a default "self" profile for a freshly registered user. Called from
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this /// `register` when the client provides a wrapped profile DEK for the default
/// is the contract the frontend relies on for medication creation. The name is /// profile. The server has no encryption key, so the name blob and wrapped
/// empty (not encrypted) because the server has no encryption key; the client /// profile DEK are taken verbatim from the caller (the client generated the
/// sets it via PUT /profiles/me after deriving its key. /// profile DEK and wrapped it under the account DEK).
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile { 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(); let now = DateTime::now();
Profile { Profile {
id: None, id: None,
profile_id: format!("profile_{user_id}"), // profile_<owner> keeps the deterministic-id contract the frontend
user_id: user_id.to_string(), // 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, family_id: None,
name: String::new(), name: name_data.to_string(),
name_iv: String::new(), name_iv: name_iv.to_string(),
name_auth_tag: String::new(), name_auth_tag: name_auth_tag.to_string(),
kind: "human".to_string(),
relationship: "self".to_string(),
role: "patient".to_string(), role: "patient".to_string(),
permissions: vec!["read:self".to_string(), "write:self".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, created_at: now,
updated_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<AppState>,
Extension(claims): Extension<Claims>,
) -> Result<Json<Vec<ProfileResponse>>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo.find_all_by_owner(&claims.sub).await {
Ok(profiles) => {
let resp: Vec<ProfileResponse> = 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)] #[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<String>,
#[serde(default = "default_kind_value")]
pub kind: String,
#[serde(default)]
pub relationship: String,
pub name_data: String, pub name_data: String,
pub name_iv: String, pub name_iv: String,
#[serde(default)] #[serde(default)]
pub name_auth_tag: String, 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 fn default_kind_value() -> String {
/// auto-created profile is missing, lazily create it. "human".to_string()
pub async fn get_my_profile( }
pub async fn create_profile(
State(state): State<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> { Json(req): Json<CreateProfileRequest>,
let database = state.db.get_database(); ) -> Result<(StatusCode, Json<ProfileResponse>), (StatusCode, Json<serde_json::Value>)> {
let repo = ProfileRepository::new(database.collection("profiles")); let now = DateTime::now();
let profile = Profile {
let profile = match repo.find_by_user_id(&claims.sub).await { id: None,
Ok(Some(p)) => p, profile_id: req
Ok(None) => { .profile_id
// Lazily create if missing (e.g. users registered before this code shipped). .unwrap_or_else(|| format!("p_{}", uuid::Uuid::new_v4())),
let p = build_default_profile(&claims.sub, &claims.sub); owner_account_id: claims.sub.clone(),
if let Err(e) = repo.create(&p).await { user_id: claims.sub.clone(),
tracing::error!("Failed to lazily create profile: {}", e); family_id: None,
return Err(( name: req.name_data,
StatusCode::INTERNAL_SERVER_ERROR, name_iv: req.name_iv,
Json(serde_json::json!({ "error": "failed to load profile" })), name_auth_tag: req.name_auth_tag,
)); kind: req.kind,
} relationship: req.relationship,
p role: "patient".to_string(),
} permissions: vec!["read:self".to_string(), "write:self".to_string()],
Err(e) => { wrapped_profile_dek: req.wrapped_profile_dek,
tracing::error!("Profile lookup failed: {}", e); wrapped_profile_dek_iv: req.wrapped_profile_dek_iv,
return Err(( created_at: now,
StatusCode::INTERNAL_SERVER_ERROR, updated_at: now,
Json(serde_json::json!({ "error": "database error" })),
));
}
}; };
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<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileNameRequest>, Path(profile_id): Path<String>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let database = state.db.get_database(); let repo = profile_repo(&state);
let repo = ProfileRepository::new(database.collection("profiles"));
match repo 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<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
let repo = profile_repo(&state);
match repo
.update_profile(
&profile_id,
&claims.sub, &claims.sub,
&req.name_data, &req.name_data,
&req.name_iv, &req.name_iv,
&req.name_auth_tag, &req.name_auth_tag,
&req.kind,
&req.relationship,
) )
.await .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<AppState>,
Extension(claims): Extension<Claims>,
Path(profile_id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
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" })),
))
}
}
}

View file

@ -39,7 +39,7 @@ pub struct UpdateProfileRequest {
pub username: Option<String>, pub username: Option<String>,
} }
pub async fn get_profile( pub async fn get_account(
State(state): State<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@ -79,7 +79,7 @@ pub async fn get_profile(
} }
} }
pub async fn update_profile( pub async fn update_account(
State(state): State<AppState>, State(state): State<AppState>,
Extension(claims): Extension<Claims>, Extension(claims): Extension<Claims>,
Json(req): Json<UpdateProfileRequest>, Json(req): Json<UpdateProfileRequest>,

View file

@ -126,16 +126,21 @@ impl AppointmentRepository {
Ok(appointment) 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( pub async fn find_by_user_filtered(
&self, &self,
user_id: &str, user_id: &str,
status: Option<&str>, status: Option<&str>,
profile_id: Option<&str>,
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> { ) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
let mut filter = doc! { "userId": user_id }; let mut filter = doc! { "userId": user_id };
if let Some(s) = status { if let Some(s) = status {
filter.insert("status", s); 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 cursor = self.collection.find(filter, None).await?;
let mut appointments = Vec::new(); let mut appointments = Vec::new();
while let Some(appt) = cursor.next().await { while let Some(appt) = cursor.next().await {

View file

@ -15,6 +15,9 @@ pub struct HealthStatistic {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")] #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>, pub id: Option<ObjectId>,
pub user_id: String, 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). /// Opaque client-encrypted blob (value, unit, type, notes inside).
#[serde(rename = "encryptedData")] #[serde(rename = "encryptedData")]
pub encrypted_data: EncryptedFieldWire, pub encrypted_data: EncryptedFieldWire,
@ -27,6 +30,7 @@ pub struct HealthStatistic {
pub struct HealthStatResponse { pub struct HealthStatResponse {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
pub profile_id: String,
pub encrypted_data: EncryptedFieldWire, pub encrypted_data: EncryptedFieldWire,
pub recorded_at: String, pub recorded_at: String,
} }
@ -36,6 +40,7 @@ impl From<HealthStatistic> for HealthStatResponse {
HealthStatResponse { HealthStatResponse {
id: s.id.map(|o| o.to_hex()).unwrap_or_default(), id: s.id.map(|o| o.to_hex()).unwrap_or_default(),
user_id: s.user_id, user_id: s.user_id,
profile_id: s.profile_id,
encrypted_data: s.encrypted_data, encrypted_data: s.encrypted_data,
recorded_at: s.recorded_at, recorded_at: s.recorded_at,
} }
@ -70,6 +75,21 @@ impl HealthStatisticsRepository {
cursor.try_collect().await 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<Vec<HealthStatistic>, 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<Option<HealthStatistic>, MongoError> { pub async fn find_by_id(&self, id: &ObjectId) -> Result<Option<HealthStatistic>, MongoError> {
let filter = doc! { "_id": id }; let filter = doc! { "_id": id };
self.collection.find_one(filter, None).await self.collection.find_one(filter, None).await

View file

@ -1,35 +1,76 @@
use futures::stream::StreamExt;
use mongodb::{ use mongodb::{
bson::{doc, oid::ObjectId, DateTime}, bson::{doc, oid::ObjectId, DateTime},
Collection, Collection,
}; };
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile { pub struct Profile {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")] #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>, pub id: Option<ObjectId>,
#[serde(rename = "profileId")] #[serde(rename = "profileId")]
pub profile_id: String, 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")] #[serde(rename = "userId")]
pub user_id: String, pub user_id: String,
#[serde(rename = "familyId")] #[serde(rename = "familyId")]
pub family_id: Option<String>, pub family_id: Option<String>,
/// Display name — opaque, encrypted under the profile DEK.
#[serde(rename = "name")] #[serde(rename = "name")]
pub name: String, pub name: String,
#[serde(rename = "nameIv")] #[serde(rename = "nameIv")]
pub name_iv: String, pub name_iv: String,
#[serde(rename = "nameAuthTag")] #[serde(rename = "nameAuthTag")]
pub name_auth_tag: String, 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, pub role: String,
#[serde(rename = "permissions")] #[serde(rename = "permissions", default)]
pub permissions: Vec<String>, pub permissions: Vec<String>,
/// 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")] #[serde(rename = "createdAt")]
pub created_at: DateTime, pub created_at: DateTime,
#[serde(rename = "updatedAt")] #[serde(rename = "updatedAt")]
pub updated_at: DateTime, pub updated_at: DateTime,
} }
fn default_kind() -> String {
"human".to_string()
}
fn default_role() -> String {
"patient".to_string()
}
pub struct ProfileRepository { pub struct ProfileRepository {
collection: Collection<Profile>, collection: Collection<Profile>,
} }
@ -44,6 +85,7 @@ impl ProfileRepository {
Ok(()) Ok(())
} }
/// Look up a profile by its application-level profile id.
pub async fn find_by_profile_id( pub async fn find_by_profile_id(
&self, &self,
profile_id: &str, profile_id: &str,
@ -53,32 +95,77 @@ impl ProfileRepository {
.await .await
} }
/// Look up a profile by its owning user id. /// Look up a profile by id AND owner — used for ownership-scoped access.
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> { /// 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<Option<Profile>> {
self.collection self.collection
.find_one(doc! { "userId": user_id }, None) .find_one(
doc! { "profileId": profile_id, "ownerAccountId": owner },
None,
)
.await .await
} }
/// Update the profile's display name (opaque client-encrypted blob). /// All profiles owned by an account.
pub async fn update_encrypted_name( pub async fn find_all_by_owner(&self, owner: &str) -> mongodb::error::Result<Vec<Profile>> {
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, &self,
user_id: &str, profile_id: &str,
owner: &str,
name_data: &str, name_data: &str,
name_iv: &str, name_iv: &str,
name_auth_tag: &str, name_auth_tag: &str,
kind: &str,
relationship: &str,
) -> mongodb::error::Result<Option<Profile>> { ) -> mongodb::error::Result<Option<Profile>> {
self.collection self.collection
.find_one_and_update( .find_one_and_update(
doc! { "userId": user_id }, doc! { "profileId": profile_id, "ownerAccountId": owner },
doc! { "$set": { doc! { "$set": {
"name": name_data, "name": name_data,
"nameIv": name_iv, "nameIv": name_iv,
"nameAuthTag": name_auth_tag, "nameAuthTag": name_auth_tag,
"kind": kind,
"relationship": relationship,
"updatedAt": DateTime::now() "updatedAt": DateTime::now()
}}, }},
None, None,
) )
.await .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<bool> {
let res = self
.collection
.delete_one(
doc! { "profileId": profile_id, "ownerAccountId": owner },
None,
)
.await?;
Ok(res.deleted_count > 0)
}
} }

View file

@ -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_<owner>.
assert!(arr[0]["profile_id"]
.as_str()
.unwrap()
.starts_with("profile_"));
common::drop_test_db(&db_name).await;
}

View file

@ -179,6 +179,7 @@ async fn health_stat_stored_as_ciphertext() {
"POST", "POST",
"/api/health-stats", "/api/health-stats",
Some(json!({ Some(json!({
"profile_id": "default",
"encrypted_data": { "data": opaque_data, "iv": "aXY=" }, "encrypted_data": { "data": opaque_data, "iv": "aXY=" },
"recorded_at": "2026-07-01T10:00:00Z", "recorded_at": "2026-07-01T10:00:00Z",
})), })),

View file

@ -24,7 +24,7 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAppointmentStore, useAuthStore } from '../../store/useStore'; import { useAppointmentStore, useProfileStore } from '../../store/useStore';
import type { Appointment } from '../../types/api'; import type { Appointment } from '../../types/api';
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
@ -74,7 +74,7 @@ export const AppointmentsManager: FC = () => {
deleteAppointment, deleteAppointment,
clearError, clearError,
} = useAppointmentStore(); } = useAppointmentStore();
const user = useAuthStore((s) => s.user); const activeProfileId = useProfileStore((s) => s.activeProfileId);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate); const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
@ -84,13 +84,13 @@ export const AppointmentsManager: FC = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
loadAppointments(); if (activeProfileId) loadAppointments();
}, [loadAppointments]); }, [loadAppointments, activeProfileId]);
const openCreate = () => { const openCreate = () => {
setCreateForm({ setCreateForm({
...emptyCreate, ...emptyCreate,
profile_id: `profile_${user?.user_id ?? 'default'}`, profile_id: activeProfileId ?? 'default',
}); });
setCreateOpen(true); setCreateOpen(true);
}; };

View file

@ -38,6 +38,8 @@ describe('MedicationManager', () => {
resetMockStore(); resetMockStore();
setMockStore({ setMockStore({
useAuthStore: { user: { user_id: 'u1', username: 'tester' }, profile: {} }, 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(); baseActions.loadMedications.mockClear();
}); });

View file

@ -24,7 +24,7 @@ import {
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add'; 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 type { Medication } from '../../types/api';
import { DoseLogger } from './DoseLogger'; import { DoseLogger } from './DoseLogger';
@ -60,7 +60,7 @@ export const MedicationManager: FC = () => {
deleteMedication, deleteMedication,
clearError, clearError,
} = useMedicationStore(); } = useMedicationStore();
const user = useAuthStore((s) => s.user); const activeProfileId = useProfileStore((s) => s.activeProfileId);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate); const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
@ -69,17 +69,16 @@ export const MedicationManager: FC = () => {
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null); const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
const [saving, setSaving] = useState(false); 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(() => { useEffect(() => {
loadMedications(); if (activeProfileId) loadMedications();
}, [loadMedications]); }, [loadMedications, activeProfileId]);
const openCreate = () => { const openCreate = () => {
// profile_id is deterministic: profile_<user_id>. 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({ setCreateForm({
...emptyCreate, ...emptyCreate,
profile_id: profileId, profile_id: activeProfileId ?? 'default',
}); });
setCreateOpen(true); setCreateOpen(true);
}; };

View file

@ -7,34 +7,61 @@ import {
CircularProgress, CircularProgress,
Alert, Alert,
Chip, Chip,
MenuItem,
Stack, Stack,
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save'; 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'; import { useProfileStore, useAuthStore } from '../../store/useStore';
export const ProfileEditor: FC = () => { export const ProfileEditor: FC = () => {
const { profile, isLoading, error, loadProfile, updateName, clearError } = const {
useProfileStore(); profiles,
activeProfileId,
isLoading,
error,
loadProfiles,
updateProfile,
createProfile,
deleteProfile,
clearError,
} = useProfileStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const active = profiles.find((p) => p.profile_id === activeProfileId) ?? null;
const [name, setName] = useState(''); const [name, setName] = useState('');
const [kind, setKind] = useState('human');
const [relationship, setRelationship] = useState('');
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { // Create-profile dialog state
loadProfile(); const [creating, setCreating] = useState(false);
}, [loadProfile]); const [newName, setNewName] = useState('');
const [newKind, setNewKind] = useState('human');
const [newRel, setNewRel] = useState('');
useEffect(() => { useEffect(() => {
if (profile?.name) setName(profile.name); loadProfiles();
}, [profile]); }, [loadProfiles]);
useEffect(() => {
if (active) {
setName(active.name);
setKind(active.kind || 'human');
setRelationship(active.relationship || '');
}
}, [active]);
const handleSave = async () => { const handleSave = async () => {
if (!active) return;
setSaving(true); setSaving(true);
try { try {
await updateName(name); await updateProfile(active.profile_id, { name, kind, relationship });
setEditing(false); setEditing(false);
} catch { } catch {
/* error surfaced via store */ /* 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 ( return (
<Box> <Box>
<Typography variant="h6" sx={{ mb: 2 }}> <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
Profile <Typography variant="h6">Profiles</Typography>
</Typography> <Button startIcon={<AddIcon />} onClick={() => setCreating(true)}>
Add profile
</Button>
</Stack>
{error && ( {error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}> <Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
@ -55,11 +114,11 @@ export const ProfileEditor: FC = () => {
</Alert> </Alert>
)} )}
{isLoading && !profile ? ( {isLoading && profiles.length === 0 ? (
<Box display="flex" justifyContent="center" py={4}> <Box display="flex" justifyContent="center" py={4}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : ( ) : active ? (
<Card> <Card>
<CardContent> <CardContent>
<Stack spacing={2}> <Stack spacing={2}>
@ -84,39 +143,131 @@ export const ProfileEditor: FC = () => {
> >
{saving ? <CircularProgress size={24} /> : 'Save'} {saving ? <CircularProgress size={24} /> : 'Save'}
</Button> </Button>
<Button onClick={() => { setEditing(false); setName(profile?.name ?? ''); }}> <Button onClick={() => { setEditing(false); setName(active.name); }}>
Cancel Cancel
</Button> </Button>
</Stack> </Stack>
) : ( ) : (
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h6">{profile?.name ?? user?.username ?? '—'}</Typography> <Typography variant="h6">{active.name || user?.username || '—'}</Typography>
<Button size="small" onClick={() => setEditing(true)}>Edit</Button> <Button size="small" onClick={() => setEditing(true)}>Edit</Button>
</Stack> </Stack>
)} )}
</Box> </Box>
<Box> {editing && (
<Typography variant="caption" color="text.secondary"> <>
Account <Box>
</Typography> <Typography variant="caption" color="text.secondary">Kind</Typography>
<Typography variant="body2">{user?.email}</Typography> <TextField
</Box> select
size="small"
<Box> fullWidth
<Typography variant="caption" color="text.secondary"> value={kind}
Role onChange={(e) => setKind(e.target.value)}
</Typography> sx={{ mt: 0.5 }}
<Box sx={{ mt: 0.5 }}> >
<Chip size="small" label={profile?.role ?? 'patient'} variant="outlined" /> <MenuItem value="human">Human</MenuItem>
</Box> <MenuItem value="pet">Pet</MenuItem>
</Box> </TextField>
</Box>
{profile?.profile_id && ( <Box>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Profile ID: {profile.profile_id} Relationship
</Typography> </Typography>
<TextField
size="small"
fullWidth
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
placeholder="self, child, spouse, parent, pet, ..."
sx={{ mt: 0.5 }}
/>
</Box>
</>
)} )}
{!editing && (
<>
<Box>
<Typography variant="caption" color="text.secondary">Kind / relationship</Typography>
<Box sx={{ mt: 0.5 }}>
<Chip size="small" label={active.kind || 'human'} variant="outlined" sx={{ mr: 1 }} />
{active.relationship && (
<Chip size="small" label={active.relationship} variant="outlined" />
)}
</Box>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Account</Typography>
<Typography variant="body2">{user?.email}</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
Profile ID: {active.profile_id}
</Typography>
<Box>
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={handleDelete}
>
Delete profile
</Button>
</Box>
</>
)}
</Stack>
</CardContent>
</Card>
) : (
<Alert severity="info">
No profiles yet. Create one to start tracking health data.
</Alert>
)}
{creating && (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>New profile</Typography>
<Stack spacing={2}>
<TextField
label="Display name"
size="small"
fullWidth
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<TextField
select
label="Kind"
size="small"
fullWidth
value={newKind}
onChange={(e) => setNewKind(e.target.value)}
>
<MenuItem value="human">Human</MenuItem>
<MenuItem value="pet">Pet</MenuItem>
</TextField>
<TextField
label="Relationship"
size="small"
fullWidth
value={newRel}
onChange={(e) => setNewRel(e.target.value)}
placeholder="child, spouse, parent, pet, ..."
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleCreate}
disabled={saving || !newName.trim()}
>
{saving ? <CircularProgress size={24} /> : 'Create'}
</Button>
<Button onClick={() => setCreating(false)}>Cancel</Button>
</Stack>
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -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 ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={18} sx={{ color: 'common.white' }} />
</Box>
) : (
<Typography variant="body2" sx={{ opacity: 0.7, mr: 2 }}>
No profile
</Typography>
);
}
return (
<Select
size="small"
value={activeProfileId ?? ''}
onChange={(e) => setActiveProfile(e.target.value)}
sx={{
mr: 2,
color: 'common.white',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'.MuiSvgIcon-root': { color: 'common.white' },
}}
>
{profiles.map((p) => (
<MenuItem key={p.profile_id} value={p.profile_id}>
{p.name || p.relationship || p.profile_id}
</MenuItem>
))}
</Select>
);
};
export default ProfileSwitcher;

View file

@ -6,6 +6,8 @@ import {
rewrapDek, rewrapDek,
encryptJson, encryptJson,
decryptJson, decryptJson,
encrypt,
decrypt,
setEncKey, setEncKey,
clearEncKey, clearEncKey,
hasEncKey, hasEncKey,
@ -15,6 +17,12 @@ import {
setIdentityPrivate, setIdentityPrivate,
clearIdentityPrivate, clearIdentityPrivate,
hasIdentityPrivate, hasIdentityPrivate,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
setProfileDek,
getProfileDek,
clearProfileDeks,
} from './index'; } from './index';
/** X25519 in Web Crypto is a recent addition (Node 16+, modern browsers). Some /** 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); 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();
});
});

View file

@ -11,6 +11,9 @@ export {
generateIdentityKeyPair, generateIdentityKeyPair,
wrapIdentityPrivateKey, wrapIdentityPrivateKey,
unwrapIdentityPrivateKey, unwrapIdentityPrivateKey,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
setEncKey, setEncKey,
getEncKey, getEncKey,
clearEncKey, clearEncKey,
@ -19,6 +22,12 @@ export {
getIdentityPrivate, getIdentityPrivate,
clearIdentityPrivate, clearIdentityPrivate,
hasIdentityPrivate, hasIdentityPrivate,
setProfileDek,
getProfileDek,
setActiveProfileId,
getActiveProfileId,
getActiveProfileDek,
clearProfileDeks,
AUTH_SALT, AUTH_SALT,
PASSWORD_KEK_SALT, PASSWORD_KEK_SALT,
RECOVERY_KEK_SALT, RECOVERY_KEK_SALT,

View file

@ -114,6 +114,39 @@ export async function rewrapDek(
return wrapDek(dek, newKek); 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<CryptoKey> {
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<CipherPayload> {
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<CryptoKey> {
return unwrapDek(payload, accountDek);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// High-level flows // High-level flows
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -287,6 +320,43 @@ export function hasIdentityPrivate(): boolean {
return currentIdentityPrivate !== null; 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<string, CryptoKey> = 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 // Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password
// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword. // enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword.

View file

@ -6,6 +6,7 @@ import { MedicationManager } from '../components/medication/MedicationManager';
import { HealthStats } from '../components/health/HealthStats'; import { HealthStats } from '../components/health/HealthStats';
import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; import { InteractionsChecker } from '../components/interactions/InteractionsChecker';
import { ProfileEditor } from '../components/profile/ProfileEditor'; import { ProfileEditor } from '../components/profile/ProfileEditor';
import { ProfileSwitcher } from '../components/profile/ProfileSwitcher';
import { AppointmentsManager } from '../components/appointments/AppointmentsManager'; import { AppointmentsManager } from '../components/appointments/AppointmentsManager';
type TabIndex = 0 | 1 | 2 | 3 | 4; type TabIndex = 0 | 1 | 2 | 3 | 4;
@ -35,6 +36,7 @@ export const Dashboard: FC = () => {
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Normogen Normogen
</Typography> </Typography>
<ProfileSwitcher />
{user && ( {user && (
<Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}> <Typography variant="body2" sx={{ mr: 2, opacity: 0.85 }}>
{user.username} {user.username}

View file

@ -18,6 +18,8 @@ import {
MedicationWireResponse, MedicationWireResponse,
AppointmentWireResponse, AppointmentWireResponse,
ProfileWireResponse, ProfileWireResponse,
CreateProfileRequest,
UpdateProfileRequest,
HealthStatWireResponse, HealthStatWireResponse,
UpdateHealthStatRequest, UpdateHealthStatRequest,
EncryptedFieldWire, EncryptedFieldWire,
@ -232,25 +234,37 @@ class ApiService {
}); });
} }
// ---- Profile (zero-knowledge: opaque encrypted name) ---- // ---- Profiles (Phase A2: multi-profile, per-profile DEKs) ----
async getProfile(): Promise<ProfileWireResponse> { async listProfiles(): Promise<ProfileWireResponse[]> {
const response = await this.client.get<ProfileWireResponse>('/profiles/me'); const response = await this.client.get<ProfileWireResponse[]>('/profiles');
return response.data; return response.data;
} }
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> { async createProfile(req: CreateProfileRequest): Promise<ProfileWireResponse> {
const response = await this.client.put<ProfileWireResponse>('/profiles/me', { const response = await this.client.post<ProfileWireResponse>('/profiles', req);
name_data: nameData,
name_iv: nameIv,
});
return response.data; return response.data;
} }
async getProfile(profileId: string): Promise<ProfileWireResponse> {
const response = await this.client.get<ProfileWireResponse>(`/profiles/${profileId}`);
return response.data;
}
async updateProfile(profileId: string, req: UpdateProfileRequest): Promise<ProfileWireResponse> {
const response = await this.client.put<ProfileWireResponse>(`/profiles/${profileId}`, req);
return response.data;
}
async deleteProfile(profileId: string): Promise<void> {
await this.client.delete(`/profiles/${profileId}`);
}
// ---- Medications (zero-knowledge: opaque encrypted blobs) ---- // ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(): Promise<MedicationWireResponse[]> { async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {
const response = await this.client.get<MedicationWireResponse[]>('/medications'); const params = profileId ? { profile_id: profileId } : undefined;
const response = await this.client.get<MedicationWireResponse[]>('/medications', { params });
return response.data; return response.data;
} }
@ -287,9 +301,12 @@ class ApiService {
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- // ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> { async getAppointments(status?: string, profileId?: string): Promise<AppointmentWireResponse[]> {
const params: Record<string, string> = {};
if (status) params.status = status;
if (profileId) params.profile_id = profileId;
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', { const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
params: status ? { status } : undefined, params: Object.keys(params).length ? params : undefined,
}); });
return response.data; return response.data;
} }
@ -332,8 +349,9 @@ class ApiService {
// ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ---- // ---- Health Statistics (zero-knowledge: opaque encrypted blobs) ----
async getHealthStats(): Promise<HealthStatWireResponse[]> { async getHealthStats(profileId?: string): Promise<HealthStatWireResponse[]> {
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats'); const params = profileId ? { profile_id: profileId } : undefined;
const response = await this.client.get<HealthStatWireResponse[]>('/health-stats', { params });
return response.data; return response.data;
} }

View file

@ -3,6 +3,9 @@ import {
deriveAuthAndEncKeys, deriveAuthAndEncKeys,
setEncKey, setEncKey,
clearEncKey, clearEncKey,
setActiveProfileId,
setProfileDek,
clearProfileDeks,
encryptJson, encryptJson,
setupEncryption, setupEncryption,
unlockWithPassword, unlockWithPassword,
@ -22,6 +25,10 @@ vi.mock('../services/api', () => ({ default: apiMock }));
// Import the store AFTER the mock is registered. // Import the store AFTER the mock is registered.
const { useMedicationStore } = await import('./useStore'); 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', () => { describe('useMedicationStore', () => {
let encKey: CryptoKey; let encKey: CryptoKey;
@ -30,6 +37,10 @@ describe('useMedicationStore', () => {
const { encKey: key } = await deriveAuthAndEncKeys('test-password'); const { encKey: key } = await deriveAuthAndEncKeys('test-password');
encKey = key; encKey = key;
setEncKey(encKey); 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({ useMedicationStore.setState({
medications: [], medications: [],
@ -44,6 +55,7 @@ describe('useMedicationStore', () => {
afterEach(() => { afterEach(() => {
clearEncKey(); clearEncKey();
clearProfileDeks();
}); });
it('loadMedications decrypts wire responses into domain medications', async () => { 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 () => { it('loadMedications sets an error when no enc key is available', async () => {
clearEncKey(); clearEncKey();
clearProfileDeks();
await useMedicationStore.getState().loadMedications(); await useMedicationStore.getState().loadMedications();
expect(useMedicationStore.getState().error).toMatch(/No encryption key/); expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
}); });

View file

@ -9,11 +9,22 @@ import {
generateIdentityKeyPair, generateIdentityKeyPair,
wrapIdentityPrivateKey, wrapIdentityPrivateKey,
unwrapIdentityPrivateKey, unwrapIdentityPrivateKey,
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
encrypt as encryptRaw,
decrypt as decryptRaw,
setEncKey, setEncKey,
clearEncKey, clearEncKey,
clearIdentityPrivate, clearIdentityPrivate,
clearProfileDeks,
setIdentityPrivate, setIdentityPrivate,
getEncKey, getEncKey,
getActiveProfileDek,
getActiveProfileId,
setActiveProfileId,
setProfileDek,
getProfileDek,
encryptJson, encryptJson,
decryptJson, decryptJson,
type CipherPayload, type CipherPayload,
@ -106,11 +117,26 @@ interface InteractionState {
} }
interface ProfileState { interface ProfileState {
profile: Profile | null; profiles: Profile[];
activeProfileId: string | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
loadProfile: () => Promise<void>; /** Fetch all owned profiles and unwrap each per-profile DEK under the account
updateName: (name: string) => Promise<void>; * DEK into the in-memory crypto store. Sets the first profile active. */
loadProfiles: () => Promise<void>;
/** 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<void>;
updateProfile: (profileId: string, input: {
name: string;
kind?: string;
relationship?: string;
}) => Promise<void>;
deleteProfile: (profileId: string) => Promise<void>;
clearError: () => void; clearError: () => void;
} }
@ -225,6 +251,14 @@ export const useAuthStore = create<AuthState>()(
const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek); const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek);
setIdentityPrivate(privateKey); 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({ const response = await apiService.register({
username, username,
email, email,
@ -237,7 +271,16 @@ export const useAuthStore = create<AuthState>()(
identity_public_key: publicKey, identity_public_key: publicKey,
identity_private_key_wrapped: wrappedPrivate.data, identity_private_key_wrapped: wrappedPrivate.data,
identity_private_key_wrapped_iv: wrappedPrivate.iv, 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({ set({
user: { user: {
user_id: response.user_id, user_id: response.user_id,
@ -305,6 +348,7 @@ export const useAuthStore = create<AuthState>()(
logout: async () => { logout: async () => {
clearEncKey(); clearEncKey();
clearIdentityPrivate(); clearIdentityPrivate();
clearProfileDeks();
await apiService.logout(); await apiService.logout();
set({ set({
user: null, user: null,
@ -369,14 +413,14 @@ export const useMedicationStore = create<MedicationState>()(
adherence: {}, adherence: {},
loadMedications: async () => { loadMedications: async () => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return; return;
} }
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const wireMeds = await apiService.getMedications(); const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined);
// Decrypt each opaque blob into domain Medication objects. // Decrypt each opaque blob into domain Medication objects.
const medications = await Promise.all( const medications = await Promise.all(
wireMeds.map(async (w) => { wireMeds.map(async (w) => {
@ -416,7 +460,7 @@ export const useMedicationStore = create<MedicationState>()(
}, },
createMedication: async (data: any) => { createMedication: async (data: any) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');
@ -466,7 +510,7 @@ export const useMedicationStore = create<MedicationState>()(
}, },
updateMedication: async (id: string, data: any) => { updateMedication: async (id: string, data: any) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');
@ -565,14 +609,14 @@ export const useHealthStore = create<HealthState>()(
error: null, error: null,
loadStats: async () => { loadStats: async () => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return; return;
} }
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const wireStats = await apiService.getHealthStats(); const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined);
const stats = await Promise.all( const stats = await Promise.all(
wireStats.map(async (w) => { wireStats.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key); const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
@ -599,7 +643,7 @@ export const useHealthStore = create<HealthState>()(
}, },
createStat: async (data: any) => { createStat: async (data: any) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');
@ -607,6 +651,7 @@ export const useHealthStore = create<HealthState>()(
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const d = data as any; const d = data as any;
const profileId = getActiveProfileId();
const encrypted_data = await encryptJson({ const encrypted_data = await encryptJson({
stat_type: d.stat_type, stat_type: d.stat_type,
value: d.value, value: d.value,
@ -614,6 +659,7 @@ export const useHealthStore = create<HealthState>()(
notes: d.notes, notes: d.notes,
}, key); }, key);
const wire = await apiService.createHealthStat({ const wire = await apiService.createHealthStat({
profile_id: profileId ?? '',
encrypted_data, encrypted_data,
recorded_at: d.measured_at, recorded_at: d.measured_at,
}); });
@ -640,7 +686,7 @@ export const useHealthStore = create<HealthState>()(
}, },
updateStat: async (id: string, data: any) => { updateStat: async (id: string, data: any) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');
@ -752,71 +798,158 @@ export const useInteractionStore = create<InteractionState>()(
})) }))
); );
// Profile Store (Phase 3c) // Profile Store (Phase A2: multi-profile + per-profile DEKs)
export const useProfileStore = create<ProfileState>()( export const useProfileStore = create<ProfileState>()(
devtools((set, get) => ({ devtools((set, get) => ({
profile: null, profiles: [],
activeProfileId: null,
isLoading: false, isLoading: false,
error: null, error: null,
loadProfile: async () => { loadProfiles: async () => {
const key = getEncKey(); const accountDek = getEncKey();
if (!key) { if (!accountDek) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); set({ error: 'No account key — log in to decrypt profiles', isLoading: false });
return; return;
} }
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const wire = await apiService.getProfile(); const wireList = await apiService.listProfiles();
// Decrypt the profile name. // Unwrap each profile's DEK under the account DEK, and decrypt each
let name = ''; // profile's display name under that profile's DEK.
if (wire.name_data && wire.name_iv) { const profiles: Profile[] = [];
try { for (const wire of wireList) {
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key); let profileDek = getProfileDek(wire.profile_id);
} catch { name = ''; } 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 = { // Set the first profile active if none is selected (or if the active
profile_id: wire.profile_id, // one is no longer present).
user_id: wire.user_id, const currentActive = getActiveProfileId();
name, const stillOwned = profiles.some((p) => p.profile_id === currentActive);
role: wire.role, const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null);
permissions: wire.permissions, if (newActive) setActiveProfileId(newActive);
created_at: wire.created_at, set({ profiles, activeProfileId: newActive, isLoading: false });
updated_at: wire.updated_at,
};
set({ profile, isLoading: false });
} catch (error: any) { } catch (error: any) {
set({ set({
error: error.message || 'Failed to load profile', error: error.message || 'Failed to load profiles',
isLoading: false, isLoading: false,
}); });
} }
}, },
updateName: async (name) => { setActiveProfile: (profileId) => {
const key = getEncKey(); setActiveProfileId(profileId);
if (!key) { set({ activeProfileId: profileId });
set({ error: 'No encryption key — cannot encrypt data' }); },
throw new Error('No encryption key');
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 }); set({ isLoading: true, error: null });
try { try {
const blob = await encryptJson(name, key); // Generate a fresh profile DEK, wrap it under the account DEK, and
const wire = await apiService.updateProfileName(blob.data, blob.iv); // encrypt the display name under the new profile DEK.
let decryptedName = name; const profileDek = await generateProfileDek();
try { const wrapped = await wrapProfileDek(profileDek, accountDek);
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key); const nameBlob = await encryptRaw(name, profileDek);
} catch { /* keep the input name */ } 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 = { const profile: Profile = {
profile_id: wire.profile_id, profile_id: wire.profile_id,
user_id: wire.user_id, owner_account_id: wire.owner_account_id,
name: decryptedName, name,
kind: wire.kind,
relationship: wire.relationship,
role: wire.role, role: wire.role,
permissions: wire.permissions, permissions: wire.permissions,
created_at: wire.created_at, created_at: wire.created_at,
updated_at: wire.updated_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) { } catch (error: any) {
set({ set({
error: error.message || 'Failed to update profile', error: error.message || 'Failed to update profile',
@ -826,6 +959,27 @@ export const useProfileStore = create<ProfileState>()(
} }
}, },
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 }), clearError: () => set({ error: null }),
})), })),
); );
@ -838,14 +992,14 @@ export const useAppointmentStore = create<AppointmentState>()(
error: null, error: null,
loadAppointments: async (status) => { loadAppointments: async (status) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return; return;
} }
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const wireAppts = await apiService.getAppointments(status); const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined);
const appointments = await Promise.all( const appointments = await Promise.all(
wireAppts.map(async (w) => { wireAppts.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key); const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
@ -878,7 +1032,7 @@ export const useAppointmentStore = create<AppointmentState>()(
}, },
createAppointment: async (data) => { createAppointment: async (data) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');
@ -924,7 +1078,7 @@ export const useAppointmentStore = create<AppointmentState>()(
}, },
updateAppointment: async (id, data) => { updateAppointment: async (id, data) => {
const key = getEncKey(); const key = getActiveProfileDek();
if (!key) { if (!key) {
set({ error: 'No encryption key — cannot encrypt data' }); set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key'); throw new Error('No encryption key');

View file

@ -71,6 +71,13 @@ export interface RegisterRequest {
identity_public_key?: string; identity_public_key?: string;
identity_private_key_wrapped?: string; identity_private_key_wrapped?: string;
identity_private_key_wrapped_iv?: 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 // Medication Types
@ -240,11 +247,13 @@ export interface HealthStat {
export interface HealthStatWireResponse { export interface HealthStatWireResponse {
id: string; id: string;
user_id: string; user_id: string;
profile_id: string;
encrypted_data: EncryptedFieldWire; encrypted_data: EncryptedFieldWire;
recorded_at: string; recorded_at: string;
} }
export interface CreateHealthStatRequest { export interface CreateHealthStatRequest {
profile_id: string;
encrypted_data: EncryptedFieldWire; encrypted_data: EncryptedFieldWire;
recorded_at?: string; recorded_at?: string;
} }
@ -324,18 +333,44 @@ export interface UpdateAppointmentRequest {
status?: string; status?: string;
} }
// Profile wire response (opaque encrypted name) // Profile wire response (opaque encrypted name + wrapped profile DEK)
export interface ProfileWireResponse { export interface ProfileWireResponse {
profile_id: string; profile_id: string;
user_id: string; owner_account_id: string;
name_data: string; name_data: string;
name_iv: string; name_iv: string;
kind: string;
relationship: string;
role: string; role: string;
permissions: string[]; permissions: string[];
/** Profile DEK wrapped under the account DEK (opaque ciphertext). */
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
created_at: string; created_at: string;
updated_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). // Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog { export interface DoseLog {
id?: string; id?: string;
@ -368,8 +403,10 @@ export interface AdherenceStats {
// Profile Types — match backend ProfileResponse. // Profile Types — match backend ProfileResponse.
export interface Profile { export interface Profile {
profile_id: string; profile_id: string;
user_id: string; owner_account_id: string;
name: string; name: string;
kind: string;
relationship: string;
role: string; role: string;
permissions: string[]; permissions: string[];
created_at: string; created_at: string;