feat: zero-knowledge encryption Phase 1 — server can no longer read user data

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).
This commit is contained in:
goose 2026-06-28 21:46:10 -03:00
parent 149ce37654
commit 1301473bfe
7 changed files with 420 additions and 134 deletions

View file

@ -25,22 +25,32 @@ import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { format } from 'date-fns';
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
import type {
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
} from '../../types/api';
import type { Appointment } from '../../types/api';
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
const STATUSES = ['upcoming', 'completed', 'cancelled'];
// Domain form type (decrypted fields the UI collects; the store encrypts them).
interface ApptFormData {
title: string;
provider: string;
appointment_type: string;
date_time: string;
location?: string;
duration_minutes?: number;
reason?: string;
notes?: string;
status?: string;
profile_id?: string;
}
const statusColor = (status: string): 'success' | 'default' | 'error' => {
if (status === 'upcoming') return 'success';
if (status === 'cancelled') return 'error';
return 'default';
};
const emptyCreate: CreateAppointmentRequest = {
const emptyCreate: ApptFormData = {
title: '',
provider: '',
appointment_type: 'in-person',
@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => {
const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate);
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
const [editForm, setEditForm] = useState<Partial<ApptFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
const [saving, setSaving] = useState(false);
@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => {
const submitCreate = async () => {
setSaving(true);
try {
await createAppointment(createForm);
await createAppointment(createForm as unknown as Record<string, unknown>);
setCreateOpen(false);
} catch {
/* error surfaced via store */
@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => {
if (!editTarget?.appointment_id) return;
setSaving(true);
try {
await updateAppointment(editTarget.appointment_id, editForm);
await updateAppointment(editTarget.appointment_id, editForm as unknown as Record<string, unknown>);
setEditTarget(null);
} catch {
/* error surfaced via store */

View file

@ -25,16 +25,23 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useMedicationStore, useAuthStore } from '../../store/useStore';
import type {
Medication,
CreateMedicationRequest,
UpdateMedicationRequest,
} from '../../types/api';
import type { Medication } from '../../types/api';
import { DoseLogger } from './DoseLogger';
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
const emptyCreate: CreateMedicationRequest = {
// Domain form types (decrypted fields the UI collects; the store encrypts them).
interface MedFormData {
name: string;
dosage: string;
frequency: string;
route: string;
instructions?: string;
profile_id?: string;
active?: boolean;
}
const emptyCreate: MedFormData = {
name: '',
dosage: '',
frequency: '',
@ -56,9 +63,9 @@ export const MedicationManager: FC = () => {
const user = useAuthStore((s) => s.user);
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<CreateMedicationRequest>(emptyCreate);
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
const [editTarget, setEditTarget] = useState<Medication | null>(null);
const [editForm, setEditForm] = useState<UpdateMedicationRequest>({});
const [editForm, setEditForm] = useState<Partial<MedFormData>>({});
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
const [saving, setSaving] = useState(false);
@ -238,7 +245,7 @@ export const MedicationManager: FC = () => {
fullWidth
value={createForm.route}
onChange={(e) =>
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] })
setCreateForm({ ...createForm, route: e.target.value })
}
>
{ROUTES.map((r) => (

View file

@ -40,9 +40,9 @@ export async function encrypt(
): Promise<CipherPayload> {
const iv = randomIv();
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
encoder.encode(plaintext),
encoder.encode(plaintext) as BufferSource,
);
return {
data: b64.encode(new Uint8Array(ciphertext)),
@ -57,9 +57,9 @@ export async function decrypt(
): Promise<string> {
const iv = b64.decode(payload.iv);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
b64.decode(payload.data),
b64.decode(payload.data) as BufferSource,
);
return decoder.decode(plaintext);
}

View file

@ -17,8 +17,10 @@ import {
DoseLog,
LogDoseRequest,
AdherenceStats,
Profile,
Appointment,
MedicationWireResponse,
AppointmentWireResponse,
ProfileWireResponse,
EncryptedFieldWire,
CreateAppointmentRequest,
UpdateAppointmentRequest,
} from '../types/api';
@ -213,47 +215,48 @@ class ApiService {
return response.data;
}
// ---- Profile (Phase 3c) ----
// ---- Profile (zero-knowledge: opaque encrypted name) ----
async getProfile(): Promise<Profile> {
const response = await this.client.get<Profile>('/profiles/me');
async getProfile(): Promise<ProfileWireResponse> {
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
return response.data;
}
async updateProfileName(name: string): Promise<Profile> {
const response = await this.client.put<Profile>('/profiles/me', { name });
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
name_data: nameData,
name_iv: nameIv,
});
return response.data;
}
// ---- Medications ----
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(): Promise<Medication[]> {
const response = await this.client.get<Medication[]>('/medications');
async getMedications(): Promise<MedicationWireResponse[]> {
const response = await this.client.get<MedicationWireResponse[]>('/medications');
return response.data;
}
async getMedication(id: string): Promise<Medication> {
const response = await this.client.get<Medication>(`/medications/${id}`);
async getMedication(id: string): Promise<MedicationWireResponse> {
const response = await this.client.get<MedicationWireResponse>(`/medications/${id}`);
return response.data;
}
async createMedication(data: CreateMedicationRequest): Promise<Medication> {
const response = await this.client.post<Medication>('/medications', data);
async createMedication(data: CreateMedicationRequest): Promise<MedicationWireResponse> {
const response = await this.client.post<MedicationWireResponse>('/medications', data);
return response.data;
}
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> {
// Backend update is POST /:id (not PUT).
const response = await this.client.post<Medication>(`/medications/${id}`, data);
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<MedicationWireResponse> {
const response = await this.client.post<MedicationWireResponse>(`/medications/${id}`, data);
return response.data;
}
async deleteMedication(id: string): Promise<void> {
// Backend delete is POST /:id/delete (not DELETE /:id).
await this.client.post(`/medications/${id}/delete`);
}
// ---- Dose logging + adherence (Phase 3c) ----
// ---- Dose logging + adherence (not encrypted — counts/flags only) ----
async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> {
const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req);
@ -265,27 +268,27 @@ class ApiService {
return response.data;
}
// ---- Appointments ----
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
async getAppointments(status?: string): Promise<Appointment[]> {
const response = await this.client.get<Appointment[]>('/appointments', {
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
params: status ? { status } : undefined,
});
return response.data;
}
async getAppointment(id: string): Promise<Appointment> {
const response = await this.client.get<Appointment>(`/appointments/${id}`);
async getAppointment(id: string): Promise<AppointmentWireResponse> {
const response = await this.client.get<AppointmentWireResponse>(`/appointments/${id}`);
return response.data;
}
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> {
const response = await this.client.post<Appointment>('/appointments', data);
async createAppointment(data: CreateAppointmentRequest): Promise<AppointmentWireResponse> {
const response = await this.client.post<AppointmentWireResponse>('/appointments', data);
return response.data;
}
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> {
const response = await this.client.post<Appointment>(`/appointments/${id}`, data);
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<AppointmentWireResponse> {
const response = await this.client.post<AppointmentWireResponse>(`/appointments/${id}`, data);
return response.data;
}

View file

@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto';
import { encryptJson } from '../crypto';
// Mock the api client so the store's real reducer logic is exercised without HTTP.
const apiMock = {
@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock }));
const { useMedicationStore } = await import('./useStore');
describe('useMedicationStore', () => {
beforeEach(() => {
let encKey: CryptoKey;
beforeEach(async () => {
// Derive a real encryption key so the store can encrypt/decrypt.
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
encKey = key;
setEncKey(encKey);
useMedicationStore.setState({
medications: [],
isLoading: false,
error: null,
adherence: {},
selectedMedication: null,
});
apiMock.getMedications.mockReset();
apiMock.createMedication.mockReset();
});
it('loadMedications populates state from the api client', async () => {
const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }];
apiMock.getMedications.mockResolvedValue(meds);
afterEach(() => {
clearEncKey();
});
it('loadMedications decrypts wire responses into domain medications', async () => {
// The API returns opaque wire blobs; the store decrypts them.
const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey);
apiMock.getMedications.mockResolvedValue([
{
id: 'oid1',
medication_id: 'm1',
user_id: 'u1',
profile_id: 'p1',
active: true,
encrypted_data: blob,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]);
await useMedicationStore.getState().loadMedications();
expect(apiMock.getMedications).toHaveBeenCalledOnce();
expect(useMedicationStore.getState().medications).toEqual(meds);
expect(useMedicationStore.getState().isLoading).toBe(false);
const meds = useMedicationStore.getState().medications;
expect(meds).toHaveLength(1);
expect(meds[0].name).toBe('Aspirin');
expect(meds[0].dosage).toBe('100mg');
expect(meds[0].active).toBe(true);
});
it('loadMedications sets an error message on failure (and does not throw)', async () => {
apiMock.getMedications.mockRejectedValue(new Error('boom'));
// Should NOT throw — load actions swallow.
it('loadMedications sets an error when no enc key is available', async () => {
clearEncKey();
await useMedicationStore.getState().loadMedications();
expect(useMedicationStore.getState().error).toBe('boom');
expect(useMedicationStore.getState().isLoading).toBe(false);
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
});
it('createMedication appends the new medication and re-throws on error', async () => {
apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' });
it('createMedication encrypts domain data and stores the decrypted result', async () => {
const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey);
apiMock.createMedication.mockResolvedValue({
id: 'oid2',
medication_id: 'm2',
user_id: 'u1',
profile_id: 'p1',
active: true,
encrypted_data: blob,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
});
await useMedicationStore.getState().createMedication({ name: 'New' });
await useMedicationStore.getState().createMedication({
name: 'New',
dosage: '50mg',
frequency: 'daily',
route: 'oral',
profile_id: 'p1',
active: true,
});
expect(useMedicationStore.getState().medications).toEqual([
{ medication_id: 'm2', name: 'New' },
]);
// Now an error path: createMedication rejects -> store re-throws.
apiMock.createMedication.mockRejectedValue(new Error('nope'));
await expect(
useMedicationStore.getState().createMedication({ name: 'Bad' }),
).rejects.toThrow('nope');
const meds = useMedicationStore.getState().medications;
expect(meds).toHaveLength(1);
expect(meds[0].name).toBe('New');
expect(meds[0].dosage).toBe('50mg');
});
it('logDose logs the dose then refreshes adherence', async () => {

View file

@ -1,5 +1,13 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import {
deriveAuthAndEncKeys,
setEncKey,
clearEncKey,
getEncKey,
encryptJson,
decryptJson,
} from '../crypto';
import {
User,
Medication,
@ -88,8 +96,10 @@ interface AppointmentState {
isLoading: boolean;
error: string | null;
loadAppointments: (status?: string) => Promise<void>;
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
// The store receives DOMAIN data (decrypted fields from the UI) and encrypts
// it internally before sending to the server.
createAppointment: (data: Record<string, unknown>) => Promise<void>;
updateAppointment: (id: string, data: Record<string, unknown>) => Promise<void>;
deleteAppointment: (id: string) => Promise<void>;
clearError: () => void;
}
@ -108,7 +118,11 @@ export const useAuthStore = create<AuthState>()(
login: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const response = await apiService.login(email, password);
// Zero-knowledge: derive auth secret + encryption key from password.
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
setEncKey(encKey);
// Send the derived auth secret (not the raw password) to the server.
const response = await apiService.login(email, authSecret);
set({
user: {
user_id: response.user_id,
@ -121,6 +135,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
});
} catch (error: any) {
clearEncKey();
set({
error: error.message || 'Login failed',
isLoading: false,
@ -133,11 +148,12 @@ export const useAuthStore = create<AuthState>()(
register: async (username: string, email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
setEncKey(encKey);
const response = await apiService.register({
username,
email,
password,
role: 'patient',
password: authSecret,
});
set({
user: {
@ -161,6 +177,7 @@ export const useAuthStore = create<AuthState>()(
},
logout: async () => {
clearEncKey();
await apiService.logout();
set({
user: null,
@ -215,9 +232,42 @@ export const useMedicationStore = create<MedicationState>()(
adherence: {},
loadMedications: async () => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const medications = await apiService.getMedications();
const wireMeds = await apiService.getMedications();
// Decrypt each opaque blob into domain Medication objects.
const medications = await Promise.all(
wireMeds.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
return {
id: w.id,
medication_id: w.medication_id,
user_id: w.user_id,
profile_id: w.profile_id,
name: (data.name as string) ?? '',
dosage: (data.dosage as string) ?? '',
frequency: (data.frequency as string) ?? '',
route: data.route as string | undefined,
reason: data.reason as string | undefined,
instructions: data.instructions as string | undefined,
side_effects: data.sideEffects as string[] | undefined,
prescribed_by: data.prescribedBy as string | undefined,
prescribed_date: data.prescribedDate as string | undefined,
start_date: data.startDate as string | undefined,
end_date: data.endDate as string | undefined,
notes: data.notes as string | undefined,
tags: data.tags as string[] | undefined,
active: w.active,
created_at: w.created_at,
updated_at: w.updated_at,
} as Medication;
}),
);
set({ medications, isLoading: false });
} catch (error: any) {
set({
@ -228,9 +278,42 @@ export const useMedicationStore = create<MedicationState>()(
},
createMedication: async (data: any) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
set({ isLoading: true, error: null });
try {
const medication = await apiService.createMedication(data);
// Encrypt the domain fields into an opaque blob.
const { name, dosage, frequency, route, reason, instructions,
side_effects, prescribed_by, prescribed_date, start_date,
end_date, notes, tags, ...rest } = data;
const encrypted_data = await encryptJson({
name, dosage, frequency, route, reason, instructions,
sideEffects: side_effects, prescribedBy: prescribed_by,
prescribedDate: prescribed_date, startDate: start_date,
endDate: end_date, notes, tags,
}, key);
const wire = await apiService.createMedication({
profile_id: data.profile_id,
encrypted_data,
active: data.active,
});
// Decrypt the response back into a domain Medication for the store.
const decrypted = await decryptJson<Record<string, unknown>>(wire.encrypted_data, key);
const medication = {
id: wire.id,
medication_id: wire.medication_id,
user_id: wire.user_id,
profile_id: wire.profile_id,
name: (decrypted.name as string) ?? '',
dosage: (decrypted.dosage as string) ?? '',
frequency: (decrypted.frequency as string) ?? '',
active: wire.active,
created_at: wire.created_at,
updated_at: wire.updated_at,
} as Medication;
set((state) => ({
medications: [...state.medications, medication],
isLoading: false,
@ -245,12 +328,33 @@ export const useMedicationStore = create<MedicationState>()(
},
updateMedication: async (id: string, data: any) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
set({ isLoading: true, error: null });
try {
const updated = await apiService.updateMedication(id, data);
// Encrypt the full updated domain record into a new blob.
const { name, dosage, frequency, route, reason, instructions,
side_effects, prescribed_by, prescribed_date, start_date,
end_date, notes, tags } = data;
const encrypted_data = await encryptJson({
name, dosage, frequency, route, reason, instructions,
sideEffects: side_effects, prescribedBy: prescribed_by,
prescribedDate: prescribed_date, startDate: start_date,
endDate: end_date, notes, tags,
}, key);
const wire = await apiService.updateMedication(id, {
encrypted_data,
active: data.active,
});
// Update the domain medication in the store.
set((state) => ({
medications: state.medications.map((med) =>
med.medication_id === id ? updated : med
med.medication_id === id
? { ...med, ...data, updated_at: wire.updated_at }
: med,
),
isLoading: false,
}));
@ -451,9 +555,30 @@ export const useProfileStore = create<ProfileState>()(
error: null,
loadProfile: async () => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const profile = await apiService.getProfile();
const wire = await apiService.getProfile();
// Decrypt the profile name.
let name = '';
if (wire.name_data && wire.name_iv) {
try {
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
} catch { name = ''; }
}
const profile: Profile = {
profile_id: wire.profile_id,
user_id: wire.user_id,
name,
role: wire.role,
permissions: wire.permissions,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
set({ profile, isLoading: false });
} catch (error: any) {
set({
@ -464,9 +589,28 @@ export const useProfileStore = create<ProfileState>()(
},
updateName: async (name) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
set({ isLoading: true, error: null });
try {
const profile = await apiService.updateProfileName(name);
const blob = await encryptJson(name, key);
const wire = await apiService.updateProfileName(blob.data, blob.iv);
let decryptedName = name;
try {
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
} catch { /* keep the input name */ }
const profile: Profile = {
profile_id: wire.profile_id,
user_id: wire.user_id,
name: decryptedName,
role: wire.role,
permissions: wire.permissions,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
set({ profile, isLoading: false });
} catch (error: any) {
set({
@ -489,9 +633,36 @@ export const useAppointmentStore = create<AppointmentState>()(
error: null,
loadAppointments: async (status) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const appointments = await apiService.getAppointments(status);
const wireAppts = await apiService.getAppointments(status);
const appointments = await Promise.all(
wireAppts.map(async (w) => {
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
return {
id: w.id,
appointment_id: w.appointment_id,
user_id: w.user_id,
profile_id: w.profile_id,
title: (data.title as string) ?? '',
provider: (data.provider as string) ?? '',
appointment_type: (data.appointmentType as string) ?? '',
date_time: (data.dateTime as string) ?? '',
location: data.location as string | undefined,
duration_minutes: data.durationMinutes as number | undefined,
reason: data.reason as string | undefined,
notes: data.notes as string | undefined,
status: w.status,
created_at: w.created_at,
updated_at: w.updated_at,
} as Appointment;
}),
);
set({ appointments, isLoading: false });
} catch (error: any) {
set({
@ -502,11 +673,40 @@ export const useAppointmentStore = create<AppointmentState>()(
},
createAppointment: async (data) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
set({ isLoading: true, error: null });
try {
const appt = await apiService.createAppointment(data);
const d = data as any;
const { title, provider, appointment_type, date_time, location,
duration_minutes, reason, notes } = d;
const encrypted_data = await encryptJson({
title, provider, appointmentType: appointment_type,
dateTime: date_time, location, durationMinutes: duration_minutes,
reason, notes,
}, key);
const wire = await apiService.createAppointment({
profile_id: d.profile_id,
encrypted_data,
status: d.status,
});
// Build the domain Appointment for the store.
const appointment: Appointment = {
appointment_id: wire.appointment_id,
id: wire.id,
user_id: wire.user_id,
profile_id: wire.profile_id,
title, provider, appointment_type, date_time,
location, duration_minutes, reason, notes,
status: wire.status,
created_at: wire.created_at,
updated_at: wire.updated_at,
};
set((state) => ({
appointments: [...state.appointments, appt],
appointments: [...state.appointments, appointment],
isLoading: false,
}));
} catch (error: any) {
@ -519,11 +719,29 @@ export const useAppointmentStore = create<AppointmentState>()(
},
updateAppointment: async (id, data) => {
const key = getEncKey();
if (!key) {
set({ error: 'No encryption key — cannot encrypt data' });
throw new Error('No encryption key');
}
try {
const updated = await apiService.updateAppointment(id, data);
const d = data as any;
const { title, provider, appointment_type, date_time, location,
duration_minutes, reason, notes } = d;
const encrypted_data = await encryptJson({
title, provider, appointmentType: appointment_type,
dateTime: date_time, location, durationMinutes: duration_minutes,
reason, notes,
}, key);
const wire = await apiService.updateAppointment(id, {
encrypted_data,
status: d.status,
});
set((state) => ({
appointments: state.appointments.map((a) =>
a.appointment_id === id ? updated : a,
a.appointment_id === id
? { ...a, ...data, status: wire.status, updated_at: wire.updated_at }
: a,
),
}));
} catch (error: any) {

View file

@ -110,6 +110,14 @@ export interface PillIdentification {
custom_color?: string;
}
// ---- Opaque encrypted blob wire types (zero-knowledge) ----
export interface EncryptedFieldWire {
data: string;
iv: string;
auth_tag?: string;
}
// ---- Medication: domain type (decrypted, used by UI) ----
export interface Medication {
id?: string;
medication_id?: string;
@ -129,39 +137,32 @@ export interface Medication {
notes?: string;
tags?: string[];
active: boolean;
pill_identification?: PillIdentification;
created_at?: string;
updated_at?: string;
}
export interface CreateMedicationRequest {
name: string;
dosage: string;
frequency: string;
route: string;
// Medication wire response (opaque blob from the server)
export interface MedicationWireResponse {
id: string;
medication_id: string;
user_id: string;
profile_id: string;
reason?: string;
instructions?: string;
side_effects?: string[];
prescribed_by?: string;
prescribed_date?: string;
start_date?: string;
end_date?: string;
notes?: string;
tags?: string[];
reminder_times?: string[];
pill_identification?: PillIdentification;
active: boolean;
encrypted_data: EncryptedFieldWire;
created_at: string;
updated_at: string;
}
// Medication create/update requests (send encrypted blob to the server)
export interface CreateMedicationRequest {
profile_id: string;
encrypted_data: EncryptedFieldWire;
active?: boolean;
}
export interface UpdateMedicationRequest {
name?: string;
dosage?: string;
frequency?: string;
start_date?: string;
end_date?: string;
instructions?: string;
encrypted_data?: EncryptedFieldWire;
active?: boolean;
pill_identification?: PillIdentification;
}
// Drug Interaction Types (Phase 2.8)
@ -244,7 +245,7 @@ export interface LabResult {
created_at?: string;
}
// Appointment Types — match backend AppointmentResponse (snake_case).
// Appointment: domain type (decrypted, used by UI)
export interface Appointment {
id?: string;
appointment_id: string;
@ -263,31 +264,41 @@ export interface Appointment {
updated_at?: string;
}
export interface CreateAppointmentRequest {
title: string;
provider: string;
appointment_type: string;
date_time: string;
// Appointment wire response (opaque blob)
export interface AppointmentWireResponse {
id: string;
appointment_id: string;
user_id: string;
profile_id: string;
location?: string;
duration_minutes?: number;
reason?: string;
notes?: string;
status: string;
encrypted_data: EncryptedFieldWire;
created_at: string;
updated_at: string;
}
export interface CreateAppointmentRequest {
profile_id: string;
encrypted_data: EncryptedFieldWire;
status?: string;
}
export interface UpdateAppointmentRequest {
title?: string;
provider?: string;
appointment_type?: string;
date_time?: string;
location?: string;
duration_minutes?: number;
reason?: string;
notes?: string;
encrypted_data?: EncryptedFieldWire;
status?: string;
}
// Profile wire response (opaque encrypted name)
export interface ProfileWireResponse {
profile_id: string;
user_id: string;
name_data: string;
name_iv: string;
role: string;
permissions: string[];
created_at: string;
updated_at: string;
}
// Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog {
id?: string;