feat: patient wallet — real Signing, encrypted share relay, import-from-app

Backend
- clinic Ed25519 signing key (services/signing.ts, routes/signing.ts,
  clinic_signing_keys table) — Settings → Signing is now real
- @noble wallet-crypto (lib/wallet-crypto.ts): ed25519 identity, base58check
  wallet numbers, sealed-box (x25519 + xchacha20poly1305)
- /wallet Socket.io relay namespace (challenge-signed device auth) forwarding
  only ciphertext; emitToWallet helper
- import-from-app flow (routes/patients-wallet.ts, services/wallet-share.ts):
  request-share → patient approval → decrypt + verify → review draft → commit
- temporary shares: patients.share_expires_at + 5-min auto-delete sweep; revoke

Frontend
- SigningPanel wired to live key/fingerprint/rotate + shared-records list
- "Import from a patient app" dialog (lib/signing.ts, import-from-wallet-dialog)
  reusing the draft-review path; temporary badge on the patient list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-21 18:19:57 +03:00
parent b74d5d2d05
commit 2d47abcc42
25 changed files with 5951 additions and 16 deletions
@@ -180,6 +180,40 @@
"title": "Patients",
"searchPlaceholder": "Search name or MRN",
"add": "Add patient",
"newPatient": "New patient",
"importFromApp": "Import from a patient app",
"tempBadge": "Temporary",
"importApp": {
"title": "Import from a patient app",
"description": "Enter the patient's wallet number. They'll get a request on their phone to approve sharing their record with this clinic.",
"walletLabel": "Patient wallet number",
"walletPlaceholder": "tmw_…",
"tempLabel": "Share temporarily",
"tempHint": "The record is automatically deleted from this clinic when the share window ends.",
"durationLabel": "Share for",
"hours": "{{count}} hour",
"hours_other": "{{count}} hours",
"days": "{{count}} day",
"days_other": "{{count}} days",
"request": "Send request",
"requesting": "Sending…",
"waitingTitle": "Waiting for the patient",
"waitingBody": "We've sent a request to the wallet. Approve it on the patient's device to continue.",
"approvedTitle": "Patient approved",
"approvedBody": "Review the shared record, then save it to this clinic.",
"review": "Review record",
"deniedTitle": "Request declined",
"deniedBody": "The patient declined to share their record.",
"expiredTitle": "Request expired",
"expiredBody": "The request timed out before the patient responded.",
"invalidWallet": "That doesn't look like a valid wallet number.",
"errorTitle": "Couldn't reach the wallet",
"error": "Please check the wallet number and try again.",
"savedTitle": "Patient imported",
"savedBody": "{{name}} was added to this clinic.",
"cancel": "Cancel",
"close": "Close"
},
"loading": "Loading patients…",
"empty": "No patients found.",
"loadError": "Failed to load patients.",
@@ -1761,7 +1795,25 @@
"active": "Active",
"keyDescription": "Every change you make to a patient record is signed with this key, so patients can verify it came from you before approving it.",
"rotateKey": "Rotate key",
"rotating": "Rotating…",
"rotateSuccessTitle": "Signing key rotated",
"rotateSuccessBody": "A new Ed25519 key is now active for this clinic.",
"rotateErrorTitle": "Couldn't rotate the key",
"rotateError": "Please try again.",
"loadError": "Couldn't load the signing key.",
"loading": "Loading signing key…",
"createdAt": "Created May 28, 2026",
"createdAtLabel": "Created {{date}}",
"rotatedAtLabel": "Rotated {{date}}",
"records": {
"permanent": "Permanent",
"temporary": "Temporary",
"expires": "Expires {{date}}",
"statusPending": "Awaiting approval",
"statusApproved": "Shared",
"statusDenied": "Declined",
"statusExpired": "Expired"
},
"identityTitle": "Signing identity",
"identityDescription": "The public key patients use to verify your signatures",
"fingerprintLabel": "Public key fingerprint",
+56
View File
@@ -76,6 +76,9 @@ export type Patient = {
labTrend: Trend; // headline lab plotted as a sparkline
encounters: Encounter[];
source?: "manual" | "ai"; // "ai" = imported/drafted by the chat agent
// Set when imported from a patient wallet as a temporary share — the ISO
// deadline after which the record is auto-deleted from the clinic.
shareExpiresAt?: string | null;
};
// Fetch one patient in the active clinic. Returns null when not found (404).
@@ -180,3 +183,56 @@ export async function transferPatient(
export function generateFileNumber(): string {
return String(10000 + Math.floor(Math.random() * 89999));
}
// --- Import from a patient wallet app --------------------------------------
// A clinician enters a wallet number; we relay an encrypted-share request to the
// patient's device, the patient approves on their phone, and the decrypted draft
// record comes back for review before it's committed.
export type WalletShareMode = "permanent" | "temporary";
export type WalletShareRequest = {
id: string;
walletNumber: string;
status: "pending" | "approved" | "denied" | "expired";
shareMode: WalletShareMode;
shareExpiresAt: string | null;
draft: Patient | null;
};
// Start an import: relays a share request to the wallet and returns the pending
// request to poll. Throws ApiError(400) on a malformed wallet number.
export async function requestWalletShare(input: {
walletNumber: string;
mode: WalletShareMode;
durationHours?: number;
}): Promise<WalletShareRequest> {
return apiFetch<WalletShareRequest>("/api/patients/wallet/request-share", {
method: "POST",
body: JSON.stringify(input),
});
}
// Poll a request until the patient approves/denies on their device.
export async function pollWalletShare(
id: string,
): Promise<WalletShareRequest> {
return apiFetch<WalletShareRequest>(
`/api/patients/wallet/request-share/${encodeURIComponent(id)}`,
);
}
// Commit the (possibly clinician-edited) draft into a real patient record. The
// temporary-share deadline is applied server-side from the original request.
export async function commitWalletShare(
id: string,
patient: Patient,
): Promise<Patient> {
return apiFetch<Patient>(
`/api/patients/wallet/request-share/${encodeURIComponent(id)}/commit`,
{
method: "POST",
body: JSON.stringify(patient),
},
);
}
+40
View File
@@ -0,0 +1,40 @@
// Client for the clinic signing key (Settings → Signing) and the "import from a
// patient app" wallet-share flow. Both call the backend over the shared fetch
// wrapper (session cookie sent automatically).
import { apiFetch } from "@/lib/api-client";
export type SigningKey = {
algorithm: string;
publicKey: string;
fingerprint: string;
createdAt: string; // ISO
rotatedAt: string | null;
};
export type WalletShareMode = "permanent" | "temporary";
export type SharedRecord = {
id: string;
walletNumber: string;
status: "pending" | "approved" | "denied" | "expired";
shareMode: WalletShareMode;
shareExpiresAt: string | null;
// The draft is only returned by the request-share poll, not the list.
};
// The clinic's Ed25519 signing key. The backend creates one lazily on first
// read, so this always resolves to a real key + fingerprint.
export async function getSigningKey(): Promise<SigningKey> {
return apiFetch<SigningKey>("/api/signing/key");
}
// Rotate the signing key (owner/admin only). Returns the new key.
export async function rotateSigningKey(): Promise<SigningKey> {
return apiFetch<SigningKey>("/api/signing/key/rotate", { method: "POST" });
}
// Recent records shared from patient wallets — feeds the panel's list.
export async function listSignedRecords(): Promise<SharedRecord[]> {
return apiFetch<SharedRecord[]>("/api/signing/records");
}