feat: profile sharing via X25519 envelope (Phase B, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s

An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

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

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
This commit is contained in:
goose 2026-07-19 07:21:11 -03:00
parent 015a99a7fe
commit 04520539aa
25 changed files with 1875 additions and 953 deletions

View file

@ -16,6 +16,7 @@ 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 { ProfileSharing } from './ProfileSharing';
export const ProfileEditor: FC = () => {
const {
@ -150,7 +151,11 @@ export const ProfileEditor: FC = () => {
) : (
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="h6">{active.name || user?.username || '—'}</Typography>
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
{active.is_shared ? (
<Chip size="small" label="shared with you" color="info" variant="outlined" />
) : (
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
)}
</Stack>
)}
</Box>
@ -198,25 +203,44 @@ export const ProfileEditor: FC = () => {
)}
</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>
{active.is_shared ? (
<Box>
<Typography variant="caption" color="text.secondary">Owner</Typography>
<Typography variant="body2">{active.owner_account_id}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
You have read-only access to this shared profile.
</Typography>
</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>
</>
)}
</>
)}
{/* Owner-only sharing UI. */}
{!editing && !active.is_shared && (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider' }}>
<ProfileSharing profileId={active.profile_id} />
</Box>
)}
</Stack>
</CardContent>
</Card>

View file

