mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 09:56:56 +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:
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
@@ -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)`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user