feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)

The relay is now multi-clinic. Each clinic authenticates to the /hub
namespace by signing a challenge with its own Ed25519 clinic signing key
(a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes
every device response back to only the clinic that originated the request
(keyed by requestId) — so clinics never see each other's traffic.

Backend:
- clinic_signing_keys.network_enabled + GET/PUT /api/signing/network
  (owner/admin) to join/leave the network.
- relay-client keeps one authenticated hub connection per network-enabled
  org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/
  sendToWallet take orgId; offline flush is org-scoped.
- Wallet import/push return 409 until a clinic joins.
- RELAY_TOKEN is now optional/legacy (open relay needs no shared secret).

Frontend:
- "Join Temetro Network" toggle in Settings → Signing, localized in all
  five languages (en, fr, de, so, ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-05 18:30:19 +03:00
parent ef76afc3ca
commit 99aa534e88
24 changed files with 4986 additions and 43 deletions
@@ -1996,6 +1996,20 @@
"errorTitle": "تعذّر إضافة العضو"
}
},
"network": {
"title": "شبكة Temetro",
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
"toggleLabel": "الانضمام إلى شبكة Temetro",
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
"statusConnected": "متصل",
"statusOff": "معطّل",
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
"leftTitle": "تم مغادرة شبكة Temetro",
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -1976,6 +1976,20 @@
"errorTitle": "Mitglied konnte nicht hinzugefügt werden"
}
},
"network": {
"title": "Temetro-Netzwerk",
"description": "Das Temetro-Netzwerk-Relay ermöglicht es den Wallet-Apps der Patienten, sich mit Ihrer Praxis zu verbinden, um Akten zu teilen und zu genehmigen — Ende-zu-Ende-verschlüsselt, wobei Ihre Praxis über ihren Signierschlüssel identifiziert wird.",
"toggleLabel": "Temetro-Netzwerk beitreten",
"toggleDesc": "Wenn aktiviert, verbindet sich diese Praxis mit dem Relay, sodass Sie Akten aus Patienten-Apps importieren und Aktualisierungen an deren Wallets senden können.",
"statusConnected": "Verbunden",
"statusOff": "Aus",
"joinedTitle": "Temetro-Netzwerk beigetreten",
"joinedBody": "Wallet-Apps der Patienten können sich jetzt mit dieser Praxis verbinden.",
"leftTitle": "Temetro-Netzwerk verlassen",
"leftBody": "Diese Praxis ist nicht mehr mit dem Relay verbunden.",
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
"error": "Bitte versuchen Sie es erneut."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -1976,6 +1976,20 @@
"errorTitle": "Could not add member"
}
},
"network": {
"title": "Temetro Network",
"description": "The Temetro Network relay lets patients' wallet apps connect to your clinic to share and approve records — end-to-end encrypted, with your clinic identified by its signing key.",
"toggleLabel": "Join Temetro Network",
"toggleDesc": "When on, this clinic connects to the relay so you can import records from patient apps and push updates to their wallets.",
"statusConnected": "Connected",
"statusOff": "Off",
"joinedTitle": "Joined the Temetro Network",
"joinedBody": "Patient wallet apps can now connect to this clinic.",
"leftTitle": "Left the Temetro Network",
"leftBody": "This clinic is no longer connected to the relay.",
"errorTitle": "Couldn't update network access",
"error": "Please try again."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -1976,6 +1976,20 @@
"errorTitle": "Impossible d'ajouter le membre"
}
},
"network": {
"title": "Réseau Temetro",
"description": "Le relais Réseau Temetro permet aux applications portefeuille des patients de se connecter à votre clinique pour partager et approuver des dossiers — chiffrés de bout en bout, votre clinique étant identifiée par sa clé de signature.",
"toggleLabel": "Rejoindre le Réseau Temetro",
"toggleDesc": "Une fois activé, cette clinique se connecte au relais pour importer des dossiers depuis les applications des patients et envoyer des mises à jour vers leurs portefeuilles.",
"statusConnected": "Connecté",
"statusOff": "Désactivé",
"joinedTitle": "Réseau Temetro rejoint",
"joinedBody": "Les applications portefeuille des patients peuvent désormais se connecter à cette clinique.",
"leftTitle": "Réseau Temetro quitté",
"leftBody": "Cette clinique n'est plus connectée au relais.",
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
"error": "Veuillez réessayer."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -1976,6 +1976,20 @@
"errorTitle": "Xubinta lama ku dari karin"
}
},
"network": {
"title": "Shabakadda Temetro",
"description": "Gudbiyaha Shabakadda Temetro wuxuu u oggolaanayaa abaabulka boorsada bukaannada inay ku xidhaan rugtaada si ay u wadaagaan oo u ansixiyaan diiwaannada — sir gaba-gabo ah, iyadoo rugtaada lagu aqoonsado furaheeda saxiixa.",
"toggleLabel": "Ku biir Shabakadda Temetro",
"toggleDesc": "Marka la shido, rugtan waxay ku xidhmaysaa gudbiyaha si aad u soo dejiso diiwaannada abaabulka bukaannada oo aad cusboonaysiin ugu dirto boorsadooda.",
"statusConnected": "La xidhiidhay",
"statusOff": "Daminaan",
"joinedTitle": "Waxaad ku biirtay Shabakadda Temetro",
"joinedBody": "Abaabulka boorsada bukaannada hadda way ku xidhmi karaan rugtan.",
"leftTitle": "Waxaad ka baxday Shabakadda Temetro",
"leftBody": "Rugtan hadda kuma xidhna gudbiyaha.",
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
"error": "Fadlan isku day mar kale."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
+18
View File
@@ -38,3 +38,21 @@ export async function rotateSigningKey(): Promise<SigningKey> {
export async function listSignedRecords(): Promise<SharedRecord[]> {
return apiFetch<SharedRecord[]>("/api/signing/records");
}
// Whether this clinic has joined the Temetro Network relay (patient-wallet
// sharing rides it). Readable by any clinician.
export async function getNetworkEnabled(): Promise<boolean> {
const { enabled } = await apiFetch<{ enabled: boolean }>(
"/api/signing/network",
);
return enabled;
}
// Join or leave the Temetro Network (owner/admin only). Returns the new state.
export async function setNetworkEnabled(enabled: boolean): Promise<boolean> {
const res = await apiFetch<{ enabled: boolean }>("/api/signing/network", {
method: "PUT",
body: JSON.stringify({ enabled }),
});
return res.enabled;
}