feat(backend): public relay for off-network wallet scanning

Scan-to-connect only worked on the same Wi-Fi because the QR carried a LAN
relay URL. Give the relay a public address two ways:

- `npm run dev:tunnel` (scripts/dev-tunnel.mjs) opens a cloudflared tunnel
  to localhost:4000 and starts the backend with PUBLIC_RELAY_URL set to the
  public https URL, so the QR carries it and a phone on any network connects.
- Deploy configs for Fly.io (fly.toml), Render (render.yaml), and Railway
  (railway.json), all building the existing Dockerfile.

Also harden resolveRelayUrl to honor x-forwarded-proto so the derived QR URL
is https behind a TLS proxy (iOS ATS requires wss).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-22 20:01:54 +03:00
parent 4e60361f77
commit f10d01546b
6 changed files with 203 additions and 1 deletions
+49
View File
@@ -0,0 +1,49 @@
# Fly.io deploy for the temetro backend + wallet relay (deploys ./Dockerfile).
#
# One-time setup (run from backend/):
# fly launch --no-deploy --copy-config # keep this file; pick your app name + region
# fly volumes create temetro_data --size 1 # persists auto-generated secrets + uploads
# fly postgres create # then:
# fly postgres attach <postgres-app-name> # sets DATABASE_URL secret
# fly secrets set \
# PUBLIC_RELAY_URL=https://<app>.fly.dev \ # baked into the wallet-import QR (must be public https)
# BETTER_AUTH_URL=https://<app>.fly.dev \ # https → secure auth cookies
# FRONTEND_URL=https://<your-frontend-origin>
# fly deploy
#
# Any other Docker host (Render/Railway/VPS) works with the same env contract;
# Fly is just the concrete example.
app = "temetro-backend" # change to your Fly app name
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
PORT = "4000"
UPLOAD_DIR = "/var/lib/temetro/uploads"
# Persists the entrypoint's auto-generated secrets (/var/lib/temetro) and
# uploaded files across deploys. Skip this only if you set BETTER_AUTH_SECRET /
# AI_CREDENTIALS_KEY as fly secrets instead.
[mounts]
source = "temetro_data"
destination = "/var/lib/temetro"
[http_service]
internal_port = 4000
force_https = true
# Keep one machine always up — the wallet relay holds live WebSocket
# connections, so the app should not sleep mid-pairing.
auto_stop_machines = "off"
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
method = "get"
path = "/health"
interval = "15s"
timeout = "2s"
grace_period = "10s"
+1
View File
@@ -10,6 +10,7 @@
},
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:tunnel": "node scripts/dev-tunnel.mjs",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"restartPolicyType": "ON_FAILURE",
"numReplicas": 1
}
}
+48
View File
@@ -0,0 +1,48 @@
# Render Blueprint for the temetro backend + wallet relay.
#
# Render discovers render.yaml at the repo root, so for this monorepo either set
# the service's Root Directory to `backend/` in the dashboard, or copy this file
# to the repo root and prefix the paths with `backend/`.
#
# After the first deploy, set the public URL env vars (Render → service →
# Environment): PUBLIC_RELAY_URL and BETTER_AUTH_URL to https://<service>.onrender.com,
# and FRONTEND_URL to your frontend origin. PUBLIC_RELAY_URL is what gets baked
# into the wallet-import QR, so it must be the public https URL.
databases:
- name: temetro-db
databaseName: temetro
user: temetro
plan: free
postgresMajorVersion: "17"
services:
- type: web
name: temetro-backend
runtime: docker
dockerfilePath: ./Dockerfile
dockerContext: .
plan: free
healthCheckPath: /health
envVars:
- key: DATABASE_URL
fromDatabase:
name: temetro-db
property: connectionString
- key: NODE_ENV
value: production
- key: PORT
value: "4000"
# Generated once and kept stable by Render (no on-disk volume needed).
- key: BETTER_AUTH_SECRET
generateValue: true
- key: AI_CREDENTIALS_KEY
generateValue: true
# Set these to your public URLs after the first deploy (sync:false = enter
# in the dashboard, not committed).
- key: PUBLIC_RELAY_URL
sync: false
- key: BETTER_AUTH_URL
sync: false
- key: FRONTEND_URL
sync: false
+84
View File
@@ -0,0 +1,84 @@
// Run the backend with a public Cloudflare tunnel so a patient's phone can reach
// the wallet relay from ANY network (cellular, other Wi-Fi) — not just the LAN.
//
// npm run dev:tunnel
//
// It opens `cloudflared tunnel --url http://localhost:4000`, grabs the public
// https://<sub>.trycloudflare.com URL, and starts the backend with
// PUBLIC_RELAY_URL set to it — so the QR in "Import from a patient app" carries
// that public URL (over wss://). Requires cloudflared: `brew install cloudflared`.
import { spawn, spawnSync } from "node:child_process";
const PORT = process.env.PORT || "4000";
function hasCloudflared() {
const probe = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
return !probe.error;
}
if (!hasCloudflared()) {
console.error(
"\n✖ cloudflared is not installed.\n" +
" Install it, then re-run `npm run dev:tunnel`:\n\n" +
" brew install cloudflared # macOS\n" +
" # or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n",
);
process.exit(1);
}
let backend = null;
let resolvedUrl = null;
const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
console.log(`\n⛅ Starting Cloudflare tunnel to http://localhost:${PORT}\n`);
const tunnel = spawn(
"cloudflared",
["tunnel", "--url", `http://localhost:${PORT}`],
{ stdio: ["ignore", "pipe", "pipe"] },
);
function onTunnelOutput(buf) {
const text = buf.toString();
process.stderr.write(text); // keep cloudflared's own logs visible
if (resolvedUrl) return;
const match = text.match(TUNNEL_RE);
if (match) {
resolvedUrl = match[0];
startBackend(resolvedUrl);
}
}
tunnel.stdout.on("data", onTunnelOutput);
tunnel.stderr.on("data", onTunnelOutput);
function startBackend(publicUrl) {
console.log(
`\n✅ Public relay URL: ${publicUrl}\n` +
" Generate a QR in the web app (Patients → Import from a patient app → QR);\n" +
" it will carry this URL, so a phone on any network can scan to connect.\n",
);
backend = spawn("npm", ["run", "dev"], {
stdio: "inherit",
env: { ...process.env, PUBLIC_RELAY_URL: publicUrl },
});
backend.on("exit", (code) => shutdown(code ?? 0));
}
function shutdown(code) {
for (const proc of [backend, tunnel]) {
if (proc && !proc.killed) proc.kill("SIGTERM");
}
process.exit(code);
}
tunnel.on("exit", (code) => {
if (!resolvedUrl) {
console.error("\n✖ cloudflared exited before a tunnel URL was established.");
}
shutdown(code ?? 0);
});
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
+8 -1
View File
@@ -30,7 +30,14 @@ patientsWalletRouter.use(requireAuth, requireOrg);
function resolveRelayUrl(req: Request): string {
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
const host = req.get("host");
if (host) return `${req.protocol}://${host}`;
if (host) {
// Behind a TLS-terminating proxy (Fly/Render/etc.) req.protocol is "http";
// trust x-forwarded-proto so the QR carries an https URL — the phone then
// connects over wss, which iOS App Transport Security requires.
const proto =
req.get("x-forwarded-proto")?.split(",")[0]?.trim() || req.protocol;
return `${proto}://${host}`;
}
return env.BETTER_AUTH_URL;
}