Introduces a wrapped-DEK recovery model so a forgotten password doesn't lose
all encrypted data. The encryption key becomes a random DEK (not derived from
the password); the DEK is wrapped under both a password-derived KEK and a
recovery-phrase-derived KEK, and both wrapped forms are stored on the server.
Crypto (crypto/keys.ts):
- DEK generation (random AES-256-GCM), KEK derivation (PBKDF2 password/recovery),
wrapDek/unwrapDek/rewrapDek.
- setupEncryption(password, recoveryPhrase?) — generates a DEK, wraps under both
KEKs, returns wrapped forms + recovery proof.
- unlockWithPassword(password, wrappedDek) — derives password KEK, unwraps DEK.
- unlockWithRecovery(phrase, wrappedDek) — derives recovery KEK, unwraps DEK.
Backend:
- User model: wrapped_dek, wrapped_dek_iv, recovery_wrapped_dek,
recovery_wrapped_dek_iv fields.
- RegisterRequest accepts wrapped-DEK fields; stored verbatim.
- AuthResponse returns wrapped_dek + wrapped_dek_iv (for login unwrapping).
- New GET /api/auth/recovery-info?email= — returns recovery-wrapped DEK.
- RecoverPasswordRequest gains new_wrapped_dek + new_wrapped_dek_iv.
- change-password also accepts + stores re-wrapped DEK.
Frontend:
- Auth store: login unwraps DEK from response; new recover() action fetches
recovery-wrapped DEK, unwraps with phrase, re-wraps under new password.
- RecoveryPage (new): email + recovery phrase + new password flow.
- LoginPage: 'Forgot password? Recover' link. App.tsx: /recover route.
Verified: backend 21 tests, 0 warnings; frontend build clean, 20 tests.
Complete the core zero-knowledge property: all user data (medications,
appointments, profile names) is now client-encrypted via AES-GCM; the server
stores and returns opaque ciphertext and can never decrypt it.
Frontend crypto module (Web Crypto API, no deps):
- crypto/keys.ts: double-PBKDF2 derivation from the password — an auth secret
(base64, sent to the server as the 'password') and an encryption key (AES-GCM
CryptoKey, kept in memory only, never transmitted). In-memory key store.
- crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers.
Auth split: login/register now derive the auth secret + enc key from the
password BEFORE the API call. Only the auth secret (not the raw password) is
sent to the server. The server's PBKDF2 stays as-is (it hashes whatever it
receives) but can never derive the enc key.
Backend — server treats all data blobs as opaque:
- Medication: removed MedicationData + flat MedicationResponse; new
MedicationResponse echoes metadata + encrypted_data blob. Create/update
accept opaque blobs (whole-blob replace). MedicationData struct deleted.
- Appointment: same opaque treatment; status moved to a top-level document field
so it remains filterable without decryption. AppointmentData struct deleted.
- Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-created
profile starts empty; client sets it.
- EncryptedFieldWire shared wire type across medication/appointment.
Frontend — decrypt-on-read, encrypt-on-write:
- Stores derive the enc key on login/register; decrypt wire responses into
domain objects on load; encrypt domain data into blobs on create/update.
- API client returns wire types (opaque blobs); components consume decrypted
domain data (mostly unchanged — the store does the crypto).
- Updated store tests for the ZK contract (derive a real key, mock wire responses).
- 20 frontend tests pass, build clean.
Backend: 21 tests pass (removed MedicationData/appointment-data deser tests;
opaque-blob echo tests added), clippy 0 warnings.
KNOWN LIMITATIONS (Phase 2): forgotten password = data loss (no recovery wrapping
yet). Page reload requires re-entering the password to re-derive the enc key
(in-memory only, by design). No data migration (no real data existed).
Backend: server can no longer read user data. All data blobs (medication,
appointment, profile name) are now opaque client-encrypted ciphertext — the
server stores and returns them verbatim, never deserializing the contents.
- Medication: removed MedicationData + flat MedicationResponse; new
MedicationResponse echoes metadata + encrypted_data blob. Create/update
accept opaque blobs (whole-blob replace). Update is no longer load-mutate-
reserialize (server can't read the data).
- Appointment: same opaque treatment; status moved to a top-level document
field so it remains filterable without decryption.
- Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-
created profile on register starts with an empty name; client sets it.
- EncryptedFieldWire type shared across medication/appointment.
Frontend (partial): crypto module using Web Crypto API —
- crypto/keys.ts: double-PBKDF2 derivation (auth secret sent to server +
encryption key kept in memory); in-memory key store (set/get/clear).
- crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers.
- crypto/index.ts: re-exports.
NOT YET DONE (frontend integration): auth store key derivation on login/register,
stores decrypt-on-load/encrypt-on-write, types update, UI components wired,
crypto round-trip tests, ADR. This commit is a verified checkpoint — backend
builds clean (21 tests, 0 warnings); frontend crypto module exists but is not
yet wired into the data flow.
Backend + frontend. Resolves the integration gap where the medication list/create
responses were deeply nested (fields inside medicationData.data JSON blob,
camelCase) while the frontend expected flat snake_case fields — so the
MedicationManager couldn't display real data.
Backend (models/medication.rs):
* New MedicationData struct: deserializes the camelCase JSON string stored in
medicationData.data (sideEffects->side_effects, prescribedBy->prescribed_by,
etc.). Tolerates missing keys via #[default].
* New MedicationResponse: flat snake_case, the wire format. From<Medication>
deserializes the data blob, maps fields, synthesizes active=true (TODO: real
active flag), formats timestamps as ISO 8601. Tolerates malformed blobs.
* Fixed MedicationRepository::update: the old dot-notation (medicationData.name)
wrote into phantom paths because the stored shape is medicationData:{data:
'<string>'}. Now loads the doc, deserializes the blob, applies overrides,
re-serializes, and $sets the whole data string. Updates actually persist now.
* 4 new unit tests (MedicationData deser, defaults, MedicationResponse flatten,
malformed-blob tolerance).
Handlers (handlers/medications.rs): create/list/get/update now return
MedicationResponse instead of the raw Medication model.
Frontend (types/api.ts): Medication interface reconciled to match
MedicationResponse (added id, profile_id, route, reason, side_effects,
prescribed_by/date, notes, tags; relaxed user_id to optional).
Verified: backend cargo fmt/build/clippy 0 warnings, 23 unit tests pass (was 19);
frontend npm build clean, 20 vitest tests pass.
Three workstreams, all backend+frontend (per scope decisions):
Dose logging + real adherence (backend + frontend):
* log_dose now returns the created dose (201 + body) instead of an empty 201.
* get_adherence implemented for real: queries the medication_doses collection
over the last 30 days, counts taken vs total, computes the rate. The previous
implementation hardcoded zeros. Removed the dead calculate_adherence stub.
* Frontend: fixed DoseLog type to match backend MedicationDose (taken:bool,
loggedAt, camelCase); added AdherenceStats + LogDoseRequest types; logDose() +
getAdherence() in api.ts; loadAdherence/logDose actions in the medication
store (adherence cache keyed by med id); new DoseLogger component (Taken/
Skipped buttons + LinearProgress adherence bar) embedded in each
MedicationManager card.
Profile management (backend + frontend):
* New GET/PUT /api/profiles/me endpoints (ProfileResponse excludes encryption
fields; find_by_user_id + update_name on ProfileRepository).
* Register auto-creates a default 'patient' profile (deterministic profile_id =
profile_<user_id>) — this is the contract the frontend relies on.
* Frontend: Profile type; getProfile()/updateProfileName() in api.ts; useProfileStore;
new ProfileEditor component (view/edit name, shows role) as a 4th Dashboard tab.
* Resolved the MedicationManager profile_id TODO: now derives profile_<user_id>
instead of the 'default' fallback.
* NOTE: profile name is stored plaintext (the model anticipates encryption via
nameIv/nameAuthTag but no crypto layer is implemented yet — TODO).
Vitest tests:
* Added @testing-library/user-event; setupTests clears localStorage + cleanup
between tests; new test/mockStore.ts helper (mocks the co-located stores,
handles both selector and no-selector call patterns).
* 5 test files, 20 tests: SeverityChip (4), useMedicationStore actions incl.
loadMedications/createMedication/logDose (4), MedicationManager render+dialog
(5), InteractionsChecker selection+results (4), HealthStats table+dialog (3).
Verified: backend cargo fmt/build/clippy 0 warnings, 19 unit tests pass;
frontend npm build clean, 20 vitest tests pass. Solaria round-trip confirmed:
profile auto-created on register (GET /profiles/me), PUT updates name, dose log
returns the dose body, adherence computes 66.7% for 2-taken/1-skipped.
KNOWN FOLLOW-UP (separate task): the backend Medication list response is deeply
nested + camelCase + stores fields inside medicationData.data; the frontend
Medication type assumes flat top-level snake_case fields. This pre-dates Phase 3c
and affects the whole MedicationManager — needs a backend serialization fix or a
frontend adapter.
Build the three core feature UIs on top of the now-wired stores + corrected API
client from Phase 3a, turning the runnable shell into a usable app.
Dashboard (pages/Dashboard.tsx): AppBar (Normogen + username + logout) over an
MUI Tabs container (Medications | Health | Interactions), widened to lg. User
loads on mount (needed for profile_id in medication create).
MedicationManager (components/medication/): full CRUD.
* List of medication cards (name, dosage·frequency, active chip, instructions)
with edit/delete actions and an empty state.
* Create dialog: name/dosage/frequency/route(select)/instructions. profile_id
sourced from user.profile_id with a 'default' fallback (TODO: real profiles).
Payload typed to CreateMedicationRequest.
* Edit dialog (UpdateMedicationRequest shape) + delete confirm. Guards the
optional medication_id field.
HealthStats (components/health/): trend cards + chart + add + table.
* Trend summary cards from /health-stats/trends (avg/min/max + trend arrow).
* recharts LineChart of a selected stat type over measured_at, with a type
selector. Timestamp field is measured_at (not timestamp).
* Record dialog (stat_type/value/unit/measured_at/notes) + recent-readings table
(date-fns formatted).
InteractionsChecker (components/interactions/): multi-select medication names
(chips) -> POST /interactions/check. Results rendered as cards with a
SeverityChip, description, and disclaimer. Success/empty states.
Shared SeverityChip (components/common/): maps InteractionSeverity ->
MUI Chip color (severe=error, moderate=warning, mild=success, unknown=default).
Added @mui/icons-material@7 (pinned to match @mui/material 7; npm tried to grab
v9 which needs material ^9).
Verified: npm run build clean (TS5 strict), dev server serves the app + all
component modules transform. Solaria round-trip confirmed every contract the UIs
call: medication create/list (200), interactions warfarin+aspirin -> severe,
health-stat create (201).
The frontend was a Create React App scaffold (deprecated) where App.tsx was
still the stock 'Learn React' boilerplate — the implemented login/register pages
and stores were never mounted (no router). This swaps to Vite/TS5, fixes the
broken API client, wires a real router + dashboard, and lands on a runnable app.
Tooling swap (CRA -> Vite):
* Dropped react-scripts, web-vitals, @types/jest; added vite 6 + @vitejs/plugin-react
+ vitest 3 + jsdom; bumped typescript 4.9 -> 5.6, @types/node 16 -> 22.
* New vite.config.ts (with dev-server /api proxy -> backend, no CORS in dev),
tsconfig.json (target es2022, moduleResolution bundler, vite/client types),
tsconfig.node.json, vite-env.d.ts, root index.html.
* .env / .env.example (VITE_API_URL + VITE_API_TARGET). Renamed index.tsx ->
main.tsx (theme provider + CssBaseline). Deleted all CRA boilerplate
(App.css, logo.svg, App.test.tsx, reportWebVitals.ts, react-app-env.d.ts,
CRA index.html/logos/readme) + the orphaned empty web/src/ and mobile/ trees.
* node_modules 479MB -> 265MB; lockfile 695KB -> 198KB.
API client correctness (services/api.ts):
* Env var process.env.REACT_APP_* -> import.meta.env.VITE_*; dropped the
hardcoded http://solaria:8001/api (now /api via the dev proxy).
* Fixed contract mismatches: getCurrentUser /auth/me -> /users/me;
updateMedication PUT -> POST /:id; deleteMedication DELETE -> POST /:id/delete.
* Added refreshToken() (/auth/refresh) and server-side logout() (/auth/logout).
* Removed the /lab-results block (no backend route).
* 401 interceptor now attempts ONE silent refresh (deduped) before bouncing to
/login, instead of hard-redirecting on every 401.
* CreateMedicationRequest type now matches the backend (required route +
profile_id fields).
Router + dashboard:
* Real App.tsx with BrowserRouter: /login, /register (public), / (protected
Dashboard), catch-all -> /.
* New Dashboard.tsx: MUI AppBar + welcome card, loads the current user on mount,
logout button. The shell feature UIs get built into next.
* LoginPage/RegisterPage navigate to / (was /dashboard); store logout is now
async; AuthTokens type includes refresh_token.
* Minimal MUI theme.ts.
Verified: npm run build clean (TS5 strict + Vite), dev server serves the app +
proxies /api to the backend, vitest runs. Solaria round-trip confirmed all
corrected endpoints (/users/me 200, /auth/refresh 200, /auth/logout 204,
medication create 200).