temetro is in beta and under active development. Expect things to change.
temetro

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

MethodPathPermission
GET/api/signing/keypatient:read
POST/api/signing/key/rotateorganization:update (owner/admin)
GET/api/signing/recordspatient:read
GET/api/signing/networkpatient:read
PUT/api/signing/networkorganization: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/network with { "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.

MethodEndpointPermission
GET/api/clinic/settingspatient:read
PUT/api/clinic/locationorganization: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

MethodPathPermission
POST/api/patients/wallet/request-sharepatient:write
POST/api/patients/wallet/pairpatient:write
GET/api/patients/wallet/request-share/{id}patient:read
POST/api/patients/wallet/request-share/{id}/commitpatient:write

There are two ways to start an import:

  • By wallet numberrequest-share (above): the clinician types the patient's tmw_… number and the backend relays the request to that device.
  • By QRPOST /api/patients/wallet/pair returns { id, ephemeralPubKey } with no wallet number. The clinic encodes its relay URL + id + ephemeralPubKey into a temetro-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 (pendingapproved / 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.

MethodPathAuthReturns
GET/api/patients/wallet/link/{fileNumber}patient:read{ walletNumber } when the patient is wallet-linked, else 404
POST/api/patients/wallet/pushpatient:writeCreates + sends an update; body { fileNumber, changes: string[] }
GET/api/patients/wallet/updatespatient:readThe clinic's recent update pushes + their status
GET/api/patients/wallet/updates/{id}patient:readOne 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.)

On this page