feat: clinic→wallet record-update push

A clinician can push an updated record to a wallet-linked patient (permanent
share). The snapshot is signed with the clinic Ed25519 key and sealed to the
wallet's X25519 key — derived from its Ed25519 wallet number via the birational
map, verified byte-for-byte against the wallet's own derivation. Stored pending,
delivered over the /wallet relay live and on the wallet's next authenticated
connect (offline catch-up). The patient approves/denies in-app; the wallet signs
its decision, the backend verifies it, and the record is replaced only on
approval. Wallet pins the clinic key (TOFU) and warns on change.

Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper,
POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id,
wallet:update-request / wallet:update-response relay events.
Frontend: "Push to wallet" dialog with live status, wallet-link gating on the
patient sheet, "Sent updates" list under Settings → Signing, walletPush /
walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-03 18:57:38 +03:00
parent b29fdff1cb
commit bb536ba6da
23 changed files with 5593 additions and 5 deletions
+1
View File
@@ -21,3 +21,4 @@ export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
+53
View File
@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
export type WalletUpdateStatus =
| "pending"
| "delivered"
| "approved"
| "denied";
// One row per clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient they can push the updated record to the patient's app;
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
// the device is connected, and again on the wallet's next authenticated connect
// (so an offline phone still receives it). The patient reviews the change,
// verifies the clinic signature, and approves/denies in-app — only then is the
// on-device record replaced. The clinic polls `status` for delivery/approval.
export const walletRecordUpdates = pgTable(
"wallet_record_updates",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdBy: text("created_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
walletNumber: text("wallet_number").notNull(),
status: text("status")
.$type<WalletUpdateStatus>()
.notNull()
.default("pending"),
// base64 sealed box of the full updated patient snapshot (sealed to the
// wallet's derived X25519 key).
payloadSealed: text("payload_sealed").notNull(),
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
clinicSignature: text("clinic_signature").notNull(),
clinicPublicKey: text("clinic_public_key").notNull(),
clinicFingerprint: text("clinic_fingerprint").notNull(),
// Human-readable summary of what changed (shown in the wallet inbox).
changes: jsonb("changes").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_updates_org_idx").on(t.organizationId),
index("wallet_updates_wallet_idx").on(t.walletNumber),
],
);
+49
View File
@@ -0,0 +1,49 @@
import { bytesToHex } from "@noble/hashes/utils.js";
// Convert an Ed25519 public key to the matching X25519 (Montgomery) public key,
// so the clinic can `seal()` a record update to a wallet that only publishes an
// Ed25519 identity (its wallet number). The patient wallet derives the matching
// X25519 *private* key from its Ed25519 seed (SHA-512 clamp) to `open()` it —
// this file MUST stay byte-for-byte compatible with the wallet app's
// src/lib/crypto.ts. @noble/curves does not export edwardsToMontgomery in the
// pinned version, so the birational map u = (1 + y) / (1 - y) mod p is done here
// with BigInt. Verified: edPubToMontU(A) === x25519.getPublicKey(edClamp(seed)).
const P = 2n ** 255n - 19n;
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
let b = base % mod;
let e = exp;
while (e > 0n) {
if (e & 1n) result = (result * b) % mod;
b = (b * b) % mod;
e >>= 1n;
}
return result;
}
// Modular inverse via Fermat's little theorem (p is prime).
function inv(a: bigint): bigint {
return modpow(((a % P) + P) % P, P - 2n, P);
}
// Ed25519 public key (compressed, little-endian y with the x-sign in the high
// bit) → X25519 u-coordinate, returned as 32-byte little-endian hex.
export function ed25519PubToX25519Hex(edPub: Uint8Array): string {
if (edPub.length !== 32) throw new Error("Ed25519 public key must be 32 bytes.");
const bytes = edPub.slice();
bytes[31] = (bytes[31] as number) & 0x7f; // clear the x sign bit
let y = 0n;
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i] as number);
y %= P;
// u = (1 + y) / (1 - y) (mod p)
const u = ((1n + y) * inv((1n - y + P) % P)) % P;
const out = new Uint8Array(32);
let v = u;
for (let i = 0; i < 32; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return bytesToHex(out);
}
+46
View File
@@ -11,6 +11,7 @@ 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 * as walletUpdates from "./services/wallet-updates.js";
import type { MessageAttachment } from "./types/messaging.js";
let io: Server | null = null;
@@ -317,12 +318,57 @@ export function initRealtime(httpServer: HttpServer): Server {
socket.data.walletNumber = walletNumber;
socket.join(walletRoom(walletNumber));
ack?.({ ok: true });
// Deliver any record updates the device missed while offline. Sent
// after the ack so the client is ready to receive them.
void walletUpdates
.pendingUpdatesForWallet(walletNumber)
.then(async (rows) => {
for (const row of rows) {
socket.emit("wallet:update-request", await walletUpdates.toEvent(row));
await walletUpdates.markDelivered(row.id);
}
})
.catch(() => {});
} catch {
ack?.({ ok: false });
}
},
);
// The patient approved/denied a clinic→wallet record update on their device.
// We verify the wallet's signature over the decision and resolve the row.
socket.on(
"wallet:update-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
signature?: string;
},
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const view = await walletUpdates.applyUpdateResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient approved/denied a share on their device; the sealed bundle (if
// approved) rides along and is decrypted + verified server-side.
socket.on(
+89
View File
@@ -20,6 +20,7 @@ import { recordActivity } from "../services/activity.js";
import * as patientService from "../services/patients.js";
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
import * as walletShare from "../services/wallet-share.js";
import * as walletUpdates from "../services/wallet-updates.js";
export const patientsWalletRouter = Router();
@@ -138,6 +139,94 @@ patientsWalletRouter.get(
},
);
// --- Clinic → wallet record-update push ------------------------------------
// Whether a patient is linked to a wallet (drives the "Push update" button).
// Returns the wallet number when linked, 404 otherwise.
patientsWalletRouter.get(
"/link/:fileNumber",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const walletNumber = await walletUpdates.walletNumberForPatient(
req.organizationId!,
req.params.fileNumber as string,
);
if (!walletNumber) throw new HttpError(404, "Not wallet-linked.");
res.json({ walletNumber });
} catch (err) {
next(err);
}
},
);
const pushSchema = z.object({
fileNumber: z.string().trim().min(1),
changes: z.array(z.string().trim().min(1)).min(1).max(50),
});
// Push the current record snapshot to the linked wallet. Seals + signs it,
// stores it pending, and delivers live if the device is connected (it is also
// re-sent on the wallet's next connect). The patient must approve in-app.
patientsWalletRouter.post(
"/push",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = pushSchema.parse(req.body);
const row = await walletUpdates.createRecordUpdate(
req.organizationId!,
req.user!.id,
input.fileNumber,
input.changes,
);
const event = await walletUpdates.toEvent(row);
emitToWallet(row.walletNumber, "wallet:update-request", event);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Pushed a record update to a patient wallet (#${row.fileNumber})`,
entityType: "patient",
entityId: row.fileNumber,
patientFileNumber: row.fileNumber,
});
res.status(201).json(walletUpdates.viewOf(row));
} catch (err) {
next(err);
}
},
);
// The clinic's recent update pushes (Signing panel + status polling).
patientsWalletRouter.get(
"/updates",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await walletUpdates.listUpdates(req.organizationId!));
} catch (err) {
next(err);
}
},
);
patientsWalletRouter.get(
"/updates/:id",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const view = await walletUpdates.getUpdate(
req.organizationId!,
req.params.id as string,
);
if (!view) throw new HttpError(404, "Update 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.
+234
View File
@@ -0,0 +1,234 @@
import { hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { walletRecordUpdates } from "../db/schema/wallet-updates.js";
import { walletShareRequests } from "../db/schema/wallet-share.js";
import { HttpError } from "../lib/http-error.js";
import {
decodeWalletNumber,
fingerprint,
seal,
verifySignature,
} from "../lib/wallet-crypto.js";
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
import { getPatient } from "./patients.js";
import { signWithClinicKey } from "./signing.js";
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
// provenance (TOFU pin) before applying.
export type WalletUpdateEvent = {
requestId: string;
clinicName: string;
sealed: string;
signature: string;
clinicPublicKey: string;
fingerprint: string;
changes: string[];
createdAt: string;
};
// The clinic-facing view (no ciphertext) for the "Sent updates" list + polling.
export type WalletUpdateView = {
id: string;
fileNumber: string;
walletNumber: string;
status: UpdateRow["status"];
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
export function viewOf(row: UpdateRow): WalletUpdateView {
return toView(row);
}
function toView(row: UpdateRow): WalletUpdateView {
return {
id: row.id,
fileNumber: row.fileNumber,
walletNumber: row.walletNumber,
status: row.status,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
deliveredAt: row.deliveredAt ? row.deliveredAt.toISOString() : null,
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
};
}
// The wallet number a patient's record is linked to, or null when it isn't
// wallet-backed. Only *permanent, approved, committed* shares qualify —
// temporary shares auto-delete, so pushing an update to them is meaningless.
export async function walletNumberForPatient(
orgId: string,
fileNumber: string,
): Promise<string | null> {
const [row] = await db
.select({ walletNumber: walletShareRequests.walletNumber })
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.organizationId, orgId),
eq(walletShareRequests.committedFileNumber, fileNumber),
eq(walletShareRequests.status, "approved"),
eq(walletShareRequests.shareMode, "permanent"),
isNotNull(walletShareRequests.walletNumber),
),
)
.limit(1);
return row?.walletNumber ?? null;
}
// Compose, seal and sign a record-update push, and store it as pending. Loads
// the current patient snapshot, seals it to the wallet's derived X25519 key, and
// signs the plaintext bundle with the clinic's Ed25519 key. Returns the row.
export async function createRecordUpdate(
orgId: string,
userId: string,
fileNumber: string,
changes: string[],
): Promise<UpdateRow> {
const walletNumber = await walletNumberForPatient(orgId, fileNumber);
if (!walletNumber) {
throw new HttpError(409, "This patient is not linked to a wallet.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient`.
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
const sealed = seal(x25519Hex, bundle);
const [row] = await db
.insert(walletRecordUpdates)
.values({
organizationId: orgId,
createdBy: userId,
fileNumber,
walletNumber,
payloadSealed: sealed,
clinicSignature: signature,
clinicPublicKey: publicKey,
clinicFingerprint: fingerprint(hexToBytes(publicKey)),
changes,
})
.returning();
return row!;
}
// Build the wire event for a stored update row (joins the clinic name).
export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
clinicPublicKey: row.clinicPublicKey,
fingerprint: row.clinicFingerprint,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
};
}
// Every unresolved update for a wallet — re-sent on each authenticated connect
// so an offline device eventually receives what it missed.
export async function pendingUpdatesForWallet(
walletNumber: string,
): Promise<UpdateRow[]> {
return db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.walletNumber, walletNumber),
isNull(walletRecordUpdates.resolvedAt),
),
)
.orderBy(walletRecordUpdates.createdAt);
}
// Mark a pending update delivered (best-effort; only advances from pending).
export async function markDelivered(id: string): Promise<void> {
await db
.update(walletRecordUpdates)
.set({ status: "delivered", deliveredAt: new Date() })
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.status, "pending"),
),
);
}
// Apply the patient's decision relayed back from the wallet. Verifies the
// wallet's Ed25519 signature over `${decision}:${requestId}` (provenance) before
// resolving. Returns the resolved view, or null when unknown/already resolved.
export async function applyUpdateResponse(
requestId: string,
walletNumber: string,
decision: "approved" | "denied",
signatureHex?: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);
if (!verifySignature(publicKey, signatureHex, message)) {
throw new HttpError(400, "Response signature did not match the wallet.");
}
const [updated] = await db
.update(walletRecordUpdates)
.set({ status: decision, resolvedAt: new Date() })
.where(eq(walletRecordUpdates.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
// Recent update pushes for the clinic (Signing panel "Sent updates" list).
export async function listUpdates(
orgId: string,
limit = 30,
): Promise<WalletUpdateView[]> {
const rows = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.organizationId, orgId))
.orderBy(desc(walletRecordUpdates.createdAt))
.limit(limit);
return rows.map(toView);
}
export async function getUpdate(
orgId: string,
id: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.organizationId, orgId),
),
);
return row ? toView(row) : null;
}