mirror of
https://github.com/temetro/temetro.git
synced 2026-09-04 14:55:28 +00:00
fix: use phone-reachable relay URL in wallet-import QR
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 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,15 @@ NODE_ENV=development
|
|||||||
# already in use on your machine (the app still talks to Postgres internally).
|
# already in use on your machine (the app still talks to Postgres internally).
|
||||||
POSTGRES_PORT=5432
|
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) -----------------------------------------------------
|
# --- Email (optional) -----------------------------------------------------
|
||||||
# If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the
|
# If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the
|
||||||
# server console instead of being sent — zero setup for local development.
|
# server console instead of being sent — zero setup for local development.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const schema = z.object({
|
|||||||
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
UPLOAD_DIR: z.string().min(1).default("./uploads"),
|
||||||
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
|
||||||
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
|
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),
|
PORT: z.coerce.number().int().positive().default(4000),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { eq } from "drizzle-orm";
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { organization } from "../db/schema/auth.js";
|
import { organization } from "../db/schema/auth.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
import { HttpError } from "../lib/http-error.js";
|
import { HttpError } from "../lib/http-error.js";
|
||||||
import { patientInputSchema } from "../lib/patient-validation.js";
|
import { patientInputSchema } from "../lib/patient-validation.js";
|
||||||
import { isReceptionOnly } from "../lib/role-scope.js";
|
import { isReceptionOnly } from "../lib/role-scope.js";
|
||||||
@@ -21,6 +24,16 @@ export const patientsWalletRouter = Router();
|
|||||||
|
|
||||||
patientsWalletRouter.use(requireAuth, requireOrg);
|
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({
|
const requestSchema = z.object({
|
||||||
walletNumber: z.string().trim().min(1),
|
walletNumber: z.string().trim().min(1),
|
||||||
mode: z.enum(["permanent", "temporary"]).default("permanent"),
|
mode: z.enum(["permanent", "temporary"]).default("permanent"),
|
||||||
@@ -47,7 +60,7 @@ patientsWalletRouter.post(
|
|||||||
input.mode,
|
input.mode,
|
||||||
input.durationHours,
|
input.durationHours,
|
||||||
);
|
);
|
||||||
res.status(201).json({ ...view, ephemeralPubKey });
|
res.status(201).json({ ...view, ephemeralPubKey, relayUrl: resolveRelayUrl(req) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ApiError, API_BASE_URL } from "@/lib/api-client";
|
import { ApiError } from "@/lib/api-client";
|
||||||
import {
|
import {
|
||||||
commitWalletShare,
|
commitWalletShare,
|
||||||
type Patient,
|
type Patient,
|
||||||
@@ -159,7 +159,10 @@ export function ImportFromWalletDialog({
|
|||||||
durationHours: temporary ? durationHours : undefined,
|
durationHours: temporary ? durationHours : undefined,
|
||||||
});
|
});
|
||||||
const params = new URLSearchParams({
|
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,
|
rid: pairing.id,
|
||||||
epk: pairing.ephemeralPubKey,
|
epk: pairing.ephemeralPubKey,
|
||||||
mode: pairing.shareMode,
|
mode: pairing.shareMode,
|
||||||
|
|||||||
@@ -216,7 +216,13 @@ export async function requestWalletShare(input: {
|
|||||||
// Create a QR pairing request (no wallet number — the patient scans a QR that
|
// 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
|
// carries this clinic's relay URL + the request). Returns the request plus the
|
||||||
// ephemeral public key the device seals to.
|
// 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: {
|
export async function requestWalletPairing(input: {
|
||||||
mode: WalletShareMode;
|
mode: WalletShareMode;
|
||||||
|
|||||||
Reference in New Issue
Block a user