@ -0,0 +1,140 @@
import { useEffect, useState, type FC } from 'react';
import {
Box,
Button,
CircularProgress,
Alert,
IconButton,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Stack,
TextField,
Typography,
} from '@mui/material';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import PersonRemoveIcon from '@mui/icons-material/PersonRemove';
import { useProfileStore } from '../../store/useStore';
/** Owner-side sharing UI for a single owned profile: lists the recipients the
* profile is shared with (with revoke buttons) and an "add recipient by
* email" field. Shown only for profiles the current user owns. Recipients
* get the profile via /profiles/shared-with-me + the ECDH-wrapped DEK. */
export const ProfileSharing: FC<{ profileId: string }> = ({ profileId }) => {
const {
profileShares,
loadProfileShares,
shareProfile,
revokeShare,
error,
clearError,
} = useProfileStore();
const [recipientEmail, setRecipientEmail] = useState('');
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState('');
useEffect(() => {
loadProfileShares(profileId);
}, [profileId, loadProfileShares]);
const shares = profileShares[profileId] ?? [];
const handleAdd = async () => {
setBusy(true);
setLocalError('');
try {
await shareProfile(profileId, recipientEmail.trim());
setRecipientEmail('');
} catch (e: any) {
setLocalError(e?.message || 'Failed to share');
} finally {
setBusy(false);
}
};
const handleRevoke = async (recipientUserId: string) => {
if (!confirm('Revoke this share? The recipient will lose access immediately.')) return;
setBusy(true);
try {
await revokeShare(profileId, recipientUserId);
} catch {
/* surfaced via store error */
} finally {
setBusy(false);
}
};
return (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Shared with
</Typography>
{(error || localError) && (
<Alert
severity="error"
sx={{ mb: 2 }}
onClose={() => {
setLocalError('');
clearError();
}}
>
{localError || error}
</Alert>
)}
{shares.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Not shared with anyone.
</Typography>
) : (
<List dense sx={{ mb: 2, bgcolor: 'background.paper', borderRadius: 1 }}>
{shares.map((s) => (
<ListItem key={s.recipient_user_id}>
<ListItemText
primary={s.recipient_email || s.recipient_user_id}
secondary={s.permissions.join(', ')}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="revoke"
disabled={busy}
onClick={() => handleRevoke(s.recipient_user_id)}
>
<PersonRemoveIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
<Stack direction="row" spacing={1} alignItems="center">
<TextField
size="small"
fullWidth
type="email"
placeholder="recipient@example.com"
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
/>
<Button
variant="contained"
startIcon={<PersonAddIcon />}
disabled={busy || !recipientEmail.trim()}
onClick={handleAdd}
>
{busy ? <CircularProgress size={24} /> : 'Share'}
</Button>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
The recipient must have a Normogen account. They'll see this profile in their switcher.
</Typography>
</Box>
);
};
export default ProfileSharing;

View file

@ -1,17 +1,23 @@
import { useEffect, type FC } from 'react';
import { Box, CircularProgress, MenuItem, Select, Typography } from '@mui/material';
import { Box, Chip, 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. */
* + `loadSharedWithMe` on mount so owned AND shared profiles are ready. */
export const ProfileSwitcher: FC = () => {
const { profiles, activeProfileId, isLoading, loadProfiles, setActiveProfile } =
const { profiles, activeProfileId, isLoading, loadProfiles, loadSharedWithMe, setActiveProfile } =
useProfileStore();
useEffect(() => {
loadProfiles();
}, [loadProfiles]);
(async () => {
await loadProfiles();
// Fetch profiles shared TO me after owned profiles are loaded (the
// identity private key must be unwrapped first; loadSharedWithMe is a
// no-op if it isn't available yet).
await loadSharedWithMe();
})();
}, [loadProfiles, loadSharedWithMe]);
if (profiles.length === 0) {
return isLoading ? (
@ -36,10 +42,21 @@ export const ProfileSwitcher: FC = () => {
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'.MuiSvgIcon-root': { color: 'common.white' },
}}
renderValue={(value) => {
const p = profiles.find((x) => x.profile_id === value);
return p ? (p.name || p.relationship || p.profile_id) : '';
}}
>
{profiles.map((p) => (
<MenuItem key={p.profile_id} value={p.profile_id}>
{p.name || p.relationship || p.profile_id}
<span>{p.name || p.relationship || p.profile_id}</span>
{p.is_shared && (
<Chip
size="small"
label="shared"
sx={{ ml: 1, height: 18, fontSize: '0.7rem' }}
/>
)}
</MenuItem>
))}
</Select>

View file

@ -20,6 +20,8 @@ import {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
setProfileDek,
getProfileDek,
clearProfileDeks,
@ -282,3 +284,52 @@ describe('per-profile DEK isolation (Phase A2)', () => {
expect(getProfileDek('profile_child')).toBeNull();
});
});
describe('profile sharing: X25519 envelope (Phase B)', () => {
it('owner wraps a profile DEK to a recipient, who unwraps it', async () => {
if (!(await x25519Supported())) {
console.log('[crypto] skipped (X25519 unsupported in this runtime)');
return;
}
// Two accounts, each with their own identity keypair.
const owner = await generateIdentityKeyPair();
const recipient = await generateIdentityKeyPair();
// A profile DEK the owner created (would normally be wrapped under the
// owner's account DEK too — irrelevant for this test).
const profileDek = await generateProfileDek();
// Owner wraps the profile DEK to the recipient's public key. Internally
// this derives a fresh ephemeral keypair + ECDH(recipient pub, ephemeral
// priv) and AES-GCM-wraps the DEK under the shared secret.
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipient.publicKey,
owner.privateKey, // not actually used by wrap (only recipient pub matters)
);
expect(envelope.ephemeralPublicKey).not.toBe('');
expect(envelope.wrappedProfileDek.data).not.toBe('');
// Recipient unwraps with their private key + the envelope's ephemeral pub.
const recoveredDek = await unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
recipient.privateKey,
);
expect(recoveredDek).toBeDefined();
// The recovered DEK encrypts/decrypts the same data the original did.
const data = await encrypt('shared-medication', profileDek);
expect(await decrypt(data, recoveredDek)).toBe('shared-medication');
// A *different* recipient's private key cannot unwrap the share.
const stranger = await generateIdentityKeyPair();
await expect(
unwrapProfileDekFromShare(
envelope.wrappedProfileDek,
envelope.ephemeralPublicKey,
stranger.privateKey,
),
).rejects.toThrow();
});
});

View file

@ -14,6 +14,9 @@ export {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
type SharedProfileDekEnvelope,
setEncKey,
getEncKey,
clearEncKey,

View file

@ -147,6 +147,97 @@ export async function unwrapProfileDek(
return unwrapDek(payload, accountDek);
}
// ---------------------------------------------------------------------------
// Profile sharing: X25519 envelope (Phase B).
//
// To share a profile, the owner generates a fresh ephemeral X25519 keypair,
// ECDH-derives a shared secret from (ephemeral private, recipient public),
// uses it as an AES-GCM wrapping key, and wraps the profile DEK. The server
// stores the wrapped DEK + the ephemeral public key (plaintext). The recipient
// ECDH-derives the same shared secret from (their private, ephemeral public)
// and unwraps the profile DEK. See docs/adr/multi-person-sharing.md §3.
// ---------------------------------------------------------------------------
/** Shape returned by the owner-side share wrapping. */
export interface SharedProfileDekEnvelope {
/** base64 ephemeral X25519 public key (send to the server plaintext). */
ephemeralPublicKey: string;
/** Profile DEK wrapped under the ECDH-derived key. */
wrappedProfileDek: CipherPayload;
}
/** Import a base64-raw X25519 public key for ECDH. */
async function importX25519Public(publicKeyB64: string): Promise<CryptoKey> {
const raw = Uint8Array.from(atob(publicKeyB64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey(
'raw',
raw as BufferSource,
{ name: 'ECDH', namedCurve: 'X25519' },
true,
[],
);
}
/** Owner: wrap a profile DEK to a recipient's identity public key. Generates
* a fresh ephemeral keypair per share. Requires the owner's identity private
* key (in-memory via getIdentityPrivate()). */
export async function wrapProfileDekToRecipient(
profileDek: CryptoKey,
recipientPublicKeyB64: string,
myIdentityPrivate: CryptoKey,
): Promise<SharedProfileDekEnvelope> {
// Fresh ephemeral keypair for this share (forward secrecy within its lifetime).
const ephemeral = (await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'X25519' },
true,
['deriveBits'],
)) as CryptoKeyPair;
const recipientPub = await importX25519Public(recipientPublicKeyB64);
// ECDH(ephemeral private, recipient public) -> shared secret -> AES-GCM key.
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: recipientPub },
ephemeral.privateKey,
256,
);
const wrapKey = await crypto.subtle.importKey(
'raw',
sharedBits,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
const wrappedProfileDek = await wrapDek(profileDek, wrapKey);
const rawEphemeralPub = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
return {
ephemeralPublicKey: base64(new Uint8Array(rawEphemeralPub)),
wrappedProfileDek,
};
}
/** Recipient: unwrap a profile DEK from a share using the recipient's identity
* private key + the share's ephemeral public key. Inverse of
* wrapProfileDekToRecipient. Throws on tamper / wrong keys. */
export async function unwrapProfileDekFromShare(
wrapped: CipherPayload,
ephemeralPublicKeyB64: string,
myIdentityPrivate: CryptoKey,
): Promise<CryptoKey> {
const ephemeralPub = await importX25519Public(ephemeralPublicKeyB64);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: ephemeralPub },
myIdentityPrivate,
256,
);
const wrapKey = await crypto.subtle.importKey(
'raw',
sharedBits,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
return unwrapDek(wrapped, wrapKey);
}
// ---------------------------------------------------------------------------
// High-level flows
// ---------------------------------------------------------------------------

View file

@ -20,6 +20,10 @@ import {
ProfileWireResponse,
CreateProfileRequest,
UpdateProfileRequest,
SharedProfileWireResponse,
CreateProfileShareRequest,
ProfileShareListing,
PublicKeyResponse,
HealthStatWireResponse,
UpdateHealthStatRequest,
EncryptedFieldWire,
@ -260,6 +264,50 @@ class ApiService {
await this.client.delete(`/profiles/${profileId}`);
}
// ---- Profile sharing (Phase B: X25519 envelope) ----
/** Public endpoint — recipient identity public key lookup by email. */
async getUserPublicKey(email: string): Promise<PublicKeyResponse> {
const response = await this.client.get<PublicKeyResponse>('/users/public-key', {
params: { email },
});
return response.data;
}
/** Profiles shared TO the current user. Carries ECDH-wrapped profile DEKs. */
async listSharedWithMe(): Promise<SharedProfileWireResponse[]> {
const response = await this.client.get<SharedProfileWireResponse[]>(
'/profiles/shared-with-me',
);
return response.data;
}
/** Owner: list who a profile has been shared with. */
async listProfileShares(profileId: string): Promise<ProfileShareListing[]> {
const response = await this.client.get<ProfileShareListing[]>(
`/profiles/${profileId}/shares`,
);
return response.data;
}
/** Owner: share a profile. The wrapped DEK + ephemeral pubkey are produced
* client-side via wrapProfileDekToRecipient. */
async createProfileShare(
profileId: string,
req: CreateProfileShareRequest,
): Promise<ProfileShareListing> {
const response = await this.client.post<ProfileShareListing>(
`/profiles/${profileId}/shares`,
req,
);
return response.data;
}
/** Owner: revoke a share (soft revoke — server deletes the share record). */
async deleteProfileShare(profileId: string, recipientUserId: string): Promise<void> {
await this.client.delete(`/profiles/${profileId}/shares/${recipientUserId}`);
}
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
async getMedications(profileId?: string): Promise<MedicationWireResponse[]> {

View file

@ -12,6 +12,8 @@ import {
generateProfileDek,
wrapProfileDek,
unwrapProfileDek,
wrapProfileDekToRecipient,
unwrapProfileDekFromShare,
encrypt as encryptRaw,
decrypt as decryptRaw,
setEncKey,
@ -19,6 +21,7 @@ import {
clearIdentityPrivate,
clearProfileDeks,
setIdentityPrivate,
getIdentityPrivate,
getEncKey,
getActiveProfileDek,
getActiveProfileId,
@ -37,6 +40,7 @@ import {
DrugInteraction,
AdherenceStats,
Profile,
ProfileShareListing,
Appointment,
CreateAppointmentRequest,
UpdateAppointmentRequest,
@ -137,6 +141,18 @@ interface ProfileState {
relationship?: string;
}) => Promise<void>;
deleteProfile: (profileId: string) => Promise<void>;
/** Fetch profiles shared TO the current user and merge them into `profiles`,
* unwrapping each share's profile DEK via the recipient's identity private
* key. Called after loadProfiles on login/unlock. */
loadSharedWithMe: () => Promise<void>;
/** Owner: share a profile to a recipient by email. Fetches the recipient's
* identity public key, wraps the profile DEK to it via ECDH, and POSTs. */
shareProfile: (profileId: string, recipientEmail: string) => Promise<void>;
/** Owner: revoke a share (delete the share record server-side). */
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
/** Shares the current user has created for a profile (for the owner UI). */
profileShares: Record<string, ProfileShareListing[]>;
loadProfileShares: (profileId: string) => Promise<void>;
clearError: () => void;
}
@ -805,6 +821,7 @@ export const useProfileStore = create<ProfileState>()(
activeProfileId: null,
isLoading: false,
error: null,
profileShares: {},
loadProfiles: async () => {
const accountDek = getEncKey();
@ -980,6 +997,130 @@ export const useProfileStore = create<ProfileState>()(
}
},
loadSharedWithMe: async () => {
// Recipient path: profiles shared TO the current user. Each share's
// profile DEK is wrapped under an ECDH-derived key; unwrap it with the
// identity private key, then decrypt the display name under the profile
// DEK. Merge into the profiles list with is_shared = true.
const myIdentityPrivate = getIdentityPrivate();
if (!myIdentityPrivate) {
// No identity key unlocked — can't unwrap shared profile DEKs. Silently
// skip; the owned-profile list still loads.
return;
}
try {
const shared = await apiService.listSharedWithMe();
const sharedProfiles: Profile[] = [];
for (const s of shared) {
// Skip if we already have the DEK in memory (avoid re-unwrapping).
let profileDek = getProfileDek(s.profile_id);
if (!profileDek) {
try {
profileDek = await unwrapProfileDekFromShare(
{ data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv },
s.ephemeral_public_key,
myIdentityPrivate,
);
setProfileDek(s.profile_id, profileDek);
} catch {
continue; // couldn't unwrap — skip this share
}
}
let name = '';
if (s.name_data && s.name_iv && profileDek) {
try {
name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek);
} catch { name = ''; }
}
sharedProfiles.push({
profile_id: s.profile_id,
owner_account_id: s.owner_account_id,
name,
kind: s.kind,
relationship: s.relationship,
role: 'patient',
permissions: s.permissions,
created_at: s.created_at,
updated_at: s.created_at,
is_shared: true,
});
}
// Merge: replace any prior shared entries, keep owned ones intact.
const owned = get().profiles.filter((p) => !p.is_shared);
const merged = [...owned, ...sharedProfiles];
set({ profiles: merged });
} catch (error: any) {
// Non-fatal: owned profiles still work.
set({ error: error.message || 'Failed to load shared profiles' });
}
},
shareProfile: async (profileId, recipientEmail) => {
// Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST.
const myIdentityPrivate = getIdentityPrivate();
const profileDek = getProfileDek(profileId);
if (!myIdentityPrivate) {
set({ error: 'No identity key unlocked — cannot share' });
throw new Error('No identity key');
}
if (!profileDek) {
set({ error: 'No profile key — switch to the profile first' });
throw new Error('No profile key');
}
set({ isLoading: true, error: null });
try {
const { identity_public_key: recipientPub } =
await apiService.getUserPublicKey(recipientEmail);
const envelope = await wrapProfileDekToRecipient(
profileDek,
recipientPub,
myIdentityPrivate,
);
await apiService.createProfileShare(profileId, {
recipient_email: recipientEmail,
ephemeral_public_key: envelope.ephemeralPublicKey,
wrapped_profile_dek: envelope.wrappedProfileDek.data,
wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv,
permissions: ['read'],
});
// Refresh the share listing for this profile.
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to share profile',
isLoading: false,
});
throw error;
}
},
revokeShare: async (profileId, recipientUserId) => {
set({ isLoading: true, error: null });
try {
await apiService.deleteProfileShare(profileId, recipientUserId);
await get().loadProfileShares(profileId);
set({ isLoading: false });
} catch (error: any) {
set({
error: error.message || 'Failed to revoke share',
isLoading: false,
});
throw error;
}
},
loadProfileShares: async (profileId) => {
try {
const shares = await apiService.listProfileShares(profileId);
set((state) => ({
profileShares: { ...state.profileShares, [profileId]: shares },
}));
} catch (error: any) {
set({ error: error.message || 'Failed to load shares' });
}
},
clearError: () => set({ error: null }),
})),
);

