mirror of
https://github.com/temetro/temetro.git
synced 2026-07-28 04:38:57 +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": {
|
||||
"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",
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user