Signing & wallet import
The clinic signing key and the encrypted "import from a patient app" flow.
Each clinic holds an Ed25519 signing key, and patients can share their record from the patient wallet app over an end-to-end-encrypted relay. The clinic never reads a record until the patient approves the share on their phone.
Signing key
| Method | Path | Permission |
|---|---|---|
GET | /api/signing/key | patient:read |
POST | /api/signing/key/rotate | organization:update (owner/admin) |
GET | /api/signing/records | patient:read |
GET | /api/signing/network | patient:read |
PUT | /api/signing/network | organization:update (owner/admin) |
The key is created lazily on first read, so GET /api/signing/key always returns a real key:
{
"algorithm": "ed25519",
"publicKey": "b1f0…",
"fingerprint": "ed25519:9f86 d081 884c 7d65 …",
"createdAt": "2026-06-21T10:00:00.000Z",
"rotatedAt": null
}The private key is stored encrypted at rest (the same AI_CREDENTIALS_KEY-derived key used for
provider secrets). Rotating mints a new keypair and stamps rotatedAt.
Join the Temetro Network
Wallet sharing rides the Temetro Network relay, which a clinic opts into.
GET /api/signing/network→{ "enabled": boolean }— whether this clinic has joined.PUT /api/signing/networkwith{ "enabled": true | false }— join or leave (owner/admin only).
Joining opens this clinic's authenticated relay connection (it signs the relay's challenge with the signing key above — no shared secret needed); leaving tears it down. While disabled, the import/push endpoints below return 409. The flag is off by default.
Clinic location
Set in Settings → Signing → Clinic location. Stored per clinic (organization) and surfaced to patients in the wallet app.
| Method | Endpoint | Permission |
|---|---|---|
GET | /api/clinic/settings | patient:read |
PUT | /api/clinic/location | organization:update (owner/admin) |
GET /api/clinic/settings returns the current location (empty strings / null until set):
{
"location": {
"address": "KM4, Maka Al Mukarama Rd",
"city": "Mogadishu",
"country": "Somalia",
"latitude": 2.0371,
"longitude": 45.3438
}
}PUT /api/clinic/location takes the same location fields. address / city / country are free
text; latitude (−90…90) and longitude (−180…180) are optional map coordinates (null to clear).
Import from a patient app
| Method | Path | Permission |
|---|---|---|
POST | /api/patients/wallet/request-share | patient:write |
POST | /api/patients/wallet/pair | patient:write |
GET | /api/patients/wallet/request-share/{id} | patient:read |
POST | /api/patients/wallet/request-share/{id}/commit | patient:write |
There are two ways to start an import:
- By wallet number —
request-share(above): the clinician types the patient'stmw_…number and the backend relays the request to that device. - By QR —
POST /api/patients/wallet/pairreturns{ id, ephemeralPubKey }with no wallet number. The clinic encodes its relay URL +id+ephemeralPubKeyinto atemetro-pair:?relay=…&rid=…&epk=…QR; the patient scans it in the app, which connects to that relay and approves. The wallet number is bound when the authenticated device responds. Polling and commit are identical for both.
Start a share request
POST /api/patients/wallet/request-share
{ "walletNumber": "tmw_…", "mode": "temporary", "durationHours": 24 }The backend validates the wallet number, mints a per-request ephemeral X25519 keypair, stores
the request, and relays a wallet:share-request event to the device over the /wallet Socket.io
namespace. Returns the pending request ({ id, status: "pending", shareMode, shareExpiresAt }).
Poll the request
GET /api/patients/wallet/request-share/{id} returns the current status
(pending → approved / denied / expired) and, once approved, the decrypted draft record.
When the patient approves on their phone, the app signs the record bundle with its wallet key and seals it to the request's ephemeral public key; the backend decrypts it, verifies the signature against the wallet number, and caches the draft.
Commit the draft
POST /api/patients/wallet/request-share/{id}/commit takes the (optionally clinician-edited)
Patient body and creates the record. For a temporary share the deadline (shareExpiresAt) is
applied server-side from the original request; a scheduled sweep hard-deletes the record when it
passes, and the patient can revoke earlier from the app.
Decentralization, honestly
The relay only ever forwards ciphertext — keys and the record live on the patient's device. This is the WalletConnect-style model, not a literal blockchain: records stay off-chain, which is exactly what lets a temporary share be deleted on expiry.
Clinic → wallet record updates
For a patient imported from a wallet as a permanent share, the clinic can push an updated record back to the patient's app, where it stays pending until the patient approves it.
| Method | Path | Auth | Returns |
|---|---|---|---|
GET | /api/patients/wallet/link/{fileNumber} | patient:read | { walletNumber } when the patient is wallet-linked, else 404 |
POST | /api/patients/wallet/push | patient:write | Creates + sends an update; body { fileNumber, changes: string[] } |
GET | /api/patients/wallet/updates | patient:read | The clinic's recent update pushes + their status |
GET | /api/patients/wallet/updates/{id} | patient:read | One update (poll for approval) |
POST /push loads the current record snapshot, signs the bundle with the clinic's Ed25519 key,
seals it to the wallet's X25519 key (derived from its Ed25519 wallet number), and stores it
pending. The relay delivers a wallet:update-request event immediately if the device is
connected, and re-sends any unresolved updates on the wallet's next authenticated connect (so an
offline phone catches up). The patient approves/denies in-app; the wallet signs its decision, the
backend verifies it, and status moves to approved / denied.
What's in the sealed bundle
The plaintext the wallet opens is:
{
"patient": { "…the canonical Patient snapshot…" },
"appointments": [],
"invoices": [],
"prescriptions": [],
"documents": [{ "id": "…", "filename": "…", "mimeType": "…", "sizeBytes": 0, "createdAt": "…" }],
"changes": ["New prescription: Amoxicillin 500mg"]
}Appointments, invoices and prescriptions live in their own tables rather than on the Patient
snapshot, so each is pulled for this patient and shipped alongside it — the wallet has no other way
to see them. documents carries metadata only; the bytes stay on the clinic and the app fetches
them on demand through the portal's result-file action, keyed by the same
attachment id.
changes is display text for the approval screen. It is not data — the patient's record is
updated from the fields above, so anything summarised in changes has to be present in the bundle
to actually reach them.
Trust on first use
The pushed update carries the clinic's clinicId, public key and fingerprint. The wallet pins
the key against the clinic's id on first contact and warns the patient if a later update
from that clinic is signed with a different key — v1-grade trust ahead of full key-transparency.
(Pinning against the clinic's display name would collide whenever two clinics share a name, or
break whenever one is renamed.)