diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b1e6eeb4b..77fa87cf8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36065,18 +36065,6 @@ components: type: string description: Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default. - geoLookup: - type: string - enum: - - offline - - online - - off - description: "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider - above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`)." - geoProviderChosen: - type: boolean - description: The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in - use. required: - version - buildSha @@ -36087,8 +36075,6 @@ components: - docs - offlineGeoEnabled - geoProviderHost - - geoLookup - - geoProviderChosen additionalProperties: false description: The running build's identity plus the offline-geolocation state. Public — no authentication, no account data. AssistantFlagsEnvelope: diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index bffaa5be3..deef1897b 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,6 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); +const geoMock = vi.hoisted(() => ({ + lookup: "online" as "offline" | "online" | "off", + chosen: false, + host: "ipwho.is", +})); +vi.mock("@/lib/geo", () => ({ + geoLookupMode: () => geoMock.lookup, + geoProviderChosen: () => geoMock.chosen, + resolveGeoProviderHost: () => geoMock.host, +})); + vi.mock("@/lib/jobs/job-failures", () => ({ JOB_FAILURE_WINDOW_HOURS: 72, readFailingQueues: vi.fn(), @@ -208,4 +219,17 @@ describe("GET /api/admin/status (integration health summary)", () => { ], }); }); + + it("carries the login-IP geo state the public version route no longer shows", async () => { + geoMock.lookup = "online"; + geoMock.chosen = true; + geoMock.host = "ip-api.com"; + const res = await GET(); + const body = (await res.json()) as { data: { geo: unknown } }; + expect(body.data.geo).toEqual({ + lookup: "online", + providerChosen: true, + providerHost: "ip-api.com", + }); + }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index d94de0dd4..5e7f4b9ab 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,6 +9,11 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; +import { + geoLookupMode, + geoProviderChosen, + resolveGeoProviderHost, +} from "@/lib/geo"; // Build timestamp (set at build time via next.config) const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString(); @@ -109,5 +114,12 @@ export const GET = apiHandler(async () => { : null, webPush: webPushConfigured ? { configured: true } : null, }, + // How login IPs are placed on this host. Admin-only: the public + // `/api/version` keeps just `offlineGeoEnabled` and `geoProviderHost`. + geo: { + lookup: geoLookupMode(), + providerChosen: geoProviderChosen(), + providerHost: resolveGeoProviderHost(), + }, }); }); diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index cba8c7961..4803e2ab5 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -9,8 +9,6 @@ const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is"); vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => offlineGeoReadyMock(), resolveGeoProviderHost: () => resolveGeoProviderHostMock(), - geoLookupMode: () => (offlineGeoReadyMock() ? "offline" : "online"), - geoProviderChosen: () => false, })); import { GET } from "../route"; @@ -108,4 +106,23 @@ describe("GET /api/version", () => { const body = (await response.json()) as VersionEnvelope; expect(body.data.geoProviderHost).toBe("ip-api.com"); }); + + it("keeps the finer geo state off the public answer", async () => { + // Lookup mode and whether the operator chose the provider are admin-only + // (`/api/admin/status`); the public route would only add a way to tell + // instances apart. + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "buildSha", + "builtAt", + "changelog", + "docs", + "geoProviderHost", + "license", + "offlineGeoEnabled", + "repository", + "version", + ]); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index f737480be..9c0435b8e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,11 +1,6 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { - geoLookupMode, - geoProviderChosen, - offlineGeoReady, - resolveGeoProviderHost, -} from "@/lib/geo"; +import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo"; import packageJson from "../../../../package.json"; // v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo @@ -41,7 +36,10 @@ export const dynamic = "force-dynamic"; * `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default * `ipwho.is`) and the resolver emits a one-shot admin notification on * first use. `geoProviderHost` lets the admin status surface name the - * real provider instead of assuming the default. + * real provider instead of assuming the default. Both were public before + * v1.42 and stay so for existing readers; the finer geo state (lookup mode, + * whether the operator chose the provider) is admin-only and lives on + * `/api/admin/status`, so it adds nothing to tell instances apart by. */ export const GET = apiHandler(async () => { // v1.4.43 B11 — prefer the build-arg-injected env var so the runtime @@ -69,7 +67,5 @@ export const GET = apiHandler(async () => { docs: "https://docs.healthlog.dev", offlineGeoEnabled, geoProviderHost, - geoLookup: geoLookupMode(), - geoProviderChosen: geoProviderChosen(), }); }); diff --git a/src/components/admin/__tests__/geo-lookup-state.test.ts b/src/components/admin/__tests__/geo-lookup-state.test.ts index 17da78f9c..f7b062cb1 100644 --- a/src/components/admin/__tests__/geo-lookup-state.test.ts +++ b/src/components/admin/__tests__/geo-lookup-state.test.ts @@ -1,41 +1,24 @@ import { describe, expect, it } from "vitest"; -import { geoLookupState, type PublicVersion } from "../_shared"; +import { geoLookupState, type AdminGeoStatus } from "../_shared"; -const base: PublicVersion = { - version: "1.42.0", - buildSha: null, - builtAt: null, +const base: AdminGeoStatus = { + lookup: "online", + providerChosen: false, + providerHost: "ipwho.is", }; describe("geoLookupState", () => { it("warns only about the unconfigured default provider", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - }), - ).toBe("default"); + expect(geoLookupState(base)).toBe("default"); }); it("treats a provider the operator named as a decision", () => { - expect( - geoLookupState({ - ...base, - offlineGeoEnabled: false, - geoLookup: "online", - geoProviderChosen: true, - }), - ).toBe("chosen"); + expect(geoLookupState({ ...base, providerChosen: true })).toBe("chosen"); }); it("reads a switched-off lookup and the offline databases as settled", () => { - expect( - geoLookupState({ ...base, offlineGeoEnabled: false, geoLookup: "off" }), - ).toBe("off"); - expect(geoLookupState({ ...base, offlineGeoEnabled: true })).toBe( - "offline", - ); + expect(geoLookupState({ ...base, lookup: "off" })).toBe("off"); + expect(geoLookupState({ ...base, lookup: "offline" })).toBe("offline"); }); }); diff --git a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx index a6c49734f..9ee36ebd4 100644 --- a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx +++ b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx @@ -50,6 +50,11 @@ vi.mock("@tanstack/react-query", () => ({ activeSessions: 1, }, integrations: { umami: null, glitchtip: null, webPush: null }, + geo: { + lookup: "offline", + providerChosen: false, + providerHost: "ipwho.is", + }, }, isLoading: false, isError: false, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 4c3855915..023211450 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -81,6 +81,8 @@ export interface SystemStatus { glitchtip: { configured: boolean; enabled: boolean } | null; webPush: { configured: boolean } | null; }; + /** Login-IP geolocation state (admin-only since v1.42). */ + geo: AdminGeoStatus; } export interface AdminSettings { @@ -590,10 +592,16 @@ export interface PublicVersion { // offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is). // Optional so legacy /api/version responses still satisfy the shape. geoProviderHost?: string; - /** v1.42 — `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ - geoLookup?: "offline" | "online" | "off"; - /** v1.42 — the operator named the provider in IP_GEO_LOOKUP_URL. */ - geoProviderChosen?: boolean; +} + +/** How login IPs are placed on this host, as `/api/admin/status` reports it. */ +export interface AdminGeoStatus { + /** `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ + lookup: "offline" | "online" | "off"; + /** The operator named the provider in IP_GEO_LOOKUP_URL. */ + providerChosen: boolean; + /** Host of the online provider (IP_GEO_LOOKUP_URL, default ipwho.is). */ + providerHost: string; } /** @@ -602,15 +610,15 @@ export interface PublicVersion { * warns, because it sends every login IP to a third party nobody chose. */ export function geoLookupState( - version: PublicVersion, + geo: AdminGeoStatus, ): "offline" | "off" | "chosen" | "default" { - if (version.offlineGeoEnabled) return "offline"; - if (version.geoLookup === "off") return "off"; - return version.geoProviderChosen ? "chosen" : "default"; + if (geo.lookup === "offline") return "offline"; + if (geo.lookup === "off") return "off"; + return geo.providerChosen ? "chosen" : "default"; } -/** Shared `/api/version` reader — the system-status section + overview - * summary both surface app version / build SHA / offline-geo state. */ +/** Shared `/api/version` reader for the version tile (version, build SHA, + * update check). The geo state comes from `/api/admin/status`. */ export function usePublicVersion() { return useQuery({ queryKey: queryKeys.publicVersion(), diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 334a83e2d..a3cf1f4c4 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -25,7 +25,6 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { geoLookupState, StatusItem, - usePublicVersion, useSystemStatus, type FailingQueue, } from "./_shared"; @@ -129,7 +128,6 @@ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); return (
@@ -238,30 +236,27 @@ export function SystemStatusSection() { tone="success" /> )} - {/* v1.4.27 R5 — offline GeoLite2 availability. Renders only - when /api/version returns the new field so legacy - responses do not produce a placeholder row. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + {/* v1.4.27 R5 — offline GeoLite2 availability, read from the + admin status rather than the public /api/version. */} +
) : isError ? ( // P19: surface load failures inline instead of leaving the diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 19ae088dd..c2088e662 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -26,18 +26,12 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; -import { - geoLookupState, - StatusItem, - usePublicVersion, - useSystemStatus, -} from "./_shared"; +import { geoLookupState, StatusItem, useSystemStatus } from "./_shared"; import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); const { data: status, isError, refetch } = useSystemStatus(); - const { data: version } = usePublicVersion(); const { data: keyBackup } = useKeyBackupStatus(); return ( @@ -92,30 +86,27 @@ export function SystemStatusSummary() { /> )} {/* v1.4.27 R5 — surface the offline-geo state so the maintainer - spots the missing MAXMIND_LICENSE_KEY without crawling logs. - The field is undefined on legacy responses; the row only - renders when /api/version answers the new shape. */} - {version?.offlineGeoEnabled !== undefined && ( - - )} + spots the missing GeoLite2 databases without crawling logs. + Read from the admin status, not the public /api/version. */} + ) : isError ? (