mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 04:08:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 233ce9f854 |
+11
-1
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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 */
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string[]> {
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"expiredTitle": "انتهت صلاحية الطلب",
|
||||
"expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.",
|
||||
"invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.",
|
||||
"networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.",
|
||||
"errorTitle": "تعذّر الوصول إلى المحفظة",
|
||||
"error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.",
|
||||
"savedTitle": "تم استيراد المريض",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user