From f10d01546b3c2df030206b12ffe05e3ecccb471a Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 22 Jun 2026 20:01:54 +0300 Subject: [PATCH] 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 --- backend/fly.toml | 49 ++++++++++++++++ backend/package.json | 1 + backend/railway.json | 13 +++++ backend/render.yaml | 48 +++++++++++++++ backend/scripts/dev-tunnel.mjs | 84 +++++++++++++++++++++++++++ backend/src/routes/patients-wallet.ts | 9 ++- 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 backend/fly.toml create mode 100644 backend/railway.json create mode 100644 backend/render.yaml create mode 100644 backend/scripts/dev-tunnel.mjs diff --git a/backend/fly.toml b/backend/fly.toml new file mode 100644 index 0000000..0dcb377 --- /dev/null +++ b/backend/fly.toml @@ -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 # sets DATABASE_URL secret +# fly secrets set \ +# PUBLIC_RELAY_URL=https://.fly.dev \ # baked into the wallet-import QR (must be public https) +# BETTER_AUTH_URL=https://.fly.dev \ # https → secure auth cookies +# FRONTEND_URL=https:// +# 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" diff --git a/backend/package.json b/backend/package.json index 313e685..66c8d4d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/railway.json b/backend/railway.json new file mode 100644 index 0000000..c9db633 --- /dev/null +++ b/backend/railway.json @@ -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 + } +} diff --git a/backend/render.yaml b/backend/render.yaml new file mode 100644 index 0000000..68caffc --- /dev/null +++ b/backend/render.yaml @@ -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://.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 diff --git a/backend/scripts/dev-tunnel.mjs b/backend/scripts/dev-tunnel.mjs new file mode 100644 index 0000000..00d10b3 --- /dev/null +++ b/backend/scripts/dev-tunnel.mjs @@ -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://.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)); diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index 8c64931..fe3bba5 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -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; }