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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-26 21:34:19 +03:00
parent 6bd81bd7dd
commit 2454bb4c2b
6 changed files with 193 additions and 2 deletions
+13 -1
View File
@@ -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",
+7
View File
@@ -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).
+12 -1
View File
@@ -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);
+44
View File
@@ -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;
}
}
+41
View File
@@ -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;
+76
View File
@@ -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<LatestInfo> {
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;