mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-7
@@ -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) -----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+60
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-155
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user