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
@@ -19,6 +19,7 @@ import {
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { listWalletUpdates, type WalletUpdate } from "@/lib/wallet-updates";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
@@ -33,17 +34,23 @@ export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [updates, setUpdates] = useState<WalletUpdate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
Promise.all([
getSigningKey(),
listSignedRecords().catch(() => []),
listWalletUpdates().catch(() => []),
])
.then(([k, r, u]) => {
if (!active) return;
setKey(k);
setRecords(r);
setUpdates(u);
setError(null);
})
.catch(() => {
@@ -223,6 +230,40 @@ export function SigningPanel() {
</SettingsCard>
)}
</SettingsSection>
<SettingsSection
description={t("walletUpdatesList.description")}
title={t("walletUpdatesList.title")}
>
{updates.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("walletUpdatesList.none")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{updates.map((update) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={update.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm">
{update.changes.join(" · ") || `#${update.fileNumber}`}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
#{update.fileNumber} · {formatDate(update.createdAt)}
</p>
</div>
<Badge variant="secondary">
{t(`walletPush.status.${update.status}.title`)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
}