feat(version): expose offlineGeoEnabled on the public version endpoint for monitoring

`/api/version` now returns an additional `offlineGeoEnabled: boolean`
field that mirrors `offlineGeoReady()` — the same source the runtime
resolver checks before deciding whether to fall back to ipwho.is. A
monitor (or the admin status surface in the next commit) can read the
flag without crawling logs.

The route flips from `force-static` to `force-dynamic` because the
readiness check hits the filesystem on each request; the previous
static caching would have frozen the answer at build time. Two new
test cases cover the true / false branches alongside the existing
shape assertions.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 22:17:16 +02:00
1 parent b5866694c8
commit 4eb22387a2
2 files changed
+41 -4

No files matched your search

@@ -4,11 +4,17 @@ vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
const offlineGeoReadyMock = vi.fn(() => false);
vi.mock("@/lib/geo", () => ({
offlineGeoReady: () => offlineGeoReadyMock(),
}));
import { GET } from "../route";
beforeEach(() => {
delete process.env.NEXT_PUBLIC_APP_BUILD_SHA;
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
offlineGeoReadyMock.mockReturnValue(false);
});
interface VersionEnvelope {
@@ -20,6 +26,7 @@ interface VersionEnvelope {
repository: string;
changelog: string;
docs: string;
offlineGeoEnabled: boolean;
};
}
@@ -49,4 +56,18 @@ describe("GET /api/version", () => {
expect(body.data.buildSha).toBe("abc1234");
expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z");
});
it("reports offlineGeoEnabled=false when the GeoLite2 databases are absent", async () => {
offlineGeoReadyMock.mockReturnValue(false);
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.offlineGeoEnabled).toBe(false);
});
it("reports offlineGeoEnabled=true when the GeoLite2 databases are present", async () => {
offlineGeoReadyMock.mockReturnValue(true);
const response = await (GET as unknown as () => Promise<Response>)();
const body = (await response.json()) as VersionEnvelope;
expect(body.data.offlineGeoEnabled).toBe(true);
});
});
+20 -4
View File
@@ -1,16 +1,24 @@
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { offlineGeoReady } from "@/lib/geo";
import packageJson from "../../../../package.json";
export const dynamic = "force-static";
export const revalidate = false;
// v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo
// resolver uses for the runtime fallback gate, so the public state and
// the runtime behaviour cannot disagree. The check hits the filesystem
// (`existsSync` on the `.empty` marker + the City MMDB) which forces
// the route to be dynamic — the previous `force-static` setting would
// have frozen the answer at build time.
export const dynamic = "force-dynamic";
/**
* GET /api/version
*
* Public endpoint exposing the running build's version. Used by the
* Settings → About surface, the footer of every page, and the
* "Check for updates" button (which compares against the GHCR API).
* Settings → About surface, the footer of every page, the
* "Check for updates" button (which compares against the GHCR API),
* and the admin status-summary row that surfaces the offline-geo
* availability state.
*
* The build SHA and built-at timestamp come from environment variables
* baked at image build time:
@@ -21,11 +29,18 @@ export const revalidate = false;
*
* For local `pnpm dev` neither is set; the route returns `null` and
* the UI falls back to "development" wording.
*
* `offlineGeoEnabled` is `true` when the GeoLite2-City MMDB is present
* at the configured `GEOLITE2_DIR` and no `.empty` marker is set.
* When `false` the runtime falls back to the online `ipwho.is`
* provider and the resolver emits a one-shot admin notification on
* first use.
*/
export const GET = apiHandler(async () => {
const version = packageJson.version;
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
const offlineGeoEnabled = offlineGeoReady();
return apiSuccess({
version,
@@ -35,5 +50,6 @@ export const GET = apiHandler(async () => {
repository: "https://github.com/MBombeck/HealthLog",
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
docs: "https://docs.healthlog.dev",
offlineGeoEnabled,
});
});