Files
temetro/backend/src/services/wallet-share.ts
T
Khalid Abdi 233ce9f854 fix: QR wallet pairing broken by multi-clinic routing (v0.8.1)
QR "scan to connect" pairing has no wallet number, so nothing registered
the request with the relay and the scanning device's response was rejected
("unknown or expired request"). The backend now pre-registers the pairing
request over the relay's new hub:expect event on POST /pair, and
re-registers still-pending requests on hub (re)connect so routing survives
a relay restart. /pair now requires the clinic to have joined the network
(clear 409, surfaced in the import dialog, localized in all five langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:16:33 +03:00

293 lines
9.5 KiB
TypeScript

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 a QR "scan to connect" pairing request — no wallet number yet (it is
// bound when the scanning device responds). Mints the ephemeral keypair the
// device seals to; the pairing URI (relay + id + ephemeral key) goes in the QR.
export async function createPairingRequest(
orgId: string,
userId: string,
mode: WalletShareMode,
durationHours?: number,
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
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: null,
ephemeralPubKey: publicKeyHex,
ephemeralPrivEnc: encryptSecret(privateKeyHex),
shareMode: mode,
shareExpiresAt,
})
.returning();
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
}
// 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);
}
// Ids of this clinic's still-pending share/pairing requests. Used to re-register
// them with the relay when the clinic's hub (re)connects (the relay keeps
// routing state in memory, so it's lost on a relay restart / redeploy).
export async function pendingRequestIds(orgId: string): Promise<string[]> {
const rows = await db
.select({ id: walletShareRequests.id })
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.organizationId, orgId),
eq(walletShareRequests.status, "pending"),
),
);
return rows.map((r) => r.id);
}
// 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;
// Typed flow: the responder must match the wallet the clinic addressed.
// QR pairing flow (stored wallet number is null): bind it to the
// authenticated responder now.
if (row.walletNumber && row.walletNumber !== walletNumber.trim()) return null;
if (decision === "denied") {
const [updated] = await db
.update(walletShareRequests)
.set({
status: "denied",
resolvedAt: new Date(),
walletNumber: walletNumber.trim(),
})
.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,
walletNumber: walletNumber.trim(),
})
.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 }));
}