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
+5
View File
@@ -144,7 +144,11 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
// Every unresolved update for a wallet — re-sent on each authenticated connect
// so an offline device eventually receives what it missed.
// Pending updates a wallet missed, scoped to one clinic — the relay delivers a
// `wallet:online` over that clinic's own hub connection, so a clinic only ever
// re-sends its *own* updates (never another clinic's).
export async function pendingUpdatesForWallet(
orgId: string,
walletNumber: string,
): Promise<UpdateRow[]> {
return db
@@ -152,6 +156,7 @@ export async function pendingUpdatesForWallet(
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.organizationId, orgId),
eq(walletRecordUpdates.walletNumber, walletNumber),
isNull(walletRecordUpdates.resolvedAt),
),