mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
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:
@@ -7,6 +7,28 @@ for how releases are cut and published.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.0] — 2026-07-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each
|
||||||
|
clinic authenticates to the `/hub` namespace by **signing a challenge with its own Ed25519 clinic
|
||||||
|
signing key** (`services/signing.ts`) — a per-clinic identity, not a shared password — 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. `wallet:online` is fanned out only to
|
||||||
|
clinics with pending work for that wallet.
|
||||||
|
- **"Join Temetro Network" opt-in.** A per-clinic toggle in **Settings → Signing** (backed by
|
||||||
|
`clinic_signing_keys.network_enabled`, `GET`/`PUT /api/signing/network`, owner/admin only). Off by
|
||||||
|
default; enabling opens the clinic's relay connection, disabling tears it down. Wallet
|
||||||
|
import/push endpoints return **409** while a clinic hasn't joined. Localised in all five languages.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The backend keeps one authenticated relay connection per network-enabled org**
|
||||||
|
(`services/relay-client.ts` — `connectOrg`/`disconnectOrg`, a `hubs` map keyed by `orgId`), instead
|
||||||
|
of a single shared-token connection. `emitToWallet`/`sendToWallet` now take an `orgId`, and the
|
||||||
|
offline-flush (`pendingUpdatesForWallet`) is org-scoped.
|
||||||
|
- **`RELAY_TOKEN` is now optional/legacy.** Clinics authenticate with their signing key, so an open
|
||||||
|
relay needs no shared secret; `RELAY_TOKEN` only gates an optional *private* relay.
|
||||||
|
|
||||||
## [0.7.0] — 2026-07-05
|
## [0.7.0] — 2026-07-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ FRONTEND_PORT=3000
|
|||||||
# The standalone relay (github.com/temetro/temetro-network) that connects this
|
# 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 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
|
# 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
|
# baked into the QR a patient scans). This clinic authenticates to the relay's
|
||||||
# backend presents on the relay's /hub namespace — set the SAME value here and
|
# /hub with its own Ed25519 signing key, so no shared secret is needed.
|
||||||
# on the relay. Generate one with: openssl rand -base64 32
|
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
|
||||||
|
# gates on a shared token (then use the SAME value here and on the relay).
|
||||||
RELAY_URL=http://localhost:8080
|
RELAY_URL=http://localhost:8080
|
||||||
RELAY_TOKEN=
|
RELAY_TOKEN=
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -61,12 +61,17 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
|
|||||||
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
|
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
|
||||||
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
|
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
|
||||||
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
|
- **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
|
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
|
||||||
as a `/hub` client in **`src/services/relay-client.ts`**; `emitToWallet` (realtime.ts) delegates to
|
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
|
||||||
its `sendToWallet`, and device responses (`wallet:share-response` / `wallet:update-response` /
|
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
|
||||||
`wallet:revoke`) + `wallet:online` replay are handled there, calling the same `wallet-share` /
|
authenticates by signing the relay's `hub:challenge` with its clinic signing key
|
||||||
`wallet-updates` services the old `/wallet` namespace did. Configure with `RELAY_URL` +
|
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
|
||||||
`RELAY_TOKEN`.
|
private relay). `emitToWallet(orgId, …)` (realtime.ts) delegates to `sendToWallet(orgId, …)`, and
|
||||||
|
device responses (`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) +
|
||||||
|
`wallet:online` replay are handled per-org there, calling the same `wallet-share` /
|
||||||
|
`wallet-updates` services the old `/wallet` namespace did. A clinic opts in via **"Join Temetro
|
||||||
|
Network"** (Settings → Signing → `PUT /api/signing/network`, `clinic_signing_keys.network_enabled`);
|
||||||
|
`connectOrg`/`disconnectOrg` open/close its connection, and wallet routes 409 when it's off.
|
||||||
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
|
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
|
||||||
|
|
||||||
## Gotchas / conventions
|
## Gotchas / conventions
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "clinic_signing_keys" ADD COLUMN "network_enabled" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -225,6 +225,13 @@
|
|||||||
"when": 1783117115021,
|
"when": 1783117115021,
|
||||||
"tag": "0031_stiff_gateway",
|
"tag": "0031_stiff_gateway",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783263738631,
|
||||||
|
"tag": "0032_closed_dakota_north",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temetro-backend",
|
"name": "temetro-backend",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { organization } from "./auth.js";
|
import { organization } from "./auth.js";
|
||||||
|
|
||||||
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
|
|||||||
fingerprint: text("fingerprint").notNull(),
|
fingerprint: text("fingerprint").notNull(),
|
||||||
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
||||||
privateKeyEnc: text("private_key_enc").notNull(),
|
privateKeyEnc: text("private_key_enc").notNull(),
|
||||||
|
// Whether this clinic has joined the Temetro Network relay ("Join Temetro
|
||||||
|
// Network" in Settings → Signing). Off by default: only when enabled does the
|
||||||
|
// backend open this clinic's relay hub connection and expose wallet features.
|
||||||
|
// The relay identity *is* this signing key, so the flag lives on the same row.
|
||||||
|
networkEnabled: boolean("network_enabled").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
rotatedAt: timestamp("rotated_at"),
|
rotatedAt: timestamp("rotated_at"),
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-7
@@ -31,8 +31,10 @@ const schema = z.object({
|
|||||||
// Temetro Network relay (github.com/temetro/temetro-network). Both this
|
// Temetro Network relay (github.com/temetro/temetro-network). Both this
|
||||||
// backend and patient phones connect to it; it routes the encrypted wallet
|
// 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
|
// 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
|
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
|
||||||
// presents on the relay's /hub namespace (must match the relay's RELAY_TOKEN).
|
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
|
||||||
|
// now *optional/legacy* — set it only for a private relay that also gates on a
|
||||||
|
// shared token (must then match the relay's RELAY_TOKEN).
|
||||||
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
|
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
|
||||||
RELAY_TOKEN: z.string().default(""),
|
RELAY_TOKEN: z.string().default(""),
|
||||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||||
@@ -87,11 +89,9 @@ if (env.NODE_ENV === "production") {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!env.RELAY_TOKEN) {
|
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
|
||||||
console.warn(
|
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
|
||||||
"⚠️ RELAY_TOKEN is unset in production — the Temetro Network /hub connection is unauthenticated. Set a shared secret: openssl rand -base64 32",
|
// no warning needed.
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isProd = env.NODE_ENV === "production";
|
export const isProd = env.NODE_ENV === "production";
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ export function emitToConversation(
|
|||||||
// push over the relay's /hub namespace (see services/relay-client.ts). The
|
// push over the relay's /hub namespace (see services/relay-client.ts). The
|
||||||
// relay only ever forwards ciphertext — it cannot read the record bundle.
|
// relay only ever forwards ciphertext — it cannot read the record bundle.
|
||||||
export function emitToWallet(
|
export function emitToWallet(
|
||||||
|
orgId: string,
|
||||||
walletNumber: string,
|
walletNumber: string,
|
||||||
event: string,
|
event: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
): void {
|
): void {
|
||||||
sendToWallet(walletNumber, event, data);
|
sendToWallet(orgId, walletNumber, event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { emitToWallet } from "../realtime.js";
|
|||||||
import { recordActivity } from "../services/activity.js";
|
import { recordActivity } from "../services/activity.js";
|
||||||
import * as patientService from "../services/patients.js";
|
import * as patientService from "../services/patients.js";
|
||||||
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
|
||||||
|
import { getNetworkEnabled } from "../services/signing.js";
|
||||||
import * as walletShare from "../services/wallet-share.js";
|
import * as walletShare from "../services/wallet-share.js";
|
||||||
import * as walletUpdates from "../services/wallet-updates.js";
|
import * as walletUpdates from "../services/wallet-updates.js";
|
||||||
|
|
||||||
@@ -26,6 +27,18 @@ export const patientsWalletRouter = Router();
|
|||||||
|
|
||||||
patientsWalletRouter.use(requireAuth, requireOrg);
|
patientsWalletRouter.use(requireAuth, requireOrg);
|
||||||
|
|
||||||
|
// Wallet sharing rides the Temetro Network relay, which a clinic must opt into
|
||||||
|
// ("Join Temetro Network" in Settings → Signing). Guard the actions that need a
|
||||||
|
// live relay connection so a disabled clinic gets a clear message, not silence.
|
||||||
|
async function requireNetwork(orgId: string): Promise<void> {
|
||||||
|
if (!(await getNetworkEnabled(orgId))) {
|
||||||
|
throw new HttpError(
|
||||||
|
409,
|
||||||
|
"This clinic hasn't joined the Temetro Network. Enable it in Settings → Signing to share with patient wallets.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The device-reachable URL the patient's app should connect to (baked into the
|
// The device-reachable URL the patient's app should connect to (baked into the
|
||||||
// QR). Devices connect to the standalone Temetro Network relay — the same relay
|
// 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
|
// this backend is hubbed to — so RELAY_URL is the canonical answer. The legacy
|
||||||
@@ -97,6 +110,7 @@ patientsWalletRouter.post(
|
|||||||
requirePermission({ patient: ["write"] }),
|
requirePermission({ patient: ["write"] }),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
await requireNetwork(req.organizationId!);
|
||||||
const input = requestSchema.parse(req.body);
|
const input = requestSchema.parse(req.body);
|
||||||
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
||||||
req.organizationId!,
|
req.organizationId!,
|
||||||
@@ -109,7 +123,7 @@ patientsWalletRouter.post(
|
|||||||
.select({ name: organization.name })
|
.select({ name: organization.name })
|
||||||
.from(organization)
|
.from(organization)
|
||||||
.where(eq(organization.id, req.organizationId!));
|
.where(eq(organization.id, req.organizationId!));
|
||||||
emitToWallet(input.walletNumber, "wallet:share-request", {
|
emitToWallet(req.organizationId!, input.walletNumber, "wallet:share-request", {
|
||||||
requestId: view.id,
|
requestId: view.id,
|
||||||
clinicName: org?.name ?? "A clinic",
|
clinicName: org?.name ?? "A clinic",
|
||||||
requestedBy: req.user!.name,
|
requestedBy: req.user!.name,
|
||||||
@@ -176,6 +190,7 @@ patientsWalletRouter.post(
|
|||||||
requirePermission({ patient: ["write"] }),
|
requirePermission({ patient: ["write"] }),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
await requireNetwork(req.organizationId!);
|
||||||
const input = pushSchema.parse(req.body);
|
const input = pushSchema.parse(req.body);
|
||||||
const row = await walletUpdates.createRecordUpdate(
|
const row = await walletUpdates.createRecordUpdate(
|
||||||
req.organizationId!,
|
req.organizationId!,
|
||||||
@@ -184,7 +199,7 @@ patientsWalletRouter.post(
|
|||||||
input.changes,
|
input.changes,
|
||||||
);
|
);
|
||||||
const event = await walletUpdates.toEvent(row);
|
const event = await walletUpdates.toEvent(row);
|
||||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
orgId: req.organizationId!,
|
orgId: req.organizationId!,
|
||||||
actor: { id: req.user!.id, name: req.user!.name },
|
actor: { id: req.user!.id, name: req.user!.name },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
} from "../middleware/auth.js";
|
} from "../middleware/auth.js";
|
||||||
import { recordActivity } from "../services/activity.js";
|
import { recordActivity } from "../services/activity.js";
|
||||||
|
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
|
||||||
import * as signing from "../services/signing.js";
|
import * as signing from "../services/signing.js";
|
||||||
import * as walletShare from "../services/wallet-share.js";
|
import * as walletShare from "../services/wallet-share.js";
|
||||||
|
|
||||||
@@ -48,6 +50,52 @@ signingRouter.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Whether this clinic has joined the Temetro Network relay. Readable by any
|
||||||
|
// clinician (the panel shows the toggle state + connection status).
|
||||||
|
signingRouter.get(
|
||||||
|
"/network",
|
||||||
|
requirePermission({ patient: ["read"] }),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json({ enabled: await signing.getNetworkEnabled(req.organizationId!) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Join / leave the Temetro Network — owner/admin only (same gate as key
|
||||||
|
// rotation). Enabling opens this clinic's relay hub connection; disabling tears
|
||||||
|
// it down.
|
||||||
|
const networkSchema = z.object({ enabled: z.boolean() });
|
||||||
|
|
||||||
|
signingRouter.put(
|
||||||
|
"/network",
|
||||||
|
requirePermission({ organization: ["update"] }),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { enabled } = networkSchema.parse(req.body);
|
||||||
|
await signing.setNetworkEnabled(req.organizationId!, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
await connectOrg(req.organizationId!);
|
||||||
|
} else {
|
||||||
|
disconnectOrg(req.organizationId!);
|
||||||
|
}
|
||||||
|
await recordActivity({
|
||||||
|
orgId: req.organizationId!,
|
||||||
|
actor: { id: req.user!.id, name: req.user!.name },
|
||||||
|
action: enabled
|
||||||
|
? "Joined the Temetro Network"
|
||||||
|
: "Left the Temetro Network",
|
||||||
|
entityType: "settings",
|
||||||
|
});
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Recent records shared from patient wallets — feeds the panel's shared-records
|
// Recent records shared from patient wallets — feeds the panel's shared-records
|
||||||
// list.
|
// list.
|
||||||
signingRouter.get(
|
signingRouter.get(
|
||||||
|
|||||||
@@ -4,58 +4,121 @@
|
|||||||
//
|
//
|
||||||
// This backend was previously the device-facing Socket.io server itself (the
|
// 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
|
// `/wallet` namespace in realtime.ts). Now it is a *client* of the relay's
|
||||||
// privileged `/hub` namespace: it pushes messages to devices via `sendToWallet`
|
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
|
||||||
// and handles their responses here, calling the same wallet service functions
|
// their responses here, calling the same wallet service functions the old socket
|
||||||
// the old socket handlers did. Sealed bundles are decrypted here (we hold the
|
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
|
||||||
// ephemeral key); the relay only ever forwards ciphertext.
|
// the relay only ever forwards ciphertext.
|
||||||
|
//
|
||||||
|
// The relay is **multi-clinic**: each clinic (organization) authenticates to
|
||||||
|
// `/hub` with its own Ed25519 signing key (a per-clinic identity, not a shared
|
||||||
|
// password), and the relay routes each device response back only to the clinic
|
||||||
|
// that originated the request. So this backend keeps **one hub connection per
|
||||||
|
// network-enabled org**, opened when the org joins the network ("Join Temetro
|
||||||
|
// Network" in Settings → Signing) and torn down when it leaves.
|
||||||
|
|
||||||
import { io as connect, type Socket } from "socket.io-client";
|
import { io as connect, type Socket } from "socket.io-client";
|
||||||
|
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
|
||||||
import * as walletShare from "./wallet-share.js";
|
import * as walletShare from "./wallet-share.js";
|
||||||
import * as walletUpdates from "./wallet-updates.js";
|
import * as walletUpdates from "./wallet-updates.js";
|
||||||
|
|
||||||
let hub: Socket | null = null;
|
// One authenticated hub connection per network-enabled organization.
|
||||||
|
const hubs = new Map<string, Socket>();
|
||||||
|
|
||||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||||
|
|
||||||
// Push an end-to-end-encrypted message to a patient wallet device via the relay
|
// Push an end-to-end-encrypted message to a patient wallet device via the given
|
||||||
// (which forwards it to the room keyed by wallet number). Mirrors the old
|
// clinic's relay connection (the relay forwards it to the room keyed by wallet
|
||||||
// in-process `emitToWallet`. A no-op if the relay is not connected yet — the
|
// number). A no-op if the clinic isn't on the network / not connected yet — the
|
||||||
// device replays anything it missed on its next connect (see `wallet:online`).
|
// device replays anything it missed on its next connect (see `wallet:online`).
|
||||||
export function sendToWallet(
|
export function sendToWallet(
|
||||||
|
orgId: string,
|
||||||
walletNumber: string,
|
walletNumber: string,
|
||||||
event: string,
|
event: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
): void {
|
): void {
|
||||||
hub?.emit("wallet:send", { walletNumber, event, data });
|
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRelayClient(): void {
|
// Open (and authenticate) a hub connection for a clinic, if not already open.
|
||||||
hub = connect(`${env.RELAY_URL}/hub`, {
|
// Idempotent — safe to call on startup and again when an org joins the network.
|
||||||
auth: { token: env.RELAY_TOKEN },
|
export async function connectOrg(orgId: string): Promise<void> {
|
||||||
|
if (hubs.has(orgId)) return;
|
||||||
|
|
||||||
|
const hub = connect(`${env.RELAY_URL}/hub`, {
|
||||||
transports: ["websocket"],
|
transports: ["websocket"],
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelayMax: 10_000,
|
reconnectionDelayMax: 10_000,
|
||||||
});
|
});
|
||||||
|
hubs.set(orgId, hub);
|
||||||
|
registerHubHandlers(orgId, hub);
|
||||||
|
}
|
||||||
|
|
||||||
hub.on("connect", () => {
|
// Leave the network for a clinic: close and forget its hub connection.
|
||||||
console.log(`Connected to Temetro Network relay at ${env.RELAY_URL}`);
|
export function disconnectOrg(orgId: string): void {
|
||||||
|
const hub = hubs.get(orgId);
|
||||||
|
if (!hub) return;
|
||||||
|
hub.disconnect();
|
||||||
|
hubs.delete(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a hub connection for every clinic already on the network. Called once at
|
||||||
|
// startup; runtime joins/leaves go through connectOrg/disconnectOrg.
|
||||||
|
export async function initRelayClient(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const orgs = await networkEnabledOrgs();
|
||||||
|
await Promise.all(orgs.map((orgId) => connectOrg(orgId)));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Temetro Network: failed to open hub connections: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up auth + device-response handlers for one clinic's hub socket.
|
||||||
|
function registerHubHandlers(orgId: string, hub: Socket): void {
|
||||||
|
// Authenticate by signing the relay's challenge with this clinic's signing
|
||||||
|
// key. `clinicId` is that key's public half (hex), which is how the relay
|
||||||
|
// identifies and routes to this clinic.
|
||||||
|
hub.on("hub:challenge", async (payload: { challenge?: string }) => {
|
||||||
|
const challenge = String(payload?.challenge ?? "");
|
||||||
|
if (!challenge) return;
|
||||||
|
try {
|
||||||
|
const { signature, publicKey } = await signWithClinicKey(
|
||||||
|
orgId,
|
||||||
|
new TextEncoder().encode(challenge),
|
||||||
|
);
|
||||||
|
hub.emit(
|
||||||
|
"hub:auth",
|
||||||
|
// `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 {
|
||||||
|
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
hub.on("connect_error", (err) => {
|
hub.on("connect_error", (err) => {
|
||||||
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}): ${err.message}`);
|
console.warn(`Temetro Network relay unreachable (${env.RELAY_URL}) for ${orgId}: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A device authenticated on the relay — flush any record updates it missed
|
// 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
|
// while offline (scoped to this clinic; the relay only delivers this to
|
||||||
// old /wallet namespace did on connect.
|
// clinics with pending work for the wallet).
|
||||||
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
|
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
|
||||||
const walletNumber = String(payload?.walletNumber ?? "");
|
const walletNumber = String(payload?.walletNumber ?? "");
|
||||||
if (!walletNumber) return;
|
if (!walletNumber) return;
|
||||||
try {
|
try {
|
||||||
const rows = await walletUpdates.pendingUpdatesForWallet(walletNumber);
|
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
sendToWallet(walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||||
await walletUpdates.markDelivered(row.id);
|
await walletUpdates.markDelivered(row.id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
|||||||
return mintKey(orgId, true);
|
return mintKey(orgId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
|
||||||
|
// when the clinic has no signing key yet (it hasn't opted in).
|
||||||
|
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
|
||||||
|
.from(clinicSigningKeys)
|
||||||
|
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||||
|
return row?.networkEnabled ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Org ids of every clinic currently on the network — used at startup to open a
|
||||||
|
// relay hub connection for each.
|
||||||
|
export async function networkEnabledOrgs(): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ organizationId: clinicSigningKeys.organizationId })
|
||||||
|
.from(clinicSigningKeys)
|
||||||
|
.where(eq(clinicSigningKeys.networkEnabled, true));
|
||||||
|
return rows.map((r) => r.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
|
||||||
|
// (the relay authenticates with it), then flips the flag. Returns the new state.
|
||||||
|
export async function setNetworkEnabled(
|
||||||
|
orgId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await getOrCreateKey(orgId);
|
||||||
|
await db
|
||||||
|
.update(clinicSigningKeys)
|
||||||
|
.set({ networkEnabled: enabled })
|
||||||
|
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||||
// the signature + public key so a verifier can check provenance.
|
// the signature + public key so a verifier can check provenance.
|
||||||
export async function signWithClinicKey(
|
export async function signWithClinicKey(
|
||||||
|
|||||||
@@ -144,7 +144,11 @@ export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
|
|||||||
|
|
||||||
// Every unresolved update for a wallet — re-sent on each authenticated connect
|
// Every unresolved update for a wallet — re-sent on each authenticated connect
|
||||||
// so an offline device eventually receives what it missed.
|
// 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(
|
export async function pendingUpdatesForWallet(
|
||||||
|
orgId: string,
|
||||||
walletNumber: string,
|
walletNumber: string,
|
||||||
): Promise<UpdateRow[]> {
|
): Promise<UpdateRow[]> {
|
||||||
return db
|
return db
|
||||||
@@ -152,6 +156,7 @@ export async function pendingUpdatesForWallet(
|
|||||||
.from(walletRecordUpdates)
|
.from(walletRecordUpdates)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
eq(walletRecordUpdates.organizationId, orgId),
|
||||||
eq(walletRecordUpdates.walletNumber, walletNumber),
|
eq(walletRecordUpdates.walletNumber, walletNumber),
|
||||||
isNull(walletRecordUpdates.resolvedAt),
|
isNull(walletRecordUpdates.resolvedAt),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
CopyField,
|
CopyField,
|
||||||
SettingsCard,
|
SettingsCard,
|
||||||
@@ -13,9 +14,11 @@ import {
|
|||||||
whiteButton,
|
whiteButton,
|
||||||
} from "@/components/settings/settings-parts";
|
} from "@/components/settings/settings-parts";
|
||||||
import {
|
import {
|
||||||
|
getNetworkEnabled,
|
||||||
getSigningKey,
|
getSigningKey,
|
||||||
listSignedRecords,
|
listSignedRecords,
|
||||||
rotateSigningKey,
|
rotateSigningKey,
|
||||||
|
setNetworkEnabled,
|
||||||
type SharedRecord,
|
type SharedRecord,
|
||||||
type SigningKey,
|
type SigningKey,
|
||||||
} from "@/lib/signing";
|
} from "@/lib/signing";
|
||||||
@@ -38,6 +41,8 @@ export function SigningPanel() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [rotating, setRotating] = useState(false);
|
const [rotating, setRotating] = useState(false);
|
||||||
|
const [networkOn, setNetworkOn] = useState(false);
|
||||||
|
const [networkSaving, setNetworkSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -45,12 +50,14 @@ export function SigningPanel() {
|
|||||||
getSigningKey(),
|
getSigningKey(),
|
||||||
listSignedRecords().catch(() => []),
|
listSignedRecords().catch(() => []),
|
||||||
listWalletUpdates().catch(() => []),
|
listWalletUpdates().catch(() => []),
|
||||||
|
getNetworkEnabled().catch(() => false),
|
||||||
])
|
])
|
||||||
.then(([k, r, u]) => {
|
.then(([k, r, u, n]) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setKey(k);
|
setKey(k);
|
||||||
setRecords(r);
|
setRecords(r);
|
||||||
setUpdates(u);
|
setUpdates(u);
|
||||||
|
setNetworkOn(n);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -83,6 +90,32 @@ export function SigningPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleNetwork = async (next: boolean) => {
|
||||||
|
setNetworkSaving(true);
|
||||||
|
// Optimistic — revert on failure.
|
||||||
|
setNetworkOn(next);
|
||||||
|
try {
|
||||||
|
const saved = await setNetworkEnabled(next);
|
||||||
|
setNetworkOn(saved);
|
||||||
|
notify.success(
|
||||||
|
next
|
||||||
|
? t("settings.network.joinedTitle")
|
||||||
|
: t("settings.network.leftTitle"),
|
||||||
|
next
|
||||||
|
? t("settings.network.joinedBody")
|
||||||
|
: t("settings.network.leftBody"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setNetworkOn(!next);
|
||||||
|
notify.error(
|
||||||
|
t("settings.network.errorTitle"),
|
||||||
|
t("settings.network.error"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setNetworkSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const recordStatusLabel = (status: SharedRecord["status"]): string =>
|
const recordStatusLabel = (status: SharedRecord["status"]): string =>
|
||||||
t(
|
t(
|
||||||
`settings.signing.records.status${
|
`settings.signing.records.status${
|
||||||
@@ -137,6 +170,41 @@ export function SigningPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
description={t("settings.network.description")}
|
||||||
|
title={t("settings.network.title")}
|
||||||
|
>
|
||||||
|
<SettingsCard className="flex items-center justify-between gap-4 p-5">
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t("settings.network.toggleLabel")}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
networkOn
|
||||||
|
? "bg-emerald-500/15 text-emerald-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{networkOn
|
||||||
|
? t("settings.network.statusConnected")
|
||||||
|
: t("settings.network.statusOff")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.network.toggleDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
aria-label={t("settings.network.toggleLabel")}
|
||||||
|
checked={networkOn}
|
||||||
|
disabled={loading || networkSaving}
|
||||||
|
onCheckedChange={toggleNetwork}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
description={t("settings.signing.identityDescription")}
|
description={t("settings.signing.identityDescription")}
|
||||||
title={t("settings.signing.identityTitle")}
|
title={t("settings.signing.identityTitle")}
|
||||||
|
|||||||
@@ -1996,6 +1996,20 @@
|
|||||||
"errorTitle": "تعذّر إضافة العضو"
|
"errorTitle": "تعذّر إضافة العضو"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "شبكة Temetro",
|
||||||
|
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
|
||||||
|
"toggleLabel": "الانضمام إلى شبكة Temetro",
|
||||||
|
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
|
||||||
|
"statusConnected": "متصل",
|
||||||
|
"statusOff": "معطّل",
|
||||||
|
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
|
||||||
|
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
|
||||||
|
"leftTitle": "تم مغادرة شبكة Temetro",
|
||||||
|
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
|
||||||
|
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
|
||||||
|
"error": "يرجى المحاولة مرة أخرى."
|
||||||
|
},
|
||||||
"signing": {
|
"signing": {
|
||||||
"keyTitle": "مفتاح التوقيع",
|
"keyTitle": "مفتاح التوقيع",
|
||||||
"active": "نشط",
|
"active": "نشط",
|
||||||
|
|||||||
@@ -1976,6 +1976,20 @@
|
|||||||
"errorTitle": "Mitglied konnte nicht hinzugefügt werden"
|
"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": {
|
"signing": {
|
||||||
"keyTitle": "Signierschlüssel",
|
"keyTitle": "Signierschlüssel",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
|
|||||||
@@ -1976,6 +1976,20 @@
|
|||||||
"errorTitle": "Could not add member"
|
"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": {
|
"signing": {
|
||||||
"keyTitle": "Signing key",
|
"keyTitle": "Signing key",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -1976,6 +1976,20 @@
|
|||||||
"errorTitle": "Impossible d'ajouter le membre"
|
"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": {
|
"signing": {
|
||||||
"keyTitle": "Clé de signature",
|
"keyTitle": "Clé de signature",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -1976,6 +1976,20 @@
|
|||||||
"errorTitle": "Xubinta lama ku dari karin"
|
"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": {
|
"signing": {
|
||||||
"keyTitle": "Furaha saxiixa",
|
"keyTitle": "Furaha saxiixa",
|
||||||
"active": "Firfircoon",
|
"active": "Firfircoon",
|
||||||
|
|||||||
@@ -38,3 +38,21 @@ export async function rotateSigningKey(): Promise<SigningKey> {
|
|||||||
export async function listSignedRecords(): Promise<SharedRecord[]> {
|
export async function listSignedRecords(): Promise<SharedRecord[]> {
|
||||||
return apiFetch<SharedRecord[]>("/api/signing/records");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temetro",
|
"name": "temetro",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"shadcn": "^4.11.0"
|
"shadcn": "^4.11.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user