From ef76afc3cade78d71546b3f5310aba73ed6b7047 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 5 Jul 2026 03:04:23 +0300 Subject: [PATCH] feat: route wallet traffic through the Temetro Network relay (v0.7.0) Devices no longer connect to the backend directly. The /wallet Socket.io namespace is removed from realtime.ts; a new services/relay-client.ts connects to the standalone Temetro Network relay's /hub namespace (RELAY_TOKEN-auth), emitToWallet delegates to its sendToWallet, and device responses + wallet:online replay are handled there via the same wallet-share/wallet-updates services. - Add RELAY_URL + RELAY_TOKEN env (env.ts, .env.example, docker-compose.yml); the wallet-import QR (resolveRelayUrl) now points at RELAY_URL. - Add socket.io-client dependency. - Document the Temetro Network folder/service in root + backend CLAUDE.md. - Bump root/backend/frontend to 0.7.0; CHANGELOG entry. The relay service itself lives in github.com/temetro/temetro-network. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 ++++ CLAUDE.md | 33 +++++- backend/.env.example | 19 +-- backend/CLAUDE.md | 7 ++ backend/docker-compose.yml | 5 + backend/package-lock.json | 62 +++++++++- backend/package.json | 3 +- backend/src/env.ts | 12 ++ backend/src/index.ts | 8 +- backend/src/realtime.ts | 162 ++------------------------ backend/src/routes/patients-wallet.ts | 7 +- backend/src/services/relay-client.ts | 137 ++++++++++++++++++++++ frontend/package.json | 2 +- package.json | 2 +- 14 files changed, 305 insertions(+), 175 deletions(-) create mode 100644 backend/src/services/relay-client.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7feff..66c762c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ for how releases are cut and published. ## [Unreleased] +## [0.7.0] — 2026-07-05 + +### Added +- **Temetro Network** — a standalone, high-performance **relay** (Rust + Axum + socketioxide) that + connects the backend to patient wallet apps, in its own repo + ([github.com/temetro/temetro-network](https://github.com/temetro/temetro-network)) and deployable + on Railway. It replaces the flaky Cloudflare quick-tunnel that used to expose the backend's + embedded `/wallet` Socket.io namespace to phones. The relay is a **dumb, stateless pipe**: a + `/wallet` namespace for devices (challenge/Ed25519-signature auth, room keyed by wallet number) + and a `RELAY_TOKEN`-authenticated `/hub` namespace for the backend. It forwards sealed ciphertext + verbatim, keeps no database, and its only crypto is verifying a device's auth signature (proven + byte-for-byte compatible with `wallet-crypto.ts`). + +### Changed +- **The backend is now a client of the relay, not the wallet server.** The `/wallet` Socket.io + namespace was removed from `src/realtime.ts`; a new `src/services/relay-client.ts` connects to the + relay's `/hub` (`emitToWallet` delegates to its `sendToWallet`), handles device responses + (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) and flushes missed updates on + `wallet:online` — calling the same `wallet-share` / `wallet-updates` services as before. New + `RELAY_URL` + `RELAY_TOKEN` env vars; the wallet-import QR now points at `RELAY_URL`. + ## [0.6.0] — 2026-07-04 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0c8fca0..b501963 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,13 @@ repository (published as `temetro`). > "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow: > clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`), > and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over -> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is -> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`. +> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record +> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push** +> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`. > -> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing, -> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**. -> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`). +> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary +> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not +> enforced** at sign-in (see `backend/CLAUDE.md`). ## Patient wallet app (sibling repo `~/Desktop/temetro-app`) @@ -47,6 +48,28 @@ here means keys + data live on the patient's device and the relay only ever forw is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be deleted). Commit/push that app inside its own repo, separately from this one. +## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`) + +The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the +Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not** +in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g. +on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's +embedded `/wallet` Socket.io namespace to phones. + +It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices +(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend +(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed +ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a +device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a +`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet` +now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo, +separately from this one. + +> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration +> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it +> in an accessible copy and mirror the tree in with `tar`; drive git there via +> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd. + ## Layout `frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged diff --git a/backend/.env.example b/backend/.env.example index 96923e8..6fcd4bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,13 +32,18 @@ POSTGRES_PORT=5432 BACKEND_PORT=4000 FRONTEND_PORT=3000 -# --- Patient wallet relay ------------------------------------------------- -# The URL baked into the QR a patient scans to import their record. Their phone -# must be able to reach it — so localhost will NOT work from a real device. If -# unset, the backend derives it from the request host (fine when you open the -# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it -# explicitly to a phone-reachable address: your machine's LAN IP or a public -# tunnel URL. +# --- Temetro Network relay ------------------------------------------------ +# The standalone relay (github.com/temetro/temetro-network) that connects this +# backend to patient phones. Deploy it (e.g. on Railway) and point both this +# backend and the wallet app at it. RELAY_URL is the relay's public URL (also +# baked into the QR a patient scans); RELAY_TOKEN is a shared secret this +# backend presents on the relay's /hub namespace — set the SAME value here and +# on the relay. Generate one with: openssl rand -base64 32 +RELAY_URL=http://localhost:8080 +RELAY_TOKEN= + +# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT +# using the Temetro Network relay. RELAY_URL takes precedence over this. # PUBLIC_RELAY_URL=http://192.168.1.20:4000 # --- Email (optional) ----------------------------------------------------- diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 25f527b..4d4b5db 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -60,6 +60,13 @@ No test runner is configured. Verify by running the stack (`docker compose up`) - **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via `emitToUser` / `emitToConversation` (no direct socket import, so no circular deps). +- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro + Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`). This backend connects to it + as a `/hub` client in **`src/services/relay-client.ts`**; `emitToWallet` (realtime.ts) delegates to + its `sendToWallet`, and device responses (`wallet:share-response` / `wallet:update-response` / + `wallet:revoke`) + `wallet:online` replay are handled there, calling the same `wallet-share` / + `wallet-updates` services the old `/wallet` namespace did. Configure with `RELAY_URL` + + `RELAY_TOKEN`. - **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset. ## Gotchas / conventions diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index c4524ee..3573a4d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -68,6 +68,11 @@ services: BETTER_AUTH_URL: http://localhost:4000 FRONTEND_URL: http://localhost:3000 PORT: "4000" + # Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL + # to your deployed relay's public URL and RELAY_TOKEN to the shared secret + # you configured on it — both are required for patient-wallet import. + RELAY_URL: ${RELAY_URL:-} + RELAY_TOKEN: ${RELAY_TOKEN:-} NODE_ENV: production # Uploaded patient/lab files live here, on the temetro_uploads volume. UPLOAD_DIR: /var/lib/temetro/uploads diff --git a/backend/package-lock.json b/backend/package-lock.json index 3f826df..20c3118 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "temetro-backend", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "temetro-backend", - "version": "0.1.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.84", @@ -28,6 +28,7 @@ "nodemailer": "^8.0.10", "pg": "^8.21.0", "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "zod": "^4.4.3" }, "devDependencies": { @@ -3339,6 +3340,40 @@ "node": ">=10.2.0" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -4947,6 +4982,21 @@ "ws": "~8.20.1" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -5779,6 +5829,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e21d626..9c896a3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "temetro-backend", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.", @@ -41,6 +41,7 @@ "nodemailer": "^8.0.10", "pg": "^8.21.0", "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/backend/src/env.ts b/backend/src/env.ts index b5bad6c..24fbbfa 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -28,6 +28,13 @@ const schema = z.object({ // Overrides the version reported by GET /api/version. Normally derived from // package.json; the release pipeline can pin it explicitly. APP_VERSION: z.string().optional(), + // Temetro Network relay (github.com/temetro/temetro-network). Both this + // backend and patient phones connect to it; it routes the encrypted wallet + // messages between them. RELAY_URL is the relay's public URL (also baked into + // the QR a patient scans); RELAY_TOKEN is the shared secret this backend + // presents on the relay's /hub namespace (must match the relay's RELAY_TOKEN). + RELAY_URL: z.string().min(1).default("http://localhost:8080"), + RELAY_TOKEN: z.string().default(""), // Public, device-reachable URL of this backend's wallet relay, baked into the // QR a patient scans. Optional — when unset we derive it from the request host // (so opening the web app over the LAN yields a reachable LAN URL). @@ -80,6 +87,11 @@ if (env.NODE_ENV === "production") { ); process.exit(1); } + if (!env.RELAY_TOKEN) { + console.warn( + "⚠️ RELAY_TOKEN is unset in production — the Temetro Network /hub connection is unauthenticated. Set a shared secret: openssl rand -base64 32", + ); + } } export const isProd = env.NODE_ENV === "production"; diff --git a/backend/src/index.ts b/backend/src/index.ts index 62f0132..fdfbc9c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,7 @@ import { staffRouter } from "./routes/staff.js"; import { networkRouter } from "./routes/network.js"; import { tasksRouter } from "./routes/tasks.js"; import { versionRouter } from "./routes/version.js"; +import { initRelayClient } from "./services/relay-client.js"; import { beginQuickTunnelDiscovery } from "./services/relay-url.js"; import { sweepExpiredShares } from "./services/wallet-share.js"; @@ -123,6 +124,11 @@ app.use(errorHandler); const server = createServer(app); initRealtime(server); +// Connect to the Temetro Network relay (the device-facing hub). Patient phones +// no longer connect to this backend directly — they connect to the relay, and +// we push to / receive from them over its /hub namespace. +initRelayClient(); + // Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes. const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000; setInterval(() => { @@ -154,7 +160,7 @@ server.listen(env.PORT, () => { console.log(` • portal: /api/portal (public clinic kiosk)`); console.log(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`); console.log(` • signing: /api/signing (Ed25519 clinic key)`); - console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`); + console.log(` • wallet: /api/patients/wallet (via Temetro Network relay: ${env.RELAY_URL})`); }); // Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL diff --git a/backend/src/realtime.ts b/backend/src/realtime.ts index 117a8b3..6534bd3 100644 --- a/backend/src/realtime.ts +++ b/backend/src/realtime.ts @@ -1,17 +1,14 @@ import type { Server as HttpServer } from "node:http"; import { fromNodeHeaders } from "better-auth/node"; -import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js"; import { Server, type Socket } from "socket.io"; import { auth } from "./auth.js"; import { env } from "./env.js"; -import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js"; 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 { sendToWallet } from "./services/relay-client.js"; import type { MessageAttachment } from "./types/messaging.js"; let io: Server | null = null; @@ -20,7 +17,6 @@ const userRoom = (userId: string) => `user:${userId}`; const convRoom = (conversationId: string) => `conv:${conversationId}`; const callRoom = (roomId: string) => `call:${roomId}`; const orgRoom = (orgId: string) => `org:${orgId}`; -const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`; // Mesh WebRTC tops out around four peers (each sends its stream to every other); // past that the room is closed to new joiners. @@ -44,15 +40,17 @@ export function emitToConversation( io?.to(convRoom(conversationId)).emit(event, data); } -// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet -// namespace, room keyed by wallet number). The relay only ever forwards -// ciphertext — it cannot read the record bundle. +// Relay an end-to-end-encrypted message to a patient wallet device. Devices no +// longer connect to this server directly — they connect to the standalone +// Temetro Network relay, which forwards to the room keyed by wallet number. We +// push over the relay's /hub namespace (see services/relay-client.ts). The +// relay only ever forwards ciphertext — it cannot read the record bundle. export function emitToWallet( walletNumber: string, event: string, data: unknown, ): void { - io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data); + sendToWallet(walletNumber, event, data); } type Ack = (response: { ok: boolean; [key: string]: unknown }) => void; @@ -286,151 +284,5 @@ export function initRealtime(httpServer: HttpServer): Server { }); }); - // --- Patient wallet relay (/wallet namespace) ---------------------------- - // Devices have no clinic session, so this namespace is NOT cookie-gated. - // Instead a device proves control of its wallet keypair: the server issues a - // random challenge, the device signs it with its Ed25519 key, and only then - // may it join its own wallet room. The relay forwards encrypted share - // requests/responses without ever reading the record bundle. - const walletNs = io.of("/wallet"); - walletNs.on("connection", (socket: Socket) => { - const challenge = bytesToHex(randomBytes(32)); - socket.data.challenge = challenge; - socket.data.walletNumber = null as string | null; - socket.emit("wallet:challenge", { challenge }); - - socket.on( - "wallet:auth", - (payload: { walletNumber?: string; signature?: string }, ack?: Ack) => { - try { - const walletNumber = String(payload?.walletNumber ?? ""); - const signature = String(payload?.signature ?? ""); - const publicKey = decodeWalletNumber(walletNumber); - const ok = verifySignature( - publicKey, - signature, - utf8ToBytes(socket.data.challenge as string), - ); - if (!ok) { - ack?.({ ok: false }); - return; - } - 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( - "wallet:share-response", - async ( - payload: { - requestId?: string; - walletNumber?: string; - decision?: "approved" | "denied"; - sealed?: string; - signature?: string; - }, - ack?: Ack, - ) => { - try { - if ( - !socket.data.walletNumber || - socket.data.walletNumber !== payload?.walletNumber - ) { - ack?.({ ok: false }); - return; - } - 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 }); - } - }, - ); - - // The patient revoked a previously shared record; delete it from the clinic. - socket.on( - "wallet:revoke", - async ( - payload: { requestId?: string; walletNumber?: string }, - ack?: Ack, - ) => { - try { - if ( - !socket.data.walletNumber || - socket.data.walletNumber !== payload?.walletNumber - ) { - ack?.({ ok: false }); - return; - } - const result = await walletShare.revokeShare( - String(payload?.requestId ?? ""), - String(payload?.walletNumber ?? ""), - ); - ack?.({ ok: !!result }); - } catch { - ack?.({ ok: false }); - } - }, - ); - }); - return io; } diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index 9a8e060..80b4a2c 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -27,9 +27,12 @@ export const patientsWalletRouter = Router(); patientsWalletRouter.use(requireAuth, requireOrg); // The device-reachable URL the patient's app should connect to (baked into the -// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request -// host so that opening the web app over the LAN yields a reachable LAN URL. +// QR). Devices connect to the standalone Temetro Network relay — the same relay +// this backend is hubbed to — so RELAY_URL is the canonical answer. The legacy +// PUBLIC_RELAY_URL / cloudflared / request-host fallbacks remain for pre-relay +// self-hosting. async function resolveRelayUrl(req: Request): Promise { + if (env.RELAY_URL) return env.RELAY_URL; if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL; // A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to // become reachable so the QR never carries a not-yet-live URL. diff --git a/backend/src/services/relay-client.ts b/backend/src/services/relay-client.ts new file mode 100644 index 0000000..a7aa156 --- /dev/null +++ b/backend/src/services/relay-client.ts @@ -0,0 +1,137 @@ +// 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 +// privileged `/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. + +import { io as connect, type Socket } from "socket.io-client"; + +import { env } from "../env.js"; +import * as walletShare from "./wallet-share.js"; +import * as walletUpdates from "./wallet-updates.js"; + +let hub: Socket | null = null; + +type Ack = (response: { ok: boolean; [key: string]: unknown }) => void; + +// Push an end-to-end-encrypted message to a patient wallet device via the relay +// (which forwards it to the room keyed by wallet number). Mirrors the old +// in-process `emitToWallet`. A no-op if the relay is not connected yet — the +// device replays anything it missed on its next connect (see `wallet:online`). +export function sendToWallet( + walletNumber: string, + event: string, + data: unknown, +): void { + hub?.emit("wallet:send", { walletNumber, event, data }); +} + +export function initRelayClient(): void { + hub = connect(`${env.RELAY_URL}/hub`, { + auth: { token: env.RELAY_TOKEN }, + transports: ["websocket"], + reconnection: true, + reconnectionDelayMax: 10_000, + }); + + hub.on("connect", () => { + console.log(`Connected to Temetro Network relay at ${env.RELAY_URL}`); + }); + hub.on("connect_error", (err) => { + console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}): ${err.message}`); + }); + + // A device authenticated on the relay — flush any record updates it missed + // while offline (the relay forwards each back to it). Mirrors the replay the + // old /wallet namespace did on connect. + hub.on("wallet:online", async (payload: { walletNumber?: string }) => { + const walletNumber = String(payload?.walletNumber ?? ""); + if (!walletNumber) return; + try { + const rows = await walletUpdates.pendingUpdatesForWallet(walletNumber); + for (const row of rows) { + sendToWallet(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 }); + } + }, + ); + + // 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 }); + } + }, + ); +} diff --git a/frontend/package.json b/frontend/package.json index d1ac55c..1b9ba67 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.6.0", + "version": "0.7.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index e3ae7ad..43c84a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "temetro", - "version": "0.6.0", + "version": "0.7.0", "private": true, "devDependencies": { "shadcn": "^4.11.0"