feat: QR pairing for wallet import (backend + website)

- Backend: nullable wallet_number on wallet_share_requests (migration 0029);
  createPairingRequest + POST /api/patients/wallet/pair; applyShareResponse
  binds the authenticated wallet on QR (pairing) requests
- Frontend: Import dialog gains a "Show QR" mode rendering a temetro-pair QR
  (react-qr-code) the patient scans; requestWalletPairing helper; i18n keys

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-21 21:31:51 +03:00
parent 2d47abcc42
commit 7a359d4911
11 changed files with 4529 additions and 24 deletions
+4 -1
View File
@@ -27,7 +27,10 @@ export const walletShareRequests = pgTable(
requestedBy: text("requested_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
walletNumber: text("wallet_number").notNull(),
// Null for QR "scan to connect" pairing requests — the wallet number is
// bound when the authenticated device responds. Set up-front for the
// type-the-number push flow.
walletNumber: text("wallet_number"),
ephemeralPubKey: text("ephemeral_pub_key").notNull(),
// Encrypted (lib/crypto.ts) hex of the ephemeral X25519 private key.
ephemeralPrivEnc: text("ephemeral_priv_enc").notNull(),
+27
View File
@@ -27,6 +27,33 @@ const requestSchema = z.object({
durationHours: z.number().positive().max(8760).optional(),
});
const pairSchema = z.object({
mode: z.enum(["permanent", "temporary"]).default("permanent"),
durationHours: z.number().positive().max(8760).optional(),
});
// Create a QR pairing request (no wallet number yet). Returns the request id +
// the ephemeral public key the device seals its bundle to; the clinic encodes
// both — plus its own relay URL — into the QR the patient scans.
patientsWalletRouter.post(
"/pair",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = pairSchema.parse(req.body);
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
req.organizationId!,
req.user!.id,
input.mode,
input.durationHours,
);
res.status(201).json({ ...view, ephemeralPubKey });
} catch (err) {
next(err);
}
},
);
// 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.
+45 -4
View File
@@ -33,7 +33,7 @@ export type ShareRequestView = {
function toView(row: ShareRow): ShareRequestView {
return {
id: row.id,
walletNumber: row.walletNumber,
walletNumber: row.walletNumber ?? "",
status: row.status,
shareMode: row.shareMode,
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
@@ -41,6 +41,35 @@ function toView(row: ShareRow): ShareRequestView {
};
}
// 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
@@ -125,12 +154,19 @@ export async function applyShareResponse(
.from(walletShareRequests)
.where(eq(walletShareRequests.id, requestId));
if (!row || row.status !== "pending") return null;
if (row.walletNumber !== walletNumber.trim()) 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() })
.set({
status: "denied",
resolvedAt: new Date(),
walletNumber: walletNumber.trim(),
})
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;
@@ -150,7 +186,12 @@ export async function applyShareResponse(
const [updated] = await db
.update(walletShareRequests)
.set({ status: "approved", resolvedAt: new Date(), draft: bundle.patient })
.set({
status: "approved",
resolvedAt: new Date(),
draft: bundle.patient,
walletNumber: walletNumber.trim(),
})
.where(eq(walletShareRequests.id, requestId))
.returning();
return updated ? toView(updated) : null;