View file

@ -371,6 +371,49 @@ export interface UpdateProfileRequest {
relationship?: string;
}
// A profile shared TO the current user (recipient view). Carries the
// per-share ECDH-wrapped DEK the recipient unwraps with their identity
// private key (not the account-wrapped form owned profiles use).
export interface SharedProfileWireResponse {
profile_id: string;
owner_account_id: string;
name_data: string;
name_iv: string;
kind: string;
relationship: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions: string[];
created_at: string;
}
// Request body for POST /profiles/:id/shares (owner wraps client-side).
export interface CreateProfileShareRequest {
recipient_email: string;
ephemeral_public_key: string;
wrapped_profile_dek: string;
wrapped_profile_dek_iv: string;
permissions?: string[];
expires_at?: string;
}
// A share the owner has created (listing view).
export interface ProfileShareListing {
profile_id: string;
recipient_user_id: string;
recipient_email: string;
permissions: string[];
created_at: string;
active: boolean;
}
// Response from GET /users/public-key?email=...
export interface PublicKeyResponse {
user_id: string;
identity_public_key: string;
}
// Dose Log Types — match backend MedicationDose (camelCase serialization).
export interface DoseLog {
id?: string;
@ -411,6 +454,10 @@ export interface Profile {
permissions: string[];
created_at: string;
updated_at: string;
/** True if this profile is shared TO the current user (recipient view).
* Undefined/false for profiles the user owns. Set by the store when merging
* shared-with-me results. */
is_shared?: boolean;
}
// Error Types