diff --git a/CHANGELOG.md b/CHANGELOG.md index 083b9aa..f79137c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ for how releases are cut and published. ## [Unreleased] -## [0.8.0] — 2026-07-05 +## [0.8.1] — 2026-07-05 + +### Fixed +- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing + has no wallet number, so the clinic never sent a `wallet:send` to register the request, and the + relay rejected the scanning device's response as "unknown or expired". The clinic now + **pre-registers** the pairing request with the relay (a new `hub:expect { requestId }` event on + `POST /api/patients/wallet/pair`), so the device's response routes back correctly. On hub + (re)connect the backend re-registers its still-pending requests, so routing also survives a relay + restart. `POST /pair` now also requires the clinic to have joined the network (clear 409 instead of + a dead QR), surfaced in the import dialog. ### Added - **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each diff --git a/backend/package.json b/backend/package.json index aa3de51..d763eaf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "temetro-backend", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.", diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index 7c1f21b..1b9e536 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -17,6 +17,7 @@ import { } from "../middleware/auth.js"; import { emitToWallet } from "../realtime.js"; import { recordActivity } from "../services/activity.js"; +import { expectResponse } from "../services/relay-client.js"; import * as patientService from "../services/patients.js"; import { awaitQuickTunnelUrl } from "../services/relay-url.js"; import { getNetworkEnabled } from "../services/signing.js"; @@ -84,6 +85,7 @@ patientsWalletRouter.post( requirePermission({ patient: ["write"] }), async (req, res, next) => { try { + await requireNetwork(req.organizationId!); const input = pairSchema.parse(req.body); const { view, ephemeralPubKey } = await walletShare.createPairingRequest( req.organizationId!, @@ -91,6 +93,9 @@ patientsWalletRouter.post( input.mode, input.durationHours, ); + // No wallet number to `wallet:send` to yet, so pre-register the request id + // with the relay so the scanning device's response routes back to us. + expectResponse(req.organizationId!, view.id); res.status(201).json({ ...view, ephemeralPubKey, diff --git a/backend/src/services/relay-client.ts b/backend/src/services/relay-client.ts index 5dbd44c..726a1dd 100644 --- a/backend/src/services/relay-client.ts +++ b/backend/src/services/relay-client.ts @@ -41,6 +41,13 @@ export function sendToWallet( 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 and again when an org joins the network. export async function connectOrg(orgId: string): Promise { @@ -92,11 +99,21 @@ function registerHubHandlers(orgId: string, hub: Socket): void { // `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 }, - (ack: { ok?: boolean } | undefined) => { - if (ack?.ok) { - console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`); - } else { + 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 */ } }, ); diff --git a/backend/src/services/wallet-share.ts b/backend/src/services/wallet-share.ts index cac91ca..51b4646 100644 --- a/backend/src/services/wallet-share.ts +++ b/backend/src/services/wallet-share.ts @@ -137,6 +137,22 @@ export async function listShareRequests( return rows.map(toView); } +// Ids of this clinic's still-pending share/pairing requests. Used to re-register +// them with the relay when the clinic's hub (re)connects (the relay keeps +// routing state in memory, so it's lost on a relay restart / redeploy). +export async function pendingRequestIds(orgId: string): Promise { + const rows = await db + .select({ id: walletShareRequests.id }) + .from(walletShareRequests) + .where( + and( + eq(walletShareRequests.organizationId, orgId), + eq(walletShareRequests.status, "pending"), + ), + ); + return rows.map((r) => r.id); +} + // Apply a response relayed back from the patient's device. On approval we // decrypt the sealed bundle with the request's ephemeral private key and verify // the wallet's Ed25519 signature over it (provenance: it really came from that diff --git a/frontend/components/patients/import-from-wallet-dialog.tsx b/frontend/components/patients/import-from-wallet-dialog.tsx index b1eb6a9..31db0c9 100644 --- a/frontend/components/patients/import-from-wallet-dialog.tsx +++ b/frontend/components/patients/import-from-wallet-dialog.tsx @@ -141,6 +141,8 @@ export function ImportFromWalletDialog({ } catch (err) { if (err instanceof ApiError && err.status === 400) { setError(t("patients.importApp.invalidWallet")); + } else if (err instanceof ApiError && err.status === 409) { + setError(t("patients.importApp.networkOff")); } else { setError(t("patients.importApp.error")); } @@ -171,8 +173,12 @@ export function ImportFromWalletDialog({ setPairUri(`temetro-pair:?${params.toString()}`); setRequest(pairing); setPhase("waiting"); - } catch { - setError(t("patients.importApp.error")); + } catch (err) { + if (err instanceof ApiError && err.status === 409) { + setError(t("patients.importApp.networkOff")); + } else { + setError(t("patients.importApp.error")); + } setPhase("error"); } }; diff --git a/frontend/lib/i18n/locales/ar/translation.json b/frontend/lib/i18n/locales/ar/translation.json index da5a4f4..527f3d9 100644 --- a/frontend/lib/i18n/locales/ar/translation.json +++ b/frontend/lib/i18n/locales/ar/translation.json @@ -322,6 +322,7 @@ "expiredTitle": "انتهت صلاحية الطلب", "expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.", "invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.", + "networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.", "errorTitle": "تعذّر الوصول إلى المحفظة", "error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.", "savedTitle": "تم استيراد المريض", diff --git a/frontend/lib/i18n/locales/de/translation.json b/frontend/lib/i18n/locales/de/translation.json index 6a3bccc..079189b 100644 --- a/frontend/lib/i18n/locales/de/translation.json +++ b/frontend/lib/i18n/locales/de/translation.json @@ -310,6 +310,7 @@ "expiredTitle": "Anfrage abgelaufen", "expiredBody": "Die Anfrage ist abgelaufen, bevor der Patient geantwortet hat.", "invalidWallet": "Das sieht nicht nach einer gültigen Wallet-Nummer aus.", + "networkOff": "Treten Sie zuerst dem Temetro-Netzwerk bei (Einstellungen → Signierung), um mit Patienten-Apps zu teilen.", "errorTitle": "Wallet nicht erreichbar", "error": "Bitte prüfen Sie die Wallet-Nummer und versuchen Sie es erneut.", "savedTitle": "Patient importiert", diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 5a10b14..398a937 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -310,6 +310,7 @@ "expiredTitle": "Request expired", "expiredBody": "The request timed out before the patient responded.", "invalidWallet": "That doesn't look like a valid wallet number.", + "networkOff": "Join the Temetro Network first (Settings → Signing) to share with patient apps.", "errorTitle": "Couldn't reach the wallet", "error": "Please check the wallet number and try again.", "savedTitle": "Patient imported", diff --git a/frontend/lib/i18n/locales/fr/translation.json b/frontend/lib/i18n/locales/fr/translation.json index 603e90a..177123d 100644 --- a/frontend/lib/i18n/locales/fr/translation.json +++ b/frontend/lib/i18n/locales/fr/translation.json @@ -310,6 +310,7 @@ "expiredTitle": "Demande expirée", "expiredBody": "La demande a expiré avant que le patient ne réponde.", "invalidWallet": "Cela ne ressemble pas à un numéro de portefeuille valide.", + "networkOff": "Rejoignez d'abord le Réseau Temetro (Paramètres → Signature) pour partager avec les applications des patients.", "errorTitle": "Impossible de joindre le portefeuille", "error": "Veuillez vérifier le numéro de portefeuille et réessayer.", "savedTitle": "Patient importé", diff --git a/frontend/lib/i18n/locales/so/translation.json b/frontend/lib/i18n/locales/so/translation.json index 244b29a..87f9e33 100644 --- a/frontend/lib/i18n/locales/so/translation.json +++ b/frontend/lib/i18n/locales/so/translation.json @@ -310,6 +310,7 @@ "expiredTitle": "Codsiga waa dhacay", "expiredBody": "Codsiga wuu dhacay ka hor inta uusan bukaanku ka jawaabin.", "invalidWallet": "Taasi uma muuqato lambar wallet oo sax ah.", + "networkOff": "Marka hore ku biir Shabakadda Temetro (Dejinta → Saxiixa) si aad ula wadaagto abaabulka bukaannada.", "errorTitle": "Wallet-ka lama gaari karin", "error": "Fadlan hubi lambarka wallet-ka oo isku day mar kale.", "savedTitle": "Bukaanka waa la soo dejiyay", diff --git a/frontend/package.json b/frontend/package.json index a5eab11..56c19a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.8.0", + "version": "0.8.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 382998d..69a1474 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "temetro", - "version": "0.8.0", + "version": "0.8.1", "private": true, "devDependencies": { "shadcn": "^4.11.0"