From 2454bb4c2b1019469b0426d1be67c4640cc76bb3 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 26 Jun 2026 21:34:19 +0300 Subject: [PATCH] backend: version + network endpoints, LAN-friendly CORS/origins Add public GET /api/version (current version + GitHub-release update check, cached, fail-soft) and GET /api/network (detected LAN addresses). Accept localhost/private-LAN origins in CORS and Better Auth trusted origins via a shared isAllowedOrigin helper, so staff can reach the app over the clinic network. The seed-demo.ts fake test data is removed. Co-Authored-By: Claude Opus 4.8 --- backend/src/auth.ts | 14 ++++++- backend/src/env.ts | 7 ++++ backend/src/index.ts | 13 +++++- backend/src/lib/origins.ts | 44 ++++++++++++++++++++ backend/src/routes/network.ts | 41 +++++++++++++++++++ backend/src/routes/version.ts | 76 +++++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 backend/src/lib/origins.ts create mode 100644 backend/src/routes/network.ts create mode 100644 backend/src/routes/version.ts diff --git a/backend/src/auth.ts b/backend/src/auth.ts index ad5d8c0..e387693 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -9,6 +9,7 @@ import * as authSchema from "./db/schema/auth.js"; import { env } from "./env.js"; import { ac, roles } from "./lib/access.js"; import { sendEmail } from "./lib/email.js"; +import { isAllowedOrigin } from "./lib/origins.js"; const WEEK = 60 * 60 * 24 * 7; const DAY = 60 * 60 * 24; @@ -17,7 +18,18 @@ export const auth = betterAuth({ appName: "temetro", baseURL: env.BETTER_AUTH_URL, secret: env.BETTER_AUTH_SECRET, - trustedOrigins: [env.FRONTEND_URL], + // Trust the configured frontend origin plus localhost/LAN hosts so staff can + // sign in over the network (mirrors CORS; see src/lib/origins.ts). Reflecting + // the request's own origin (when allowed) keeps Better Auth's CSRF check happy + // without a per-deployment rebuild. + trustedOrigins: (request) => { + const origins = [env.FRONTEND_URL]; + const origin = request?.headers.get("origin"); + if (origin && isAllowedOrigin(origin) && !origins.includes(origin)) { + origins.push(origin); + } + return origins; + }, database: drizzleAdapter(db, { provider: "pg", diff --git a/backend/src/env.ts b/backend/src/env.ts index e29d6d3..b5bad6c 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -21,6 +21,13 @@ 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"), + // Extra browser origins allowed to call the API with credentials, beyond + // FRONTEND_URL and the auto-allowed private/LAN hosts (see src/lib/origins.ts). + // Comma-separated; set to "*" to allow any origin (only for trusted networks). + TRUSTED_ORIGINS: z.string().optional(), + // Overrides the version reported by GET /api/version. Normally derived from + // package.json; the release pipeline can pin it explicitly. + APP_VERSION: z.string().optional(), // 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). diff --git a/backend/src/index.ts b/backend/src/index.ts index 79015a1..45248a0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,7 @@ import express from "express"; import { auth } from "./auth.js"; import { env } from "./env.js"; +import { isAllowedOrigin } from "./lib/origins.js"; import { errorHandler, notFound } from "./middleware/error.js"; import { initRealtime } from "./realtime.js"; import { activityRouter } from "./routes/activity.js"; @@ -30,7 +31,9 @@ import { prescriptionsRouter } from "./routes/prescriptions.js"; import { settingsRouter } from "./routes/settings.js"; import { signingRouter } from "./routes/signing.js"; import { staffRouter } from "./routes/staff.js"; +import { networkRouter } from "./routes/network.js"; import { tasksRouter } from "./routes/tasks.js"; +import { versionRouter } from "./routes/version.js"; import { beginQuickTunnelDiscovery } from "./services/relay-url.js"; import { sweepExpiredShares } from "./services/wallet-share.js"; @@ -39,9 +42,13 @@ const app = express(); // Behind docker / a reverse proxy we trust forwarding headers for client IPs. app.set("trust proxy", true); +// Allow the configured frontend origin plus localhost/LAN hosts, so other +// departments can reach the app over the network (see src/lib/origins.ts). +// Requests without an Origin header (curl, same-origin server calls) pass too. app.use( cors({ - origin: env.FRONTEND_URL, + origin: (origin, cb) => + cb(null, !origin || isAllowedOrigin(origin)), credentials: true, }), ); @@ -71,6 +78,10 @@ app.get("/health", (_req, res) => { res.json({ status: "ok" }); }); +// Public, unauthenticated: running version + update check, and LAN access info. +app.use("/api/version", versionRouter); +app.use("/api/network", networkRouter); + // Mount the wallet import routes BEFORE the generic patients router so // `/api/patients/wallet/...` isn't matched by patients' `/:fileNumber`. app.use("/api/patients/wallet", patientsWalletRouter); diff --git a/backend/src/lib/origins.ts b/backend/src/lib/origins.ts new file mode 100644 index 0000000..1492f1c --- /dev/null +++ b/backend/src/lib/origins.ts @@ -0,0 +1,44 @@ +// Decides which browser origins may call the API with credentials. +// +// temetro is self-hosted: a clinic runs it on one machine and other departments +// reach it over the LAN by the host's IP (e.g. http://192.168.1.20:3000). The +// browser there sends that LAN origin, which must be allowed for both CORS and +// Better Auth's CSRF/trusted-origin check. We allow: +// - FRONTEND_URL (the configured origin), +// - any explicitly listed TRUSTED_ORIGINS (or "*" for any), +// - localhost and private/LAN hosts (so LAN access works with no config). +import { env } from "../env.js"; + +const configured = (env.TRUSTED_ORIGINS ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +const allowAll = configured.includes("*"); + +function isPrivateHost(hostname: string): boolean { + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") { + return true; + } + // Private IPv4 ranges (RFC 1918) + link-local. + if (/^10\./.test(hostname)) return true; + if (/^192\.168\./.test(hostname)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true; + if (/^169\.254\./.test(hostname)) return true; + // mDNS .local hostnames (e.g. clinic-pc.local). + if (hostname.endsWith(".local")) return true; + return false; +} + +/** True if `origin` (an `Origin` header value) is allowed to call the API. */ +export function isAllowedOrigin(origin: string | undefined | null): boolean { + if (!origin) return false; + if (allowAll) return true; + if (origin === env.FRONTEND_URL) return true; + if (configured.includes(origin)) return true; + try { + return isPrivateHost(new URL(origin).hostname); + } catch { + return false; + } +} diff --git a/backend/src/routes/network.ts b/backend/src/routes/network.ts new file mode 100644 index 0000000..4c6f031 --- /dev/null +++ b/backend/src/routes/network.ts @@ -0,0 +1,41 @@ +// GET /api/network — best-effort discovery of LAN addresses other departments +// can use to reach temetro, for the Settings "Network access" panel. +// +// Caveat: inside Docker's default bridge network this sees the container's IPs, +// not the host's LAN IP, so the frontend prefers the address the browser is +// actually using (window.location) and treats this as a fallback/hint. +import { networkInterfaces } from "node:os"; + +import { Router } from "express"; + +import { env } from "../env.js"; + +function frontendPort(): number { + try { + const port = new URL(env.FRONTEND_URL).port; + return port ? Number(port) : 3000; + } catch { + return 3000; + } +} + +const router = Router(); + +router.get("/", (_req, res) => { + const port = frontendPort(); + const addresses: string[] = []; + for (const iface of Object.values(networkInterfaces())) { + for (const net of iface ?? []) { + // Node <18 reports family as "IPv4"; >=18 may report the number 4. + const isV4 = net.family === "IPv4" || (net.family as unknown) === 4; + if (isV4 && !net.internal) addresses.push(net.address); + } + } + res.json({ + port, + addresses, + urls: addresses.map((ip) => `http://${ip}:${port}`), + }); +}); + +export const networkRouter = router; diff --git a/backend/src/routes/version.ts b/backend/src/routes/version.ts new file mode 100644 index 0000000..16e6ece --- /dev/null +++ b/backend/src/routes/version.ts @@ -0,0 +1,76 @@ +// GET /api/version — reports the running version and whether a newer release +// exists on GitHub. Public (no PHI); the frontend uses it for the Settings +// "About & Updates" panel and the optional update banner. +import { createRequire } from "node:module"; + +import { Router } from "express"; + +import { env } from "../env.js"; + +const require = createRequire(import.meta.url); +// package.json is the source of truth for the running version (copied into the +// runtime image). APP_VERSION can override it (set by the release pipeline). +const pkg = require("../../package.json") as { version?: string }; +const CURRENT = env.APP_VERSION ?? pkg.version ?? "0.0.0"; + +const LATEST_RELEASE_URL = + "https://api.github.com/repos/temetro/temetro/releases/latest"; +const CACHE_TTL = 6 * 60 * 60 * 1000; // 6h — releases are infrequent. +const ERROR_TTL = 10 * 60 * 1000; // back off ~10m after a failed lookup. + +type LatestInfo = { latest: string | null; releaseUrl: string | null }; +let cache: { at: number; ttl: number; info: LatestInfo } | null = null; + +function parseSemver(v: string): [number, number, number] | null { + const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +/** True if `latest` is a strictly newer semver than `current`. */ +function isNewer(latest: string, current: string): boolean { + const a = parseSemver(latest); + const b = parseSemver(current); + if (!a || !b) return false; + for (let i = 0; i < 3; i++) { + if (a[i]! > b[i]!) return true; + if (a[i]! < b[i]!) return false; + } + return false; +} + +async function fetchLatest(): Promise { + if (cache && Date.now() - cache.at < cache.ttl) return cache.info; + try { + const res = await fetch(LATEST_RELEASE_URL, { + headers: { Accept: "application/vnd.github+json", "User-Agent": "temetro" }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) throw new Error(`GitHub responded ${res.status}`); + const body = (await res.json()) as { tag_name?: string; html_url?: string }; + const info: LatestInfo = { + latest: body.tag_name ? body.tag_name.replace(/^v/, "") : null, + releaseUrl: body.html_url ?? null, + }; + cache = { at: Date.now(), ttl: CACHE_TTL, info }; + return info; + } catch { + // Fail soft: keep any prior value, briefly cache the miss to avoid hammering. + const info: LatestInfo = cache?.info ?? { latest: null, releaseUrl: null }; + cache = { at: Date.now(), ttl: ERROR_TTL, info }; + return info; + } +} + +const router = Router(); + +router.get("/", async (_req, res) => { + const { latest, releaseUrl } = await fetchLatest(); + res.json({ + current: CURRENT, + latest, + updateAvailable: latest ? isNewer(latest, CURRENT) : false, + releaseUrl, + }); +}); + +export const versionRouter = router;