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
+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);
}
},
);