diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts index 63af5bb27..172b2a799 100644 --- a/src/lib/__tests__/geo.test.ts +++ b/src/lib/__tests__/geo.test.ts @@ -84,14 +84,15 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { ); } - it("uses the ip-api.com HTTPS endpoint by default", async () => { - // v1.18.11 (W3): default provider is ip-api.com (ipwho.is 403s on - // datacentre egress). The wire URL must be the ip-api.com JSON path - // and the parser must accept its `status`/`countryCode` shape. + it("uses the ipwho.is HTTPS endpoint by default", async () => { + // v1.25.5: ipwho.is is the default provider again. The wire URL must be + // the ipwho.is path and the parser must accept its `success`/`country_code` + // shape. (Operators whose egress ipwho.is rejects override IP_GEO_LOOKUP_URL + // to ip-api.com — the parser still accepts that shape; covered below.) const fetchSpy = vi .fn() .mockResolvedValue( - jsonOk({ status: "success", city: "Berlin", countryCode: "DE" }), + jsonOk({ success: true, city: "Berlin", country_code: "DE" }), ); vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); @@ -99,7 +100,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => { expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE"); const url = fetchSpy.mock.calls[0]?.[0] as string; expect(url.startsWith("https://")).toBe(true); - expect(url).toBe("https://ip-api.com/json/8.8.8.8"); + expect(url).toBe("https://ipwho.is/8.8.8.8"); }); it("refuses to call non-HTTPS configured providers", async () => { diff --git a/src/lib/geo.ts b/src/lib/geo.ts index c3bfc95a9..1f15b8320 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -90,16 +90,16 @@ type GeoResponse = IpwhoIsResponse & IpApiProResponse; const PRIVATE_IP = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/; -// v1.18.11 (W3) — the default online provider is ip-api.com, not ipwho.is. -// ipwho.is rejects datacentre / free-plan server egress with a 403 -// ("CORS is not supported on the Free plan"), which silently produced the -// "—" location on prod (every server-side lookup failed). The base is -// `https://ip-api.com/json`; `buildLookupUrl` appends `/` so the wire -// URL is `https://ip-api.com/json/`. The parser already accepts the -// ip-api.com shape (`status:"success"` + `countryCode`). Operators who need -// a different / keyed provider override via `IP_GEO_LOOKUP_URL`; the HTTPS -// egress guard in `buildLookupUrl` still applies to any override. -const DEFAULT_GEO_URL = "https://ip-api.com/json"; +// v1.25.5 — ipwho.is is the default online provider again (free, no key, no +// per-minute cap on the standard endpoint). The base is `https://ipwho.is`; +// `buildLookupUrl` appends `/` so the wire URL is `https://ipwho.is/`. +// The parser accepts BOTH the ipwho.is shape (`success` + `country_code`) and +// the ip-api.com shape (`status` + `countryCode`), so operators whose egress +// ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json` +// (or any keyed provider matching one of those shapes) with no code change. +// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the +// online lookup misses, and skipped entirely when the databases are absent. +const DEFAULT_GEO_URL = "https://ipwho.is"; function geoLiteDir(): string { return process.env.GEOLITE2_DIR ?? "/opt/geolite2";