mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user