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
+22 -4
View File
@@ -23,10 +23,28 @@ repository (published as `temetro`).
> (login/signup/reset/onboarding), route protection, clinic switching, and patient data fetched
> over the API — the old in-memory fixture is gone (`frontend/lib/patients.ts` now calls the backend).
>
> **Still vision, not built:** the patient companion app and the blockchain-style **signing /
> patient-owned storage / approval** flow. The AI chat itself is still **mock replies** (no LLM
> call yet — a `/chat` endpoint is the next planned step). Email verification is wired but
> currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
> **Now built (thin slice):** a **patient wallet app** (`~/Desktop/temetro-app`, sibling repo — see
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
The **patient companion app** is its own git repo on the Desktop (not in this monorepo): an **Expo
SDK 56** app that **must be built with `@expo/ui`** (real native SwiftUI / Jetpack Compose UI) — this
is a hard requirement; see its `CLAUDE.md`. It stores the patient's record **encrypted on-device**,
the patient's identity is an **Ed25519 keypair** whose public key (base58check, `tmw_…`) is their
**wallet number**, and it shares records by sealing them to a clinic's ephemeral key over the
backend relay. The crypto wire format mirrors `backend/src/lib/wallet-crypto.ts` exactly. "Decentralization"
here means keys + data live on the patient's device and the relay only ever forwards ciphertext — it
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
deleted). Commit/push that app inside its own repo, separately from this one.
## Layout
+33
View File
@@ -0,0 +1,33 @@
CREATE TABLE "clinic_signing_keys" (
"organization_id" text PRIMARY KEY NOT NULL,
"algorithm" text DEFAULT 'ed25519' NOT NULL,
"public_key" text NOT NULL,
"fingerprint" text NOT NULL,
"private_key_enc" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"rotated_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "wallet_share_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"requested_by" text NOT NULL,
"wallet_number" text NOT NULL,
"ephemeral_pub_key" text NOT NULL,
"ephemeral_priv_enc" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"share_mode" text DEFAULT 'permanent' NOT NULL,
"share_expires_at" timestamp,
"draft" jsonb,
"committed_file_number" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"resolved_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "share_origin" text;--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "share_expires_at" timestamp;--> statement-breakpoint
ALTER TABLE "clinic_signing_keys" ADD CONSTRAINT "clinic_signing_keys_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_share_requests" ADD CONSTRAINT "wallet_share_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wallet_share_org_idx" ON "wallet_share_requests" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "wallet_share_wallet_idx" ON "wallet_share_requests" USING btree ("wallet_number");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -197,6 +197,13 @@
"when": 1781973588708,
"tag": "0027_romantic_kylun",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1782052852524,
"tag": "0028_military_cyclops",
"breakpoints": true
}
]
}
+18
View File
@@ -13,6 +13,9 @@
"@ai-sdk/google": "^3.0.82",
"@ai-sdk/openai": "^3.0.71",
"@ai-sdk/openai-compatible": "^2.0.50",
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@types/multer": "^2.1.0",
"ai": "^6.0.204",
"better-auth": "^1.6.13",
@@ -2183,6 +2186,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+3
View File
@@ -24,6 +24,9 @@
"@ai-sdk/google": "^3.0.82",
"@ai-sdk/openai": "^3.0.71",
"@ai-sdk/openai-compatible": "^2.0.50",
"@noble/ciphers": "^2.2.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@types/multer": "^2.1.0",
"ai": "^6.0.204",
"better-auth": "^1.6.13",
+2
View File
@@ -19,3 +19,5 @@ export * from "./attachments.js";
export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./wallet-share.js";
+5
View File
@@ -54,6 +54,11 @@ export const patients = pgTable(
// with auto-generated file numbers or placeholder fields) and are flagged
// for clinician review/edit.
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
// Provenance for records imported from a patient wallet app, plus the
// auto-delete deadline for a *temporary* share. When `shareExpiresAt` is set
// and passes, a scheduled sweep hard-deletes the row (services/wallet-share).
shareOrigin: text("share_origin").$type<"wallet">(),
shareExpiresAt: timestamp("share_expires_at"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
+21
View File
@@ -0,0 +1,21 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// One Ed25519 signing key per clinic (organization). The clinician's edits to a
// patient record are signed with this key so patients (and other clinics) can
// verify a change really came from this clinic before approving it. The private
// key is stored encrypted at rest (lib/crypto.ts `encryptSecret`); the public
// key + fingerprint are shown in Settings → Signing. Rotating replaces the row.
export const clinicSigningKeys = pgTable("clinic_signing_keys", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
algorithm: text("algorithm").notNull().default("ed25519"),
publicKey: text("public_key").notNull(),
fingerprint: text("fingerprint").notNull(),
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
privateKeyEnc: text("private_key_enc").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
rotatedAt: timestamp("rotated_at"),
});
+50
View File
@@ -0,0 +1,50 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import type { Patient } from "../../types/patient.js";
import { organization, user } from "./auth.js";
export type WalletShareStatus =
| "pending"
| "approved"
| "denied"
| "expired";
export type WalletShareMode = "permanent" | "temporary";
// One row per "import from a patient app" request. A clinician enters a wallet
// number; we mint a per-request ephemeral X25519 keypair (the phone seals the
// record bundle to its public key) and relay a `share:request` to the device
// over the /wallet Socket.io namespace. The patient approves on their phone; the
// sealed bundle comes back, we decrypt it with `ephemeralPrivEnc` and hand the
// clinic a draft Patient to review. Nothing here is the record itself — only the
// transient handshake + the decrypted draft cached until the clinic commits it.
export const walletShareRequests = pgTable(
"wallet_share_requests",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
requestedBy: text("requested_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
walletNumber: text("wallet_number").notNull(),
ephemeralPubKey: text("ephemeral_pub_key").notNull(),
// Encrypted (lib/crypto.ts) hex of the ephemeral X25519 private key.
ephemeralPrivEnc: text("ephemeral_priv_enc").notNull(),
status: text("status").$type<WalletShareStatus>().notNull().default("pending"),
shareMode: text("share_mode").$type<WalletShareMode>().notNull().default("permanent"),
// For temporary shares: when the imported record should be auto-deleted.
shareExpiresAt: timestamp("share_expires_at"),
// The decrypted, verified draft record, cached between approval and commit.
draft: jsonb("draft").$type<Patient | null>(),
// Set once the clinic commits the draft — the imported patient's file number,
// so a later patient "revoke" from the app can delete exactly that record.
committedFileNumber: text("committed_file_number"),
createdAt: timestamp("created_at").defaultNow().notNull(),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_share_org_idx").on(t.organizationId),
index("wallet_share_wallet_idx").on(t.walletNumber),
],
);
+17
View File
@@ -24,11 +24,14 @@ import { meetingsRouter } from "./routes/meetings.js";
import { notesRouter } from "./routes/notes.js";
import { notificationsRouter } from "./routes/notifications.js";
import { patientsRouter } from "./routes/patients.js";
import { patientsWalletRouter } from "./routes/patients-wallet.js";
import { portalRouter } from "./routes/portal.js";
import { prescriptionsRouter } from "./routes/prescriptions.js";
import { settingsRouter } from "./routes/settings.js";
import { signingRouter } from "./routes/signing.js";
import { staffRouter } from "./routes/staff.js";
import { tasksRouter } from "./routes/tasks.js";
import { sweepExpiredShares } from "./services/wallet-share.js";
const app = express();
@@ -67,7 +70,11 @@ app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
// Mount the wallet import routes BEFORE the generic patients router so
// `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`.
app.use("/api/patients/wallet", patientsWalletRouter);
app.use("/api/patients", patientsRouter);
app.use("/api/signing", signingRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
@@ -96,6 +103,14 @@ app.use(errorHandler);
const server = createServer(app);
initRealtime(server);
// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes.
const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000;
setInterval(() => {
sweepExpiredShares().catch((err) =>
console.error("Wallet share sweep failed:", err),
);
}, SHARE_SWEEP_INTERVAL).unref();
server.listen(env.PORT, () => {
console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`);
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
@@ -117,4 +132,6 @@ server.listen(env.PORT, () => {
console.log(` • chat: /api/chat (LLM agent)`);
console.log(` • integr.: /api/integrations (FHIR / e-Rx / claims)`);
console.log(` • portal: /api/portal (public clinic kiosk)`);
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
});
+202
View File
@@ -0,0 +1,202 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { sha256 } from "@noble/hashes/sha2.js";
import {
bytesToHex,
concatBytes,
hexToBytes,
randomBytes,
} from "@noble/hashes/utils.js";
// Cryptographic primitives shared (by convention — the wire format is mirrored
// in the mobile wallet app's src/lib/crypto.ts) between the clinic backend and
// the patient wallet. Identity is an Ed25519 keypair; the patient's public key,
// base58check-encoded with a `tmw_` prefix, is their human-typeable **wallet
// number**. Record bundles are sealed to a recipient's ephemeral X25519 key
// (sealed-box: ephemeral sender key + X25519 ECDH + XChaCha20-Poly1305) so the
// relay only ever forwards ciphertext, and signed with the wallet's Ed25519 key
// so the recipient can verify the bundle truly came from that wallet number.
//
// Both apps use @noble so the byte layout is identical on every platform.
export const WALLET_PREFIX = "tmw_";
const B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function base58Encode(bytes: Uint8Array): string {
let zeros = 0;
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
const digits: number[] = [];
for (let i = zeros; i < bytes.length; i++) {
let carry = bytes[i] as number;
for (let j = 0; j < digits.length; j++) {
carry += (digits[j] as number) << 8;
digits[j] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry > 0) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
let out = "1".repeat(zeros);
for (let i = digits.length - 1; i >= 0; i--) {
out += B58_ALPHABET[digits[i] as number];
}
return out;
}
function base58Decode(str: string): Uint8Array {
let zeros = 0;
while (zeros < str.length && str[zeros] === "1") zeros++;
const bytes: number[] = [];
for (let i = zeros; i < str.length; i++) {
const value = B58_ALPHABET.indexOf(str[i] as string);
if (value < 0) throw new Error("Invalid base58 character.");
let carry = value;
for (let j = 0; j < bytes.length; j++) {
carry += (bytes[j] as number) * 58;
bytes[j] = carry & 0xff;
carry >>= 8;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
const out = new Uint8Array(zeros + bytes.length);
for (let i = 0; i < bytes.length; i++) {
out[zeros + bytes.length - 1 - i] = bytes[i] as number;
}
return out;
}
function checksum(payload: Uint8Array): Uint8Array {
return sha256(sha256(payload)).slice(0, 4);
}
// --- Ed25519 identity -------------------------------------------------------
export function newSigningKeypair(): { privateKeyHex: string; publicKeyHex: string } {
const privateKey = ed25519.utils.randomSecretKey();
const publicKey = ed25519.getPublicKey(privateKey);
return {
privateKeyHex: bytesToHex(privateKey),
publicKeyHex: bytesToHex(publicKey),
};
}
export function signMessage(privateKeyHex: string, message: Uint8Array): string {
return bytesToHex(ed25519.sign(message, hexToBytes(privateKeyHex)));
}
export function verifySignature(
publicKey: Uint8Array,
signatureHex: string,
message: Uint8Array,
): boolean {
try {
return ed25519.verify(hexToBytes(signatureHex), message, publicKey);
} catch {
return false;
}
}
// `ed25519:9f86 d081 …` — a short, human-comparable fingerprint of a public key
// (first 16 bytes of its SHA-256, grouped in fours). Matches the panel format.
export function fingerprint(publicKey: Uint8Array): string {
const hex = bytesToHex(sha256(publicKey)).slice(0, 32);
const groups = hex.match(/.{1,4}/g) ?? [];
return `ed25519:${groups.join(" ")}`;
}
// --- Wallet number (base58check of the Ed25519 public key) ------------------
export function encodeWalletNumber(publicKey: Uint8Array): string {
const payload = concatBytes(publicKey, checksum(publicKey));
return WALLET_PREFIX + base58Encode(payload);
}
// Decode + validate a wallet number back to its 32-byte Ed25519 public key.
// Throws on a bad prefix, bad base58, wrong length, or checksum mismatch.
export function decodeWalletNumber(walletNumber: string): Uint8Array {
const trimmed = walletNumber.trim();
if (!trimmed.startsWith(WALLET_PREFIX)) {
throw new Error("Wallet number must start with tmw_.");
}
const decoded = base58Decode(trimmed.slice(WALLET_PREFIX.length));
if (decoded.length !== 36) {
throw new Error("Wallet number has an invalid length.");
}
const publicKey = decoded.slice(0, 32);
const check = decoded.slice(32);
const expected = checksum(publicKey);
if (check.some((b, i) => b !== expected[i])) {
throw new Error("Wallet number checksum mismatch (likely a typo).");
}
return publicKey;
}
export function isValidWalletNumber(walletNumber: string): boolean {
try {
decodeWalletNumber(walletNumber);
return true;
} catch {
return false;
}
}
// --- Sealed box (anonymous sender -> recipient X25519 public key) -----------
export function newEncryptionKeypair(): {
privateKeyHex: string;
publicKeyHex: string;
} {
const privateKey = x25519.utils.randomSecretKey();
const publicKey = x25519.getPublicKey(privateKey);
return {
privateKeyHex: bytesToHex(privateKey),
publicKeyHex: bytesToHex(publicKey),
};
}
function deriveKey(
shared: Uint8Array,
ephemeralPub: Uint8Array,
recipientPub: Uint8Array,
): Uint8Array {
return sha256(concatBytes(shared, ephemeralPub, recipientPub));
}
// Seal `plaintext` to `recipientPublicKeyHex` (X25519). Returns base64 of
// `ephemeralPub(32) || nonce(24) || ciphertext`.
export function seal(
recipientPublicKeyHex: string,
plaintext: Uint8Array,
): string {
const recipientPub = hexToBytes(recipientPublicKeyHex);
const ephemeralPriv = x25519.utils.randomSecretKey();
const ephemeralPub = x25519.getPublicKey(ephemeralPriv);
const shared = x25519.getSharedSecret(ephemeralPriv, recipientPub);
const key = deriveKey(shared, ephemeralPub, recipientPub);
const nonce = randomBytes(24);
const ciphertext = xchacha20poly1305(key, nonce).encrypt(plaintext);
return Buffer.from(concatBytes(ephemeralPub, nonce, ciphertext)).toString(
"base64",
);
}
export function open(
recipientPrivateKeyHex: string,
sealedBase64: string,
): Uint8Array {
const recipientPriv = hexToBytes(recipientPrivateKeyHex);
const recipientPub = x25519.getPublicKey(recipientPriv);
const blob = new Uint8Array(Buffer.from(sealedBase64, "base64"));
const ephemeralPub = blob.slice(0, 32);
const nonce = blob.slice(32, 56);
const ciphertext = blob.slice(56);
const shared = x25519.getSharedSecret(recipientPriv, ephemeralPub);
const key = deriveKey(shared, ephemeralPub, recipientPub);
return xchacha20poly1305(key, nonce).decrypt(ciphertext);
}
+116
View File
@@ -1,13 +1,16 @@
import type { Server as HttpServer } from "node:http";
import { fromNodeHeaders } from "better-auth/node";
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { Server, type Socket } from "socket.io";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import * as walletShare from "./services/wallet-share.js";
import type { MessageAttachment } from "./types/messaging.js";
let io: Server | null = null;
@@ -16,6 +19,7 @@ const userRoom = (userId: string) => `user:${userId}`;
const convRoom = (conversationId: string) => `conv:${conversationId}`;
const callRoom = (roomId: string) => `call:${roomId}`;
const orgRoom = (orgId: string) => `org:${orgId}`;
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
// past that the room is closed to new joiners.
@@ -39,6 +43,17 @@ export function emitToConversation(
io?.to(convRoom(conversationId)).emit(event, data);
}
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
// namespace, room keyed by wallet number). The relay only ever forwards
// ciphertext — it cannot read the record bundle.
export function emitToWallet(
walletNumber: string,
event: string,
data: unknown,
): void {
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
}
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
export function initRealtime(httpServer: HttpServer): Server {
@@ -270,5 +285,106 @@ export function initRealtime(httpServer: HttpServer): Server {
});
});
// --- Patient wallet relay (/wallet namespace) ----------------------------
// Devices have no clinic session, so this namespace is NOT cookie-gated.
// Instead a device proves control of its wallet keypair: the server issues a
// random challenge, the device signs it with its Ed25519 key, and only then
// may it join its own wallet room. The relay forwards encrypted share
// requests/responses without ever reading the record bundle.
const walletNs = io.of("/wallet");
walletNs.on("connection", (socket: Socket) => {
const challenge = bytesToHex(randomBytes(32));
socket.data.challenge = challenge;
socket.data.walletNumber = null as string | null;
socket.emit("wallet:challenge", { challenge });
socket.on(
"wallet:auth",
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
try {
const walletNumber = String(payload?.walletNumber ?? "");
const signature = String(payload?.signature ?? "");
const publicKey = decodeWalletNumber(walletNumber);
const ok = verifySignature(
publicKey,
signature,
utf8ToBytes(socket.data.challenge as string),
);
if (!ok) {
ack?.({ ok: false });
return;
}
socket.data.walletNumber = walletNumber;
socket.join(walletRoom(walletNumber));
ack?.({ ok: true });
} catch {
ack?.({ ok: false });
}
},
);
// The patient approved/denied a share on their device; the sealed bundle (if
// approved) rides along and is decrypted + verified server-side.
socket.on(
"wallet:share-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
sealed?: string;
signature?: string;
},
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const view = await walletShare.applyShareResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.sealed,
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
socket.on(
"wallet:revoke",
async (
payload: { requestId?: string; walletNumber?: string },
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
});
return io;
}
+131
View File
@@ -0,0 +1,131 @@
import { eq } from "drizzle-orm";
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { HttpError } from "../lib/http-error.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import { isReceptionOnly } from "../lib/role-scope.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { emitToWallet } from "../realtime.js";
import { recordActivity } from "../services/activity.js";
import * as patientService from "../services/patients.js";
import * as walletShare from "../services/wallet-share.js";
export const patientsWalletRouter = Router();
patientsWalletRouter.use(requireAuth, requireOrg);
const requestSchema = z.object({
walletNumber: z.string().trim().min(1),
mode: z.enum(["permanent", "temporary"]).default("permanent"),
durationHours: z.number().positive().max(8760).optional(),
});
// Start an import: validate the wallet number, mint a per-request ephemeral key,
// and relay an encrypted-share request to the patient's device. The clinician
// then polls the request until the patient approves on their phone.
patientsWalletRouter.post(
"/request-share",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = requestSchema.parse(req.body);
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
req.organizationId!,
req.user!.id,
input.walletNumber,
input.mode,
input.durationHours,
);
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, req.organizationId!));
emitToWallet(input.walletNumber, "wallet:share-request", {
requestId: view.id,
clinicName: org?.name ?? "A clinic",
requestedBy: req.user!.name,
ephemeralPubKey,
mode: input.mode,
durationHours: input.durationHours ?? null,
});
res.status(201).json(view);
} catch (err) {
next(err);
}
},
);
// Poll a request's status (and, once approved, the decrypted draft record).
patientsWalletRouter.get(
"/request-share/:id",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const view = await walletShare.getShareRequest(
req.organizationId!,
req.params.id as string,
);
if (!view) throw new HttpError(404, "Share request not found.");
res.json(view);
} catch (err) {
next(err);
}
},
);
// Commit the (possibly clinician-edited) draft into a real patient record. The
// temporary-share metadata (origin + auto-delete deadline) is taken from the
// request server-side, so the clinic can't quietly keep a temporary record.
patientsWalletRouter.post(
"/request-share/:id/commit",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const id = req.params.id as string;
const request = await walletShare.getShareRequest(req.organizationId!, id);
if (!request) throw new HttpError(404, "Share request not found.");
if (request.status !== "approved") {
throw new HttpError(409, "This share has not been approved yet.");
}
const input = patientInputSchema.parse(req.body);
const created = await patientService.createPatient(
req.organizationId!,
req.user!.id,
input,
isReceptionOnly(req.memberRole),
{
shareOrigin: "wallet",
shareExpiresAt: request.shareExpiresAt
? new Date(request.shareExpiresAt)
: null,
},
);
await walletShare.markCommitted(
req.organizationId!,
id,
created.fileNumber,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Imported patient ${created.name} from a wallet${
request.shareMode === "temporary" ? " (temporary)" : ""
}`,
entityType: "patient",
entityId: created.fileNumber,
patientName: created.name,
patientFileNumber: created.fileNumber,
});
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
+63
View File
@@ -0,0 +1,63 @@
import { Router } from "express";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as signing from "../services/signing.js";
import * as walletShare from "../services/wallet-share.js";
export const signingRouter = Router();
signingRouter.use(requireAuth, requireOrg);
// The clinic's Ed25519 signing key (public key + fingerprint). Created lazily on
// first read so the panel always shows a real key. Readable by any clinician.
signingRouter.get(
"/key",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await signing.getOrCreateKey(req.organizationId!));
} catch (err) {
next(err);
}
},
);
// Rotate the signing key — owner/admin only (gated on the org-update statement,
// which only owner/admin hold).
signingRouter.post(
"/key/rotate",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const key = await signing.rotateKey(req.organizationId!);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Rotated the clinic signing key",
entityType: "settings",
});
res.json(key);
} catch (err) {
next(err);
}
},
);
// Recent records shared from patient wallets — feeds the panel's shared-records
// list.
signingRouter.get(
"/records",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await walletShare.listShareRequests(req.organizationId!));
} catch (err) {
next(err);
}
},
);
+6 -2
View File
@@ -69,6 +69,7 @@ function toPatient(row: PatientRow, children: Children): Patient {
labTrend: row.labTrend,
encounters: children.encounters,
source: row.source,
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
};
}
@@ -426,6 +427,9 @@ export async function createPatient(
userId: string,
rawInput: PatientInput,
demographicsOnly = false,
// Extra columns set on import from a patient wallet (provenance + the
// auto-delete deadline for a temporary share).
extra?: { shareOrigin?: "wallet" | null; shareExpiresAt?: Date | null },
): Promise<Patient> {
// Auto-assign a file number when one wasn't supplied (e.g. AI imports).
const input: PatientInput = rawInput.fileNumber
@@ -438,13 +442,13 @@ export async function createPatient(
if (demographicsOnly) {
const [row] = await tx
.insert(patients)
.values(demographicColumns(orgId, input, userId))
.values({ ...demographicColumns(orgId, input, userId), ...extra })
.returning();
return toPatient(row!, emptyChildren());
}
const [row] = await tx
.insert(patients)
.values(patientColumns(orgId, input, userId))
.values({ ...patientColumns(orgId, input, userId), ...extra })
.returning();
await insertChildren(tx, row!.id, input);
return toPatient(row!, childrenFromInput(input));
+102
View File
@@ -0,0 +1,102 @@
import { hexToBytes } from "@noble/hashes/utils.js";
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { clinicSigningKeys } from "../db/schema/signing.js";
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
import {
fingerprint,
newSigningKeypair,
signMessage,
} from "../lib/wallet-crypto.js";
export type SigningKeyView = {
algorithm: string;
publicKey: string;
fingerprint: string;
createdAt: string;
rotatedAt: string | null;
};
type SigningKeyRow = typeof clinicSigningKeys.$inferSelect;
function toView(row: SigningKeyRow): SigningKeyView {
return {
algorithm: row.algorithm,
publicKey: row.publicKey,
fingerprint: row.fingerprint,
createdAt: row.createdAt.toISOString(),
rotatedAt: row.rotatedAt ? row.rotatedAt.toISOString() : null,
};
}
// Generate a fresh Ed25519 keypair and upsert it as the clinic's signing key
// (rotating overwrites the row and stamps `rotatedAt`). The private key is only
// ever stored encrypted (lib/crypto.ts).
async function mintKey(orgId: string, rotating: boolean): Promise<SigningKeyView> {
const { privateKeyHex, publicKeyHex } = newSigningKeypair();
const fp = fingerprint(hexToBytes(publicKeyHex));
const [row] = await db
.insert(clinicSigningKeys)
.values({
organizationId: orgId,
algorithm: "ed25519",
publicKey: publicKeyHex,
fingerprint: fp,
privateKeyEnc: encryptSecret(privateKeyHex),
rotatedAt: rotating ? new Date() : null,
})
.onConflictDoUpdate({
target: clinicSigningKeys.organizationId,
set: {
publicKey: publicKeyHex,
fingerprint: fp,
privateKeyEnc: encryptSecret(privateKeyHex),
rotatedAt: new Date(),
},
})
.returning();
return toView(row!);
}
// The clinic's signing key, creating one on first read so the panel always has
// a real key + fingerprint to show.
export async function getOrCreateKey(orgId: string): Promise<SigningKeyView> {
const [existing] = await db
.select()
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
if (existing) return toView(existing);
return mintKey(orgId, false);
}
export async function rotateKey(orgId: string): Promise<SigningKeyView> {
return mintKey(orgId, true);
}
// Sign a message with the clinic's signing key (creating one if needed). Returns
// the signature + public key so a verifier can check provenance.
export async function signWithClinicKey(
orgId: string,
message: Uint8Array,
): Promise<{ signature: string; publicKey: string }> {
const [row] = await db
.select()
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
if (!row) {
const view = await mintKey(orgId, false);
const [fresh] = await db
.select()
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
return {
signature: signMessage(decryptSecret(fresh!.privateKeyEnc), message),
publicKey: view.publicKey,
};
}
return {
signature: signMessage(decryptSecret(row.privateKeyEnc), message),
publicKey: row.publicKey,
};
}
+235
View File
@@ -0,0 +1,235 @@
import { utf8ToBytes } from "@noble/hashes/utils.js";
import { and, eq, isNotNull, lte } from "drizzle-orm";
import { db } from "../db/index.js";
import { patients } from "../db/schema/patients.js";
import {
walletShareRequests,
type WalletShareMode,
} from "../db/schema/wallet-share.js";
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
import { HttpError } from "../lib/http-error.js";
import {
decodeWalletNumber,
newEncryptionKeypair,
open,
verifySignature,
} from "../lib/wallet-crypto.js";
import type { Patient } from "../types/patient.js";
import { recordActivity } from "./activity.js";
import { deletePatient } from "./patients.js";
type ShareRow = typeof walletShareRequests.$inferSelect;
export type ShareRequestView = {
id: string;
walletNumber: string;
status: ShareRow["status"];
shareMode: WalletShareMode;
shareExpiresAt: string | null;
draft: Patient | null;
};
function toView(row: ShareRow): ShareRequestView {
return {
id: row.id,
walletNumber: row.walletNumber,
status: row.status,
shareMode: row.shareMode,
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
draft: row.draft ?? null,
};
}
// Create an import request: validate the wallet number, mint a per-request
// ephemeral X25519 keypair (the phone seals the bundle to its public key) and
// store the request. Returns the row + the ephemeral public key to relay to the
// device. Throws 400 on a malformed wallet number.
export async function createShareRequest(
orgId: string,
userId: string,
walletNumber: string,
mode: WalletShareMode,
durationHours?: number,
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
try {
decodeWalletNumber(walletNumber);
} catch (err) {
throw new HttpError(400, (err as Error).message);
}
const { privateKeyHex, publicKeyHex } = newEncryptionKeypair();
const shareExpiresAt =
mode === "temporary" && durationHours
? new Date(Date.now() + durationHours * 3_600_000)
: null;
const [row] = await db
.insert(walletShareRequests)
.values({
organizationId: orgId,
requestedBy: userId,
walletNumber: walletNumber.trim(),
ephemeralPubKey: publicKeyHex,
ephemeralPrivEnc: encryptSecret(privateKeyHex),
shareMode: mode,
shareExpiresAt,
})
.returning();
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
}
export async function getShareRequest(
orgId: string,
id: string,
): Promise<ShareRequestView | null> {
const [row] = await db
.select()
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.id, id),
eq(walletShareRequests.organizationId, orgId),
),
);
return row ? toView(row) : null;
}
// Recent import requests for the clinic — feeds the Signing panel's "Signed
// records" / shared-records list.
export async function listShareRequests(
orgId: string,
limit = 20,
): Promise<ShareRequestView[]> {
const rows = await db
.select()
.from(walletShareRequests)
.where(eq(walletShareRequests.organizationId, orgId))
.orderBy(walletShareRequests.createdAt)
.limit(limit);
return rows.map(toView);
}
// Apply a response relayed back from the patient's device. On approval we
// decrypt the sealed bundle with the request's ephemeral private key and verify
// the wallet's Ed25519 signature over it (provenance: it really came from that
// wallet number). Returns the resolved view, or null when the request is unknown
// / already resolved. Throws on a tampered/forged bundle.
export async function applyShareResponse(
requestId: string,
walletNumber: string,
decision: "approved" | "denied",
sealed?: string,
signatureHex?: string,
): Promise<ShareRequestView | null> {
const [row] = await db
.select()
.from(walletShareRequests)
.where(eq(walletShareRequests.id, requestId));
if (!row || row.status !== "pending") return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (decision === "denied") {
const [updated] = await db
.update(walletShareRequests)
.set({ status: "denied", resolvedAt: new Date() })
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
if (!sealed || !signatureHex) {
throw new HttpError(400, "Approval is missing the sealed record bundle.");
}
const plaintext = open(decryptSecret(row.ephemeralPrivEnc), sealed);
const publicKey = decodeWalletNumber(walletNumber);
if (!verifySignature(publicKey, signatureHex, plaintext)) {
throw new HttpError(400, "Bundle signature did not match the wallet number.");
}
const bundle = JSON.parse(Buffer.from(plaintext).toString("utf8")) as {
patient: Patient;
};
const [updated] = await db
.update(walletShareRequests)
.set({ status: "approved", resolvedAt: new Date(), draft: bundle.patient })
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
// Record the file number once a clinic commits the imported draft, so a later
// revoke from the device can delete exactly that record.
export async function markCommitted(
orgId: string,
id: string,
fileNumber: string,
): Promise<void> {
await db
.update(walletShareRequests)
.set({ committedFileNumber: fileNumber })
.where(
and(
eq(walletShareRequests.id, id),
eq(walletShareRequests.organizationId, orgId),
),
);
}
// Patient-initiated revoke from the app: find the committed import for this
// request + wallet and hard-delete that patient from the clinic. Returns the
// org/fileNumber deleted (for an activity entry), or null.
export async function revokeShare(
requestId: string,
walletNumber: string,
): Promise<{ orgId: string; fileNumber: string } | null> {
const [row] = await db
.select()
.from(walletShareRequests)
.where(eq(walletShareRequests.id, requestId));
if (!row || row.walletNumber !== walletNumber.trim()) return null;
if (!row.committedFileNumber) return null;
const ok = await deletePatient(row.organizationId, row.committedFileNumber);
if (!ok) return null;
await recordActivity({
orgId: row.organizationId,
actor: { id: row.requestedBy, name: "Patient wallet" },
action: `Patient revoked shared record #${row.committedFileNumber}`,
entityType: "patient",
entityId: row.committedFileNumber,
patientFileNumber: row.committedFileNumber,
}).catch(() => {});
return { orgId: row.organizationId, fileNumber: row.committedFileNumber };
}
// Deployment-wide sweep: hard-delete any temporarily-shared patient whose
// share window has passed. Called on an interval from index.ts.
export async function sweepExpiredShares(): Promise<number> {
const expired = await db
.select({
organizationId: patients.organizationId,
fileNumber: patients.fileNumber,
})
.from(patients)
.where(
and(
isNotNull(patients.shareExpiresAt),
lte(patients.shareExpiresAt, new Date()),
),
);
for (const p of expired) {
await deletePatient(p.organizationId, p.fileNumber);
await recordActivity({
orgId: p.organizationId,
actor: { id: "system", name: "temetro" },
action: `Temporary shared record #${p.fileNumber} expired and was deleted`,
entityType: "patient",
entityId: p.fileNumber,
patientFileNumber: p.fileNumber,
}).catch(() => {});
}
return expired.length;
}
// Build the canonical bytes a wallet signs / the clinic verifies for a bundle.
export function bundleBytes(patient: Patient): Uint8Array {
return utf8ToBytes(JSON.stringify({ patient }));
}
+3
View File
@@ -71,4 +71,7 @@ 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 this record was imported from a patient wallet as a *temporary*
// share — the ISO deadline after which it is auto-deleted from the clinic.
shareExpiresAt?: string | null;
};
@@ -0,0 +1,313 @@
"use client";
import { Check, Loader2, Smartphone, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ApiError } from "@/lib/api-client";
import {
commitWalletShare,
type Patient,
pollWalletShare,
requestWalletShare,
type WalletShareRequest,
} from "@/lib/patients";
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
type Phase =
| "form"
| "requesting"
| "waiting"
| "approved"
| "denied"
| "expired"
| "error";
const DURATIONS = [
{ hours: 1, key: "hours", count: 1 },
{ hours: 24, key: "days", count: 1 },
{ hours: 168, key: "days", count: 7 },
] as const;
const POLL_INTERVAL = 2500;
const POLL_TIMEOUT = 3 * 60 * 1000;
export function ImportFromWalletDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported?: (fileNumber: string) => void;
}) {
const { t } = useTranslation();
const [walletNumber, setWalletNumber] = useState("");
const [temporary, setTemporary] = useState(false);
const [durationHours, setDurationHours] = useState<number>(24);
const [phase, setPhase] = useState<Phase>("form");
const [error, setError] = useState<string | null>(null);
const [request, setRequest] = useState<WalletShareRequest | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = () => {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
};
// Reset everything whenever the dialog is (re)opened.
useEffect(() => {
if (open) {
setWalletNumber("");
setTemporary(false);
setDurationHours(24);
setPhase("form");
setError(null);
setRequest(null);
setReviewOpen(false);
}
return stopPolling;
}, [open]);
// Poll the request until the patient approves/denies on their device.
useEffect(() => {
if (phase !== "waiting" || !request) return;
const startedAt = Date.now();
pollTimer.current = setInterval(async () => {
try {
const next = await pollWalletShare(request.id);
if (next.status === "approved") {
stopPolling();
setRequest(next);
setPhase("approved");
} else if (next.status === "denied") {
stopPolling();
setPhase("denied");
} else if (
next.status === "expired" ||
Date.now() - startedAt > POLL_TIMEOUT
) {
stopPolling();
setPhase("expired");
}
} catch {
/* transient — keep polling until timeout */
if (Date.now() - startedAt > POLL_TIMEOUT) {
stopPolling();
setPhase("expired");
}
}
}, POLL_INTERVAL);
return stopPolling;
}, [phase, request]);
const sendRequest = async () => {
setPhase("requesting");
setError(null);
try {
const req = await requestWalletShare({
walletNumber: walletNumber.trim(),
mode: temporary ? "temporary" : "permanent",
durationHours: temporary ? durationHours : undefined,
});
setRequest(req);
setPhase("waiting");
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
setError(t("patients.importApp.invalidWallet"));
} else {
setError(t("patients.importApp.error"));
}
setPhase("error");
}
};
const commitDraft = async (record: Patient) => {
if (!request) return;
try {
const saved = await commitWalletShare(request.id, record);
setReviewOpen(false);
onOpenChange(false);
onImported?.(saved.fileNumber);
notify.success(
t("patients.importApp.savedTitle"),
t("patients.importApp.savedBody", { name: saved.name }),
);
} catch (err) {
notify.error(
t("patients.importApp.errorTitle"),
err instanceof Error ? err.message : t("patients.importApp.error"),
);
}
};
const durationLabel = (d: (typeof DURATIONS)[number]) =>
t(`patients.importApp.${d.key}`, { count: d.count });
return (
<>
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{t("patients.importApp.title")}
</DialogTitle>
<DialogDescription>
{t("patients.importApp.description")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-4">
{phase === "waiting" ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="font-medium text-sm">
{t("patients.importApp.waitingTitle")}
</p>
<p className="text-sm text-muted-foreground">
{t("patients.importApp.waitingBody")}
</p>
</div>
) : phase === "approved" ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<Check className="size-8 text-emerald-500" />
<p className="font-medium text-sm">
{t("patients.importApp.approvedTitle")}
</p>
<p className="text-sm text-muted-foreground">
{t("patients.importApp.approvedBody")}
</p>
<Button onClick={() => setReviewOpen(true)} type="button">
{t("patients.importApp.review")}
</Button>
</div>
) : phase === "denied" || phase === "expired" ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<X className="size-8 text-muted-foreground" />
<p className="font-medium text-sm">
{t(`patients.importApp.${phase}Title`)}
</p>
<p className="text-sm text-muted-foreground">
{t(`patients.importApp.${phase}Body`)}
</p>
</div>
) : (
<>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("patients.importApp.walletLabel")}
</span>
<Input
autoFocus
disabled={phase === "requesting"}
onChange={(e) => setWalletNumber(e.target.value)}
placeholder={t("patients.importApp.walletPlaceholder")}
value={walletNumber}
/>
</label>
<div className="flex items-start justify-between gap-4 rounded-2xl border border-border bg-card/30 p-3">
<div className="space-y-0.5">
<p className="font-medium text-sm">
{t("patients.importApp.tempLabel")}
</p>
<p className="text-xs text-muted-foreground">
{t("patients.importApp.tempHint")}
</p>
</div>
<Switch
checked={temporary}
disabled={phase === "requesting"}
onCheckedChange={(v) => setTemporary(v)}
/>
</div>
{temporary ? (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t("patients.importApp.durationLabel")}
</span>
<div className="flex gap-2">
{DURATIONS.map((d) => (
<Button
className={cn(
"flex-1 rounded-2xl",
durationHours !== d.hours &&
"bg-transparent text-foreground",
)}
key={d.hours}
onClick={() => setDurationHours(d.hours)}
size="sm"
type="button"
variant={
durationHours === d.hours ? "default" : "outline"
}
>
{durationLabel(d)}
</Button>
))}
</div>
</div>
) : null}
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
</>
)}
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{phase === "approved" || phase === "denied" || phase === "expired"
? t("patients.importApp.close")
: t("patients.importApp.cancel")}
</DialogClose>
{phase === "form" || phase === "requesting" || phase === "error" ? (
<Button
disabled={!walletNumber.trim() || phase === "requesting"}
onClick={sendRequest}
type="button"
>
{phase === "requesting"
? t("patients.importApp.requesting")
: t("patients.importApp.request")}
</Button>
) : null}
</DialogFooter>
</DialogPopup>
</Dialog>
{/* Review the shared record in the full patient form (review mode — the
form emits the draft, we commit it via the wallet-share endpoint). */}
{request?.draft ? (
<PatientFormDialog
mode="edit"
onDraft={commitDraft}
onOpenChange={setReviewOpen}
open={reviewOpen}
patient={request.draft}
/>
) : null}
</>
);
}
+26 -1
View File
@@ -1,12 +1,13 @@
"use client";
import { Plus, Search } from "lucide-react";
import { Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet-dialog";
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ export function PatientsView() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
const [addKey, setAddKey] = useState(0);
@@ -110,6 +112,15 @@ export function PatientsView() {
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => setImportOpen(true)}
type="button"
variant="outline"
>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</Button>
<Button
className="rounded-3xl"
onClick={() => {
@@ -188,6 +199,11 @@ export function PatientsView() {
<span className="flex items-center gap-2">
{p.name}
<AiBadge source={p.source} />
{p.shareExpiresAt ? (
<Badge variant="outline">
{t("patients.tempBadge")}
</Badge>
) : null}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
@@ -225,6 +241,15 @@ export function PatientsView() {
open={addOpen}
/>
<ImportFromWalletDialog
onImported={(fileNumber) => {
refresh();
open(fileNumber);
}}
onOpenChange={setImportOpen}
open={importOpen}
/>
<PatientDetailSheet
fileNumber={selected}
onOpenChange={(o) => {
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
@@ -11,9 +12,77 @@ import {
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import {
getSigningKey,
listSignedRecords,
rotateSigningKey,
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
if (!active) return;
setKey(k);
setRecords(r);
setError(null);
})
.catch(() => {
if (active) setError(t("settings.signing.loadError"));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [t]);
const rotate = async () => {
setRotating(true);
try {
const next = await rotateSigningKey();
setKey(next);
notify.success(
t("settings.signing.rotateSuccessTitle"),
t("settings.signing.rotateSuccessBody"),
);
} catch {
notify.error(
t("settings.signing.rotateErrorTitle"),
t("settings.signing.rotateError"),
);
} finally {
setRotating(false);
}
};
const recordStatusLabel = (status: SharedRecord["status"]): string =>
t(
`settings.signing.records.status${
status.charAt(0).toUpperCase() + status.slice(1)
}`,
);
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
@@ -29,15 +98,35 @@ export function SigningPanel() {
<p className="text-sm text-muted-foreground">
{t("settings.signing.keyDescription")}
</p>
<Button className={cn("rounded-lg", whiteButton)}>
{t("settings.signing.rotateKey")}
<Button
className={cn("rounded-lg", whiteButton)}
disabled={rotating || loading}
onClick={rotate}
type="button"
>
{rotating
? t("settings.signing.rotating")
: t("settings.signing.rotateKey")}
</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.createdAt")}
{key
? t("settings.signing.createdAtLabel", {
date: formatDate(key.createdAt),
})
: loading
? t("settings.signing.loading")
: error ?? ""}
</p>
{key?.rotatedAt ? (
<p className="text-xs text-muted-foreground">
{t("settings.signing.rotatedAtLabel", {
date: formatDate(key.rotatedAt),
})}
</p>
) : null}
</div>
</SettingsCard>
@@ -49,7 +138,7 @@ export function SigningPanel() {
<CopyField
description={t("settings.signing.fingerprintDescription")}
label={t("settings.signing.fingerprintLabel")}
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
value={key?.fingerprint ?? "—"}
/>
</SettingsCard>
</SettingsSection>
@@ -98,11 +187,41 @@ export function SigningPanel() {
description={t("settings.signing.signedRecordsDescription")}
title={t("settings.signing.signedRecordsTitle")}
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
{records.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{records.map((record) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={record.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate font-mono text-sm">
{record.walletNumber}
</p>
<p className="text-xs text-muted-foreground">
{record.shareMode === "temporary"
? t("settings.signing.records.temporary")
: t("settings.signing.records.permanent")}
{record.shareExpiresAt
? ` · ${t("settings.signing.records.expires", {
date: formatDate(record.shareExpiresAt),
})}`
: ""}
</p>
</div>
<Badge variant="secondary">
{recordStatusLabel(record.status)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
@@ -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");
}