Files
temetro/backend/src/services/relay-client.ts
T
Khalid Abdi dd64d689c8 backend: Patient Portal over the relay + wallet linking
- services/portal.ts: clinic-scoped portal actions (clinic info, doctors,
  availability, linkWallet, conflict-checked booking, results, downloadable lab
  files) plus handlePortalRequest to dispatch a relayed portal:request.
- relay-client.ts: handle portal:request on the hub and ack the result back down
  the relay (device path identifies the patient by verified wallet number).
- patients: new nullable wallet_number column (+ migration); linking stores it,
  and walletNumberForPatient now resolves via it so pushes work after a portal
  link, not only after a permanent share.
- routes/portal.ts: GET /:clinic/link returns the relay-based pairing descriptor
  (clinic signing key + relay URL) for the QR — no more localhost-baked API URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:08:36 +03:00

250 lines
9.2 KiB
TypeScript

// Client connection to the Temetro Network relay
// (github.com/temetro/temetro-network), a standalone Rust service that routes
// encrypted wallet messages between this backend and patient phones.
//
// This backend was previously the device-facing Socket.io server itself (the
// `/wallet` namespace in realtime.ts). Now it is a *client* of the relay's
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
// their responses here, calling the same wallet service functions the old socket
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
// the relay only ever forwards ciphertext.
//
// The relay is **multi-clinic**: each clinic (organization) authenticates to
// `/hub` with its own Ed25519 signing key (a per-clinic identity, not a shared
// password), and the relay routes each device response back only to the clinic
// that originated the request. So this backend keeps **one hub connection per
// network-enabled org**, opened when the org joins the network ("Join Temetro
// Network" in Settings → Signing) and torn down when it leaves.
import { io as connect, type Socket } from "socket.io-client";
import { env } from "../env.js";
import { HttpError } from "../lib/http-error.js";
import { handlePortalRequest } from "./portal.js";
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
import * as walletShare from "./wallet-share.js";
import * as walletUpdates from "./wallet-updates.js";
// One authenticated hub connection per network-enabled organization.
const hubs = new Map<string, Socket>();
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
// Push an end-to-end-encrypted message to a patient wallet device via the given
// clinic's relay connection (the relay forwards it to the room keyed by wallet
// number). A no-op if the clinic isn't on the network / not connected yet — the
// device replays anything it missed on its next connect (see `wallet:online`).
export function sendToWallet(
orgId: string,
walletNumber: string,
event: string,
data: unknown,
): void {
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
}
// Tell the relay to expect a device response for `requestId` and route it back
// to this clinic — used by **QR pairing**, where there's no wallet number to
// `wallet:send` to yet, so nothing would otherwise register the request.
export function expectResponse(orgId: string, requestId: string): void {
hubs.get(orgId)?.emit("hub:expect", { requestId });
}
// Open (and authenticate) a hub connection for a clinic, if not already open.
// Idempotent — safe to call on startup, when an org joins the network, and
// before generating a pairing QR. The socket auto-reconnects on its own, so an
// existing entry is left as-is.
export async function connectOrg(orgId: string): Promise<void> {
if (hubs.has(orgId)) return;
const hub = connect(`${env.RELAY_URL}/hub`, {
transports: ["websocket"],
reconnection: true,
reconnectionDelayMax: 10_000,
});
hubs.set(orgId, hub);
registerHubHandlers(orgId, hub);
}
// Leave the network for a clinic: close and forget its hub connection.
export function disconnectOrg(orgId: string): void {
const hub = hubs.get(orgId);
if (!hub) return;
hub.disconnect();
hubs.delete(orgId);
}
// Open a hub connection for every clinic already on the network. Called once at
// startup; runtime joins/leaves go through connectOrg/disconnectOrg.
export async function initRelayClient(): Promise<void> {
try {
const orgs = await networkEnabledOrgs();
await Promise.all(orgs.map((orgId) => connectOrg(orgId)));
} catch (err) {
console.warn(`Temetro Network: failed to open hub connections: ${(err as Error).message}`);
}
}
// Wire up auth + device-response handlers for one clinic's hub socket.
function registerHubHandlers(orgId: string, hub: Socket): void {
// Authenticate by signing the relay's challenge with this clinic's signing
// key. `clinicId` is that key's public half (hex), which is how the relay
// identifies and routes to this clinic.
hub.on("hub:challenge", async (payload: { challenge?: string }) => {
const challenge = String(payload?.challenge ?? "");
if (!challenge) return;
try {
const { signature, publicKey } = await signWithClinicKey(
orgId,
new TextEncoder().encode(challenge),
);
hub.emit(
"hub:auth",
// `token` is only meaningful for a private relay (optional shared gate);
// an empty value is ignored by an open relay.
{ clinicId: publicKey, signature, token: env.RELAY_TOKEN || undefined },
async (ack: { ok?: boolean } | undefined) => {
if (!ack?.ok) {
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
return;
}
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
// The relay keeps routing state in memory, so re-register this clinic's
// still-pending requests — restores QR-pairing / share routing after a
// relay restart or a reconnect.
try {
for (const requestId of await walletShare.pendingRequestIds(orgId)) {
expectResponse(orgId, requestId);
}
} catch {
/* best-effort */
}
},
);
} catch (err) {
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
}
});
hub.on("connect_error", (err) => {
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}) for ${orgId}: ${err.message}`);
});
// A device authenticated on the relay — flush any record updates it missed
// while offline (scoped to this clinic; the relay only delivers this to
// clinics with pending work for the wallet).
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
const walletNumber = String(payload?.walletNumber ?? "");
if (!walletNumber) return;
try {
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
for (const row of rows) {
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
await walletUpdates.markDelivered(row.id);
}
} catch {
/* best-effort */
}
});
// The patient approved/denied a clinic→wallet record update. Verify the
// wallet's signature over the decision and resolve the row.
hub.on(
"wallet:update-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
signature?: string;
},
ack?: Ack,
) => {
try {
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; the sealed bundle (if approved) rides
// along and is decrypted + verified here.
hub.on(
"wallet:share-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
sealed?: string;
signature?: string;
},
ack?: Ack,
) => {
try {
const view = await walletShare.applyShareResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.sealed,
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// A wallet app made a Patient Portal request over the relay (book, view
// results, link, …). The relay forwards it here with the device's verified
// wallet number; we run the same portal logic the web kiosk uses and ack the
// result back down the relay to the device.
hub.on(
"portal:request",
async (
payload: {
action?: string;
payload?: Record<string, unknown>;
walletNumber?: string;
},
ack?: Ack,
) => {
try {
const data = await handlePortalRequest(orgId, {
action: String(payload?.action ?? ""),
payload: payload?.payload ?? {},
walletNumber: String(payload?.walletNumber ?? ""),
});
ack?.({ ok: true, data });
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
ack?.({ ok: false, error: (err as Error).message, status });
}
},
);
// The patient revoked a previously shared record; delete it from the clinic.
hub.on(
"wallet:revoke",
async (payload: { requestId?: string; walletNumber?: string }, ack?: Ack) => {
try {
const result = await walletShare.revokeShare(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
);
ack?.({ ok: !!result });
} catch {
ack?.({ ok: false });
}
},
);
}