mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01dbc07e92 | |||
| 233ce9f854 | |||
| 99aa534e88 | |||
| ef76afc3ca |
@@ -7,6 +7,72 @@ for how releases are cut and published.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.2] — 2026-07-05
|
||||
|
||||
### Fixed
|
||||
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
|
||||
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
|
||||
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
|
||||
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
|
||||
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
|
||||
`RELAY_URL`. Updated `.env.example` accordingly.
|
||||
|
||||
### Changed
|
||||
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
|
||||
connected before pre-registering the request, so the routing is set up even if the connection was
|
||||
opened lazily.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
### Added
|
||||
- **Temetro Network** — a standalone, high-performance **relay** (Rust + Axum + socketioxide) that
|
||||
connects the backend to patient wallet apps, in its own repo
|
||||
([github.com/temetro/temetro-network](https://github.com/temetro/temetro-network)) and deployable
|
||||
on Railway. It replaces the flaky Cloudflare quick-tunnel that used to expose the backend's
|
||||
embedded `/wallet` Socket.io namespace to phones. The relay is a **dumb, stateless pipe**: a
|
||||
`/wallet` namespace for devices (challenge/Ed25519-signature auth, room keyed by wallet number)
|
||||
and a `RELAY_TOKEN`-authenticated `/hub` namespace for the backend. It forwards sealed ciphertext
|
||||
verbatim, keeps no database, and its only crypto is verifying a device's auth signature (proven
|
||||
byte-for-byte compatible with `wallet-crypto.ts`).
|
||||
|
||||
### Changed
|
||||
- **The backend is now a client of the relay, not the wallet server.** The `/wallet` Socket.io
|
||||
namespace was removed from `src/realtime.ts`; a new `src/services/relay-client.ts` connects to the
|
||||
relay's `/hub` (`emitToWallet` delegates to its `sendToWallet`), handles device responses
|
||||
(`wallet:share-response` / `wallet:update-response` / `wallet:revoke`) and flushes missed updates on
|
||||
`wallet:online` — calling the same `wallet-share` / `wallet-updates` services as before. New
|
||||
`RELAY_URL` + `RELAY_TOKEN` env vars; the wallet-import QR now points at `RELAY_URL`.
|
||||
|
||||
## [0.6.0] — 2026-07-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -27,12 +27,13 @@ repository (published as `temetro`).
|
||||
> "Patient wallet app" below) and an end-to-end **encrypted share / patient-approval** flow:
|
||||
> clinics hold a real **Ed25519 signing key** (Settings → Signing, `backend/src/services/signing.ts`),
|
||||
> and "Import from a patient app" on the Patients page relays an encrypted request to the wallet over
|
||||
> a **`/wallet` Socket.io namespace**, the patient approves on their phone, and the sealed record is
|
||||
> imported (with optional **temporary share + auto-delete**). See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
> the **Temetro Network** relay (see below), the patient approves on their phone, and the sealed record
|
||||
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
|
||||
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
|
||||
>
|
||||
> **Still vision, not built:** clinic→wallet push of signed record updates, in-app record editing,
|
||||
> QR pairing, and cryptographic time-boxing of temporary shares. The AI chat is still **mock replies**.
|
||||
> Email verification is wired but currently **not enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
|
||||
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
|
||||
> enforced** at sign-in (see `backend/CLAUDE.md`).
|
||||
|
||||
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
|
||||
|
||||
@@ -47,6 +48,28 @@ here means keys + data live on the patient's device and the relay only ever forw
|
||||
is **not** a literal blockchain (records are off-chain, which is also what lets a temporary share be
|
||||
deleted). Commit/push that app inside its own repo, separately from this one.
|
||||
|
||||
## Temetro Network (sibling repo/folder `~/Desktop/Temetro-network`)
|
||||
|
||||
The **relay** that connects this backend to patient wallet apps. It is its **own git repo** on the
|
||||
Desktop (folder `~/Desktop/Temetro-network`, pushed to `github.com/temetro/temetro-network`), **not**
|
||||
in this monorepo — a standalone **Rust + Axum + socketioxide** service meant to run always-on (e.g.
|
||||
on **Railway**). It replaces the old flaky Cloudflare quick-tunnel that used to expose the backend's
|
||||
embedded `/wallet` Socket.io namespace to phones.
|
||||
|
||||
It is a **dumb, stateless pipe**: two Socket.io namespaces — `/wallet` for devices
|
||||
(challenge/Ed25519-signature auth, room keyed by wallet number) and `/hub` for this backend
|
||||
(`RELAY_TOKEN`-authenticated). Devices and the backend both connect to it; it **forwards sealed
|
||||
ciphertext verbatim** and never opens bundles or touches a database. Its only crypto is verifying a
|
||||
device's auth signature (mirrors `backend/src/lib/wallet-crypto.ts`). The backend connects to it as a
|
||||
`/hub` client via `backend/src/services/relay-client.ts` (its `sendToWallet` is what `emitToWallet`
|
||||
now calls); configure with `RELAY_URL` + `RELAY_TOKEN`. Commit/push that service inside its own repo,
|
||||
separately from this one.
|
||||
|
||||
> **Note:** in this sandbox the `~/Desktop/Temetro-network` folder blocks directory enumeration
|
||||
> (`ls`/`getcwd`/git inside it return EPERM) though plain file writes work. Develop/build/commit it
|
||||
> in an accessible copy and mirror the tree in with `tar`; drive git there via
|
||||
> `GIT_DIR`/`GIT_WORK_TREE` from an accessible cwd.
|
||||
|
||||
## Layout
|
||||
|
||||
`frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged
|
||||
|
||||
+17
-7
@@ -32,13 +32,23 @@ POSTGRES_PORT=5432
|
||||
BACKEND_PORT=4000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# --- Patient wallet relay -------------------------------------------------
|
||||
# The URL baked into the QR a patient scans to import their record. Their phone
|
||||
# must be able to reach it — so localhost will NOT work from a real device. If
|
||||
# unset, the backend derives it from the request host (fine when you open the
|
||||
# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it
|
||||
# explicitly to a phone-reachable address: your machine's LAN IP or a public
|
||||
# tunnel URL.
|
||||
# --- Temetro Network relay ------------------------------------------------
|
||||
# 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 and the wallet app at it. RELAY_URL is the relay's public URL (also
|
||||
# baked into the QR a patient scans). This clinic authenticates to the relay's
|
||||
# /hub with its own Ed25519 signing key, so no shared secret is needed.
|
||||
# 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).
|
||||
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
|
||||
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
|
||||
# your own relay. Do NOT use http://localhost — inside Docker that's the
|
||||
# container itself and the relay connection will silently fail.
|
||||
RELAY_URL=https://network.temetro.com
|
||||
RELAY_TOKEN=
|
||||
|
||||
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
|
||||
# using the Temetro Network relay. RELAY_URL takes precedence over this.
|
||||
# PUBLIC_RELAY_URL=http://192.168.1.20:4000
|
||||
|
||||
# --- Email (optional) -----------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,18 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
|
||||
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
|
||||
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
|
||||
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
|
||||
- **Patient-wallet relay** is **no longer hosted here.** Devices connect to the standalone **Temetro
|
||||
Network** service (`~/Desktop/Temetro-network`, see root `CLAUDE.md`), which is **multi-clinic**.
|
||||
This backend connects to it as a `/hub` client in **`src/services/relay-client.ts`**, keeping **one
|
||||
authenticated connection per network-enabled org** (`hubs` map keyed by `orgId`). Each org
|
||||
authenticates by signing the relay's `hub:challenge` with its clinic signing key
|
||||
(`signWithClinicKey`) — no shared `RELAY_TOKEN` needed (it's now optional/legacy, only for a
|
||||
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.
|
||||
|
||||
## Gotchas / conventions
|
||||
|
||||
@@ -68,6 +68,11 @@ services:
|
||||
BETTER_AUTH_URL: http://localhost:4000
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
PORT: "4000"
|
||||
# Temetro Network relay (github.com/temetro/temetro-network). Set RELAY_URL
|
||||
# to your deployed relay's public URL and RELAY_TOKEN to the shared secret
|
||||
# you configured on it — both are required for patient-wallet import.
|
||||
RELAY_URL: ${RELAY_URL:-}
|
||||
RELAY_TOKEN: ${RELAY_TOKEN:-}
|
||||
NODE_ENV: production
|
||||
# Uploaded patient/lab files live here, on the temetro_uploads volume.
|
||||
UPLOAD_DIR: /var/lib/temetro/uploads
|
||||
|
||||
@@ -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,
|
||||
"tag": "0031_stiff_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1783263738631,
|
||||
"tag": "0032_closed_dakota_north",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+60
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "temetro-backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.84",
|
||||
@@ -28,6 +28,7 @@
|
||||
"nodemailer": "^8.0.10",
|
||||
"pg": "^8.21.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3339,6 +3340,40 @@
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
@@ -4947,6 +4982,21 @@
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
@@ -5779,6 +5829,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro-backend",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
|
||||
@@ -41,6 +41,7 @@
|
||||
"nodemailer": "^8.0.10",
|
||||
"pg": "^8.21.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -16,6 +16,11 @@ export const clinicSigningKeys = pgTable("clinic_signing_keys", {
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
// Encrypted (lib/crypto.ts) hex of the Ed25519 private key.
|
||||
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(),
|
||||
rotatedAt: timestamp("rotated_at"),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,19 @@ const schema = z.object({
|
||||
// Overrides the version reported by GET /api/version. Normally derived from
|
||||
// package.json; the release pipeline can pin it explicitly.
|
||||
APP_VERSION: z.string().optional(),
|
||||
// Temetro Network relay (github.com/temetro/temetro-network). Both this
|
||||
// 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
|
||||
// the QR a patient scans). Each clinic authenticates to the relay's /hub with
|
||||
// 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).
|
||||
//
|
||||
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
|
||||
// override only when running your own relay. (A `localhost` default silently
|
||||
// fails inside Docker, where localhost is the container itself.)
|
||||
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
|
||||
RELAY_TOKEN: z.string().default(""),
|
||||
// Public, device-reachable URL of this backend's wallet relay, baked into the
|
||||
// QR a patient scans. Optional — when unset we derive it from the request host
|
||||
// (so opening the web app over the LAN yields a reachable LAN URL).
|
||||
@@ -80,6 +93,9 @@ if (env.NODE_ENV === "production") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// RELAY_TOKEN is optional now: clinics authenticate to the relay with their
|
||||
// own Ed25519 signing key, so an unset token is the normal "open relay" case —
|
||||
// no warning needed.
|
||||
}
|
||||
|
||||
export const isProd = env.NODE_ENV === "production";
|
||||
|
||||
@@ -36,6 +36,7 @@ import { staffRouter } from "./routes/staff.js";
|
||||
import { networkRouter } from "./routes/network.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { versionRouter } from "./routes/version.js";
|
||||
import { initRelayClient } from "./services/relay-client.js";
|
||||
import { beginQuickTunnelDiscovery } from "./services/relay-url.js";
|
||||
import { sweepExpiredShares } from "./services/wallet-share.js";
|
||||
|
||||
@@ -123,6 +124,11 @@ app.use(errorHandler);
|
||||
const server = createServer(app);
|
||||
initRealtime(server);
|
||||
|
||||
// Connect to the Temetro Network relay (the device-facing hub). Patient phones
|
||||
// no longer connect to this backend directly — they connect to the relay, and
|
||||
// we push to / receive from them over its /hub namespace.
|
||||
initRelayClient();
|
||||
|
||||
// Sweep expired temporary patient-wallet shares (auto-delete) every 5 minutes.
|
||||
const SHARE_SWEEP_INTERVAL = 5 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
@@ -154,7 +160,7 @@ server.listen(env.PORT, () => {
|
||||
console.log(` • portal: /api/portal (public clinic kiosk)`);
|
||||
console.log(` • fhir: /fhir (read-only FHIR R4 server, API-key auth)`);
|
||||
console.log(` • signing: /api/signing (Ed25519 clinic key)`);
|
||||
console.log(` • wallet: /api/patients/wallet (+ /wallet socket relay)`);
|
||||
console.log(` • wallet: /api/patients/wallet (via Temetro Network relay: ${env.RELAY_URL})`);
|
||||
});
|
||||
|
||||
// Dockerized off-network testing: learn our public Cloudflare quick-tunnel URL
|
||||
|
||||
+8
-155
@@ -1,17 +1,14 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { Server, type Socket } from "socket.io";
|
||||
|
||||
import { auth } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { decodeWalletNumber, verifySignature } from "./lib/wallet-crypto.js";
|
||||
import * as meetings from "./services/meetings.js";
|
||||
import * as messaging from "./services/messaging.js";
|
||||
import { createNotification } from "./services/notifications.js";
|
||||
import * as walletShare from "./services/wallet-share.js";
|
||||
import * as walletUpdates from "./services/wallet-updates.js";
|
||||
import { sendToWallet } from "./services/relay-client.js";
|
||||
import type { MessageAttachment } from "./types/messaging.js";
|
||||
|
||||
let io: Server | null = null;
|
||||
@@ -20,7 +17,6 @@ const userRoom = (userId: string) => `user:${userId}`;
|
||||
const convRoom = (conversationId: string) => `conv:${conversationId}`;
|
||||
const callRoom = (roomId: string) => `call:${roomId}`;
|
||||
const orgRoom = (orgId: string) => `org:${orgId}`;
|
||||
const walletRoom = (walletNumber: string) => `wallet:${walletNumber}`;
|
||||
|
||||
// Mesh WebRTC tops out around four peers (each sends its stream to every other);
|
||||
// past that the room is closed to new joiners.
|
||||
@@ -44,15 +40,18 @@ export function emitToConversation(
|
||||
io?.to(convRoom(conversationId)).emit(event, data);
|
||||
}
|
||||
|
||||
// Relay an end-to-end-encrypted message to a patient wallet device (the /wallet
|
||||
// namespace, room keyed by wallet number). The relay only ever forwards
|
||||
// ciphertext — it cannot read the record bundle.
|
||||
// Relay an end-to-end-encrypted message to a patient wallet device. Devices no
|
||||
// longer connect to this server directly — they connect to the standalone
|
||||
// Temetro Network relay, which forwards to the room keyed by wallet number. We
|
||||
// push over the relay's /hub namespace (see services/relay-client.ts). The
|
||||
// relay only ever forwards ciphertext — it cannot read the record bundle.
|
||||
export function emitToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
io?.of("/wallet").to(walletRoom(walletNumber)).emit(event, data);
|
||||
sendToWallet(orgId, walletNumber, event, data);
|
||||
}
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
@@ -286,151 +285,5 @@ export function initRealtime(httpServer: HttpServer): Server {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Patient wallet relay (/wallet namespace) ----------------------------
|
||||
// Devices have no clinic session, so this namespace is NOT cookie-gated.
|
||||
// Instead a device proves control of its wallet keypair: the server issues a
|
||||
// random challenge, the device signs it with its Ed25519 key, and only then
|
||||
// may it join its own wallet room. The relay forwards encrypted share
|
||||
// requests/responses without ever reading the record bundle.
|
||||
const walletNs = io.of("/wallet");
|
||||
walletNs.on("connection", (socket: Socket) => {
|
||||
const challenge = bytesToHex(randomBytes(32));
|
||||
socket.data.challenge = challenge;
|
||||
socket.data.walletNumber = null as string | null;
|
||||
socket.emit("wallet:challenge", { challenge });
|
||||
|
||||
socket.on(
|
||||
"wallet:auth",
|
||||
(payload: { walletNumber?: string; signature?: string }, ack?: Ack) => {
|
||||
try {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
const signature = String(payload?.signature ?? "");
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
const ok = verifySignature(
|
||||
publicKey,
|
||||
signature,
|
||||
utf8ToBytes(socket.data.challenge as string),
|
||||
);
|
||||
if (!ok) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
socket.data.walletNumber = walletNumber;
|
||||
socket.join(walletRoom(walletNumber));
|
||||
ack?.({ ok: true });
|
||||
// Deliver any record updates the device missed while offline. Sent
|
||||
// after the ack so the client is ready to receive them.
|
||||
void walletUpdates
|
||||
.pendingUpdatesForWallet(walletNumber)
|
||||
.then(async (rows) => {
|
||||
for (const row of rows) {
|
||||
socket.emit("wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a clinic→wallet record update on their device.
|
||||
// We verify the wallet's signature over the decision and resolve the row.
|
||||
socket.on(
|
||||
"wallet:update-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletUpdates.applyUpdateResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share on their device; the sealed bundle (if
|
||||
// approved) rides along and is decrypted + verified server-side.
|
||||
socket.on(
|
||||
"wallet:share-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
sealed?: string;
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const view = await walletShare.applyShareResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.sealed,
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient revoked a previously shared record; delete it from the clinic.
|
||||
socket.on(
|
||||
"wallet:revoke",
|
||||
async (
|
||||
payload: { requestId?: string; walletNumber?: string },
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
if (
|
||||
!socket.data.walletNumber ||
|
||||
socket.data.walletNumber !== payload?.walletNumber
|
||||
) {
|
||||
ack?.({ ok: false });
|
||||
return;
|
||||
}
|
||||
const result = await walletShare.revokeShare(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
);
|
||||
ack?.({ ok: !!result });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
} from "../middleware/auth.js";
|
||||
import { emitToWallet } from "../realtime.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, 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";
|
||||
import * as walletShare from "../services/wallet-share.js";
|
||||
import * as walletUpdates from "../services/wallet-updates.js";
|
||||
|
||||
@@ -26,10 +28,25 @@ export const patientsWalletRouter = Router();
|
||||
|
||||
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
|
||||
// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request
|
||||
// host so that opening the web app over the LAN yields a reachable LAN URL.
|
||||
// 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
|
||||
// PUBLIC_RELAY_URL / cloudflared / request-host fallbacks remain for pre-relay
|
||||
// self-hosting.
|
||||
async function resolveRelayUrl(req: Request): Promise<string> {
|
||||
if (env.RELAY_URL) return env.RELAY_URL;
|
||||
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
||||
// A cloudflared quick tunnel (`npm run docker:tunnel`). Wait briefly for it to
|
||||
// become reachable so the QR never carries a not-yet-live URL.
|
||||
@@ -68,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!,
|
||||
@@ -75,6 +93,12 @@ 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.
|
||||
// Ensure the hub is (re)connected first; if it's still mid-handshake the
|
||||
// on-auth re-registration of pending requests will catch this one.
|
||||
await connectOrg(req.organizationId!);
|
||||
expectResponse(req.organizationId!, view.id);
|
||||
res.status(201).json({
|
||||
...view,
|
||||
ephemeralPubKey,
|
||||
@@ -94,6 +118,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = requestSchema.parse(req.body);
|
||||
const { view, ephemeralPubKey } = await walletShare.createShareRequest(
|
||||
req.organizationId!,
|
||||
@@ -106,7 +131,7 @@ patientsWalletRouter.post(
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, req.organizationId!));
|
||||
emitToWallet(input.walletNumber, "wallet:share-request", {
|
||||
emitToWallet(req.organizationId!, input.walletNumber, "wallet:share-request", {
|
||||
requestId: view.id,
|
||||
clinicName: org?.name ?? "A clinic",
|
||||
requestedBy: req.user!.name,
|
||||
@@ -173,6 +198,7 @@ patientsWalletRouter.post(
|
||||
requirePermission({ patient: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await requireNetwork(req.organizationId!);
|
||||
const input = pushSchema.parse(req.body);
|
||||
const row = await walletUpdates.createRecordUpdate(
|
||||
req.organizationId!,
|
||||
@@ -181,7 +207,7 @@ patientsWalletRouter.post(
|
||||
input.changes,
|
||||
);
|
||||
const event = await walletUpdates.toEvent(row);
|
||||
emitToWallet(row.walletNumber, "wallet:update-request", event);
|
||||
emitToWallet(req.organizationId!, row.walletNumber, "wallet:update-request", event);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
requireAuth,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import { connectOrg, disconnectOrg } from "../services/relay-client.js";
|
||||
import * as signing from "../services/signing.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
|
||||
// list.
|
||||
signingRouter.get(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// Client connection to the Temetro Network relay
|
||||
// (github.com/temetro/temetro-network), a standalone Rust service that routes
|
||||
// encrypted wallet messages between this backend and patient phones.
|
||||
//
|
||||
// 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
|
||||
// `/hub` namespace: it pushes messages to devices via `sendToWallet` and handles
|
||||
// their responses here, calling the same wallet service functions the old socket
|
||||
// handlers did. Sealed bundles are decrypted here (we hold the ephemeral key);
|
||||
// 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 { env } from "../env.js";
|
||||
import { networkEnabledOrgs, signWithClinicKey } from "./signing.js";
|
||||
import * as walletShare from "./wallet-share.js";
|
||||
import * as walletUpdates from "./wallet-updates.js";
|
||||
|
||||
// One authenticated hub connection per network-enabled organization.
|
||||
const hubs = new Map<string, Socket>();
|
||||
|
||||
type Ack = (response: { ok: boolean; [key: string]: unknown }) => void;
|
||||
|
||||
// Push an end-to-end-encrypted message to a patient wallet device via the given
|
||||
// clinic's relay connection (the relay forwards it to the room keyed by wallet
|
||||
// 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`).
|
||||
export function sendToWallet(
|
||||
orgId: string,
|
||||
walletNumber: string,
|
||||
event: string,
|
||||
data: unknown,
|
||||
): void {
|
||||
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, when an org joins the network, and
|
||||
// before generating a pairing QR. The socket auto-reconnects on its own, so an
|
||||
// existing entry is left as-is.
|
||||
export async function connectOrg(orgId: string): Promise<void> {
|
||||
if (hubs.has(orgId)) return;
|
||||
|
||||
const hub = connect(`${env.RELAY_URL}/hub`, {
|
||||
transports: ["websocket"],
|
||||
reconnection: true,
|
||||
reconnectionDelayMax: 10_000,
|
||||
});
|
||||
hubs.set(orgId, hub);
|
||||
registerHubHandlers(orgId, hub);
|
||||
}
|
||||
|
||||
// Leave the network for a clinic: close and forget its hub connection.
|
||||
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 },
|
||||
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 */
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Temetro Network: failed to sign relay challenge for ${orgId}: ${(err as Error).message}`);
|
||||
}
|
||||
});
|
||||
|
||||
hub.on("connect_error", (err) => {
|
||||
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
|
||||
// while offline (scoped to this clinic; the relay only delivers this to
|
||||
// clinics with pending work for the wallet).
|
||||
hub.on("wallet:online", async (payload: { walletNumber?: string }) => {
|
||||
const walletNumber = String(payload?.walletNumber ?? "");
|
||||
if (!walletNumber) return;
|
||||
try {
|
||||
const rows = await walletUpdates.pendingUpdatesForWallet(orgId, walletNumber);
|
||||
for (const row of rows) {
|
||||
sendToWallet(orgId, walletNumber, "wallet:update-request", await walletUpdates.toEvent(row));
|
||||
await walletUpdates.markDelivered(row.id);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
// The patient approved/denied a clinic→wallet record update. Verify the
|
||||
// wallet's signature over the decision and resolve the row.
|
||||
hub.on(
|
||||
"wallet:update-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const view = await walletUpdates.applyUpdateResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient approved/denied a share; the sealed bundle (if approved) rides
|
||||
// along and is decrypted + verified here.
|
||||
hub.on(
|
||||
"wallet:share-response",
|
||||
async (
|
||||
payload: {
|
||||
requestId?: string;
|
||||
walletNumber?: string;
|
||||
decision?: "approved" | "denied";
|
||||
sealed?: string;
|
||||
signature?: string;
|
||||
},
|
||||
ack?: Ack,
|
||||
) => {
|
||||
try {
|
||||
const view = await walletShare.applyShareResponse(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
payload?.decision === "approved" ? "approved" : "denied",
|
||||
payload?.sealed,
|
||||
payload?.signature,
|
||||
);
|
||||
ack?.({ ok: !!view });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: (err as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The patient revoked a previously shared record; delete it from the clinic.
|
||||
hub.on(
|
||||
"wallet:revoke",
|
||||
async (payload: { requestId?: string; walletNumber?: string }, ack?: Ack) => {
|
||||
try {
|
||||
const result = await walletShare.revokeShare(
|
||||
String(payload?.requestId ?? ""),
|
||||
String(payload?.walletNumber ?? ""),
|
||||
);
|
||||
ack?.({ ok: !!result });
|
||||
} catch {
|
||||
ack?.({ ok: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,40 @@ export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
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
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
CopyField,
|
||||
SettingsCard,
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
whiteButton,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import {
|
||||
getNetworkEnabled,
|
||||
getSigningKey,
|
||||
listSignedRecords,
|
||||
rotateSigningKey,
|
||||
setNetworkEnabled,
|
||||
type SharedRecord,
|
||||
type SigningKey,
|
||||
} from "@/lib/signing";
|
||||
@@ -38,6 +41,8 @@ export function SigningPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [networkOn, setNetworkOn] = useState(false);
|
||||
const [networkSaving, setNetworkSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -45,12 +50,14 @@ export function SigningPanel() {
|
||||
getSigningKey(),
|
||||
listSignedRecords().catch(() => []),
|
||||
listWalletUpdates().catch(() => []),
|
||||
getNetworkEnabled().catch(() => false),
|
||||
])
|
||||
.then(([k, r, u]) => {
|
||||
.then(([k, r, u, n]) => {
|
||||
if (!active) return;
|
||||
setKey(k);
|
||||
setRecords(r);
|
||||
setUpdates(u);
|
||||
setNetworkOn(n);
|
||||
setError(null);
|
||||
})
|
||||
.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 =>
|
||||
t(
|
||||
`settings.signing.records.status${
|
||||
@@ -137,6 +170,41 @@ export function SigningPanel() {
|
||||
</div>
|
||||
</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
|
||||
description={t("settings.signing.identityDescription")}
|
||||
title={t("settings.signing.identityTitle")}
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"expiredTitle": "انتهت صلاحية الطلب",
|
||||
"expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.",
|
||||
"invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.",
|
||||
"networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.",
|
||||
"errorTitle": "تعذّر الوصول إلى المحفظة",
|
||||
"error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.",
|
||||
"savedTitle": "تم استيراد المريض",
|
||||
@@ -1996,6 +1997,20 @@
|
||||
"errorTitle": "تعذّر إضافة العضو"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "شبكة Temetro",
|
||||
"description": "يتيح مُرحّل شبكة Temetro لتطبيقات محفظة المرضى الاتصال بعيادتك لمشاركة السجلات والموافقة عليها — مشفّرة من طرف إلى طرف، مع تعريف عيادتك بمفتاح التوقيع الخاص بها.",
|
||||
"toggleLabel": "الانضمام إلى شبكة Temetro",
|
||||
"toggleDesc": "عند التفعيل، تتصل هذه العيادة بالمُرحّل لاستيراد السجلات من تطبيقات المرضى وإرسال التحديثات إلى محافظهم.",
|
||||
"statusConnected": "متصل",
|
||||
"statusOff": "معطّل",
|
||||
"joinedTitle": "تم الانضمام إلى شبكة Temetro",
|
||||
"joinedBody": "يمكن الآن لتطبيقات محفظة المرضى الاتصال بهذه العيادة.",
|
||||
"leftTitle": "تم مغادرة شبكة Temetro",
|
||||
"leftBody": "لم تعد هذه العيادة متصلة بالمُرحّل.",
|
||||
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
|
||||
"error": "يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"signing": {
|
||||
"keyTitle": "مفتاح التوقيع",
|
||||
"active": "نشط",
|
||||
|
||||
@@ -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",
|
||||
@@ -1976,6 +1977,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1976,6 +1977,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",
|
||||
|
||||
@@ -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é",
|
||||
@@ -1976,6 +1977,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -1976,6 +1977,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temetro",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadcn": "^4.11.0"
|
||||
|
||||
Reference in New Issue
Block a user