mirror of
https://github.com/temetro/temetro.git
synced 2026-08-18 14:23:14 +00:00
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:
@@ -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,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user