mirror of
https://github.com/temetro/temetro.git
synced 2026-08-30 20:39:06 +00:00
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:
@@ -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"
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"dev:tunnel": "node scripts/dev-tunnel.mjs",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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));
|
||||||
@@ -30,7 +30,14 @@ patientsWalletRouter.use(requireAuth, requireOrg);
|
|||||||
function resolveRelayUrl(req: Request): string {
|
function resolveRelayUrl(req: Request): string {
|
||||||
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
if (env.PUBLIC_RELAY_URL) return env.PUBLIC_RELAY_URL;
|
||||||
const host = req.get("host");
|
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;
|
return env.BETTER_AUTH_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user