From 4e60361f7748714a8013c5533bb4b19206c312f1 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Mon, 22 Jun 2026 19:39:08 +0300 Subject: [PATCH] fix: use phone-reachable relay URL in wallet-import QR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Import from a patient app → QR" code baked the web app's own API_BASE_URL (typically http://localhost:4000) into the pairing URI, so a real phone could never reach the relay and "Scan to connect" silently stalled. Use the backend's server-resolved, device-reachable relay URL instead (PUBLIC_RELAY_URL, else the request host), surface it on the WalletPairing type, and document PUBLIC_RELAY_URL in .env.example. Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 9 +++++++++ backend/src/env.ts | 4 ++++ backend/src/routes/patients-wallet.ts | 15 ++++++++++++++- .../patients/import-from-wallet-dialog.tsx | 7 +++++-- frontend/lib/patients.ts | 8 +++++++- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index f9aa950..9823955 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,15 @@ NODE_ENV=development # already in use on your machine (the app still talks to Postgres internally). POSTGRES_PORT=5432 +# --- Patient wallet relay ------------------------------------------------- +# The URL baked into the QR a patient scans to import their record. Their phone +# must be able to reach it — so localhost will NOT work from a real device. If +# unset, the backend derives it from the request host (fine when you open the +# web app over your LAN IP, e.g. http://192.168.1.20:3000). Otherwise set it +# explicitly to a phone-reachable address: your machine's LAN IP or a public +# tunnel URL. +# PUBLIC_RELAY_URL=http://192.168.1.20:4000 + # --- Email (optional) ----------------------------------------------------- # If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the # server console instead of being sent — zero setup for local development. diff --git a/backend/src/env.ts b/backend/src/env.ts index 291444d..bea81a2 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -21,6 +21,10 @@ const schema = z.object({ UPLOAD_DIR: z.string().min(1).default("./uploads"), BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"), FRONTEND_URL: z.string().min(1).default("http://localhost:3000"), + // Public, device-reachable URL of this backend's wallet relay, baked into the + // QR a patient scans. Optional — when unset we derive it from the request host + // (so opening the web app over the LAN yields a reachable LAN URL). + PUBLIC_RELAY_URL: z.string().optional(), PORT: z.coerce.number().int().positive().default(4000), NODE_ENV: z .enum(["development", "production", "test"]) diff --git a/backend/src/routes/patients-wallet.ts b/backend/src/routes/patients-wallet.ts index 886030c..8c64931 100644 --- a/backend/src/routes/patients-wallet.ts +++ b/backend/src/routes/patients-wallet.ts @@ -2,8 +2,11 @@ import { eq } from "drizzle-orm"; import { Router } from "express"; import { z } from "zod"; +import type { Request } from "express"; + import { db } from "../db/index.js"; import { organization } from "../db/schema/auth.js"; +import { env } from "../env.js"; import { HttpError } from "../lib/http-error.js"; import { patientInputSchema } from "../lib/patient-validation.js"; import { isReceptionOnly } from "../lib/role-scope.js"; @@ -21,6 +24,16 @@ export const patientsWalletRouter = Router(); patientsWalletRouter.use(requireAuth, requireOrg); +// The device-reachable URL the patient's app should connect to (baked into the +// QR). Prefer an explicit PUBLIC_RELAY_URL; otherwise derive it from the request +// host so that opening the web app over the LAN yields a reachable LAN URL. +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}`; + return env.BETTER_AUTH_URL; +} + const requestSchema = z.object({ walletNumber: z.string().trim().min(1), mode: z.enum(["permanent", "temporary"]).default("permanent"), @@ -47,7 +60,7 @@ patientsWalletRouter.post( input.mode, input.durationHours, ); - res.status(201).json({ ...view, ephemeralPubKey }); + res.status(201).json({ ...view, ephemeralPubKey, relayUrl: resolveRelayUrl(req) }); } catch (err) { next(err); } diff --git a/frontend/components/patients/import-from-wallet-dialog.tsx b/frontend/components/patients/import-from-wallet-dialog.tsx index 23ba97b..b1eb6a9 100644 --- a/frontend/components/patients/import-from-wallet-dialog.tsx +++ b/frontend/components/patients/import-from-wallet-dialog.tsx @@ -19,7 +19,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { ApiError, API_BASE_URL } from "@/lib/api-client"; +import { ApiError } from "@/lib/api-client"; import { commitWalletShare, type Patient, @@ -159,7 +159,10 @@ export function ImportFromWalletDialog({ durationHours: temporary ? durationHours : undefined, }); const params = new URLSearchParams({ - relay: API_BASE_URL, + // Use the server-resolved, phone-reachable relay URL — NOT API_BASE_URL, + // which is often http://localhost:4000 (the phone itself) and never + // connects from a real device. + relay: pairing.relayUrl, rid: pairing.id, epk: pairing.ephemeralPubKey, mode: pairing.shareMode, diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 4389b5d..e0dd926 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -216,7 +216,13 @@ export async function requestWalletShare(input: { // Create a QR pairing request (no wallet number — the patient scans a QR that // carries this clinic's relay URL + the request). Returns the request plus the // ephemeral public key the device seals to. -export type WalletPairing = WalletShareRequest & { ephemeralPubKey: string }; +export type WalletPairing = WalletShareRequest & { + ephemeralPubKey: string; + // A phone-reachable relay URL the device should connect to (resolved + // server-side from PUBLIC_RELAY_URL / the request host — not necessarily this + // browser's API_BASE_URL, which may be localhost). + relayUrl: string; +}; export async function requestWalletPairing(input: { mode: WalletShareMode;