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
+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;