diff --git a/CHANGELOG.md b/CHANGELOG.md
index fda35fdb4..e1c0d7b2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
## [Unreleased]
+## [1.25.8] — 2026-06-29 — Carrier in the sign-in overview
+
+A focused configuration release. No schema change.
+
+### Changed
+
+- The admin sign-in overview now resolves the network operator (carrier) from the online IP-location provider's ISP field, so it is populated even on a host without the optional offline ASN database. The carrier moves out of a chip stacked under the login method into its own column, next to the location.
+- The location backfill now also revisits rows that already had a location but no carrier, so the column fills in across existing history on the next pass — not just for new sign-ins.
+
## [1.25.7] — 2026-06-29 — Settings, tidied
A settings information-architecture cleanup. No schema change; every page keeps a working address (old links 301-redirect).
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 86fc002a4..876bb5459 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.25.7
+ version: 1.25.8
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/messages/de.json b/messages/de.json
index 009dc4652..12b60eee8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5875,8 +5875,7 @@
}
}
},
- "carrier": "Anbieter",
- "carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar",
+ "carrier": "Netzbetreiber",
"mfaRequired": "Zweiten Faktor verlangen",
"mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf – es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.",
"mfaEnforcedOn": "MFA an",
diff --git a/messages/en.json b/messages/en.json
index d8129dff4..10c753a21 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5876,7 +5876,6 @@
}
},
"carrier": "Carrier",
- "carrierUnavailableGeoFallback": "{location} — carrier unavailable",
"mfaRequired": "Require a second factor",
"mfaRequiredDescription": "Every account without an active second factor is sent to enrollment after sign-in. This prompts web users to enroll — it does not block direct API or native (Bearer) access for accounts not yet enrolled.",
"mfaEnforcedOn": "MFA on",
diff --git a/messages/es.json b/messages/es.json
index 67664efc4..0d79f3c38 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5876,7 +5876,6 @@
}
},
"carrier": "Operador",
- "carrierUnavailableGeoFallback": "{location} — operador no disponible",
"mfaRequired": "Exigir un segundo factor",
"mfaRequiredDescription": "Cada cuenta sin un segundo factor activo se dirige a la configuración tras iniciar sesión. Esto pide a los usuarios web que lo configuren, pero no bloquea el acceso directo por API o nativo (Bearer) de las cuentas que aún no lo tienen.",
"mfaEnforcedOn": "MFA activado",
diff --git a/messages/fr.json b/messages/fr.json
index 9ee8ddf1c..caface6c5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5876,7 +5876,6 @@
}
},
"carrier": "Opérateur",
- "carrierUnavailableGeoFallback": "{location} — opérateur indisponible",
"mfaRequired": "Exiger un second facteur",
"mfaRequiredDescription": "Chaque compte sans deuxième facteur actif est dirigé vers la configuration après la connexion. Cela invite les utilisateurs web à l'activer, mais ne bloque pas l'accès direct par API ou natif (Bearer) pour les comptes pas encore configurés.",
"mfaEnforcedOn": "MFA activé",
diff --git a/messages/it.json b/messages/it.json
index 1754dc8dc..6fe93d21a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5876,7 +5876,6 @@
}
},
"carrier": "Operatore",
- "carrierUnavailableGeoFallback": "{location} — operatore non disponibile",
"mfaRequired": "Richiedi un secondo fattore",
"mfaRequiredDescription": "Ogni account senza un secondo fattore attivo viene indirizzato alla configurazione dopo l'accesso. Questo invita gli utenti web ad attivarlo, ma non blocca l'accesso diretto via API o nativo (Bearer) per gli account non ancora configurati.",
"mfaEnforcedOn": "MFA attivo",
diff --git a/messages/pl.json b/messages/pl.json
index 462f42ac3..c604ea577 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5876,7 +5876,6 @@
}
},
"carrier": "Operator",
- "carrierUnavailableGeoFallback": "{location} — operator niedostępny",
"mfaRequired": "Wymagaj drugiego składnika",
"mfaRequiredDescription": "Każde konto bez aktywnego drugiego składnika jest po zalogowaniu kierowane do konfiguracji. To zachęca użytkowników sieci do włączenia go, ale nie blokuje bezpośredniego dostępu przez API ani natywnego (Bearer) dla kont jeszcze nieskonfigurowanych.",
"mfaEnforcedOn": "MFA wł.",
diff --git a/package.json b/package.json
index 898d0e8ba..c7c0d4132 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.25.7",
+ "version": "1.25.8",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/public/sw.js b/public/sw.js
index 3e9934830..f16d8cb76 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.25.7";
+ /* @sw-version-fallback */ "v1.25.8";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx
index 780f635f6..41d6e1ae4 100644
--- a/src/components/admin/login-overview-section.tsx
+++ b/src/components/admin/login-overview-section.tsx
@@ -7,6 +7,7 @@ import {
Download,
Loader2,
MapPin,
+ Network,
ScrollText,
XCircle,
} from "lucide-react";
@@ -409,6 +410,18 @@ export function LoginOverviewSection() {
{t("admin.location")}
+ {/*
+ v1.25.8 — the network operator (carrier) is now its own
+ column instead of a chip stacked under the auth-provider.
+ It resolves from the online geo provider's ISP field, so
+ it's populated even without the optional offline ASN MMDB.
+ */}
+
+
+
+ {t("admin.carrier")}
+
+ |
{t("admin.timestamp")}
|
@@ -449,38 +462,6 @@ export function LoginOverviewSection() {
/>
{AUTH_PROVIDER_LABELS[provider]}
- {/*
- v1.4.27 B3 — carrier chip below the auth-provider
- chip. v1.4.36 W4g — fall back to the city + country
- string with a "carrier unavailable" qualifier when
- the GeoLite2-ASN offline lookup miss (free-tier
- ipwho.is does not expose ASN) leaves `entry.carrier`
- null but the location lookup populated
- `entry.location`. Private/loopback IPs and offline-
- miss rows on BOTH columns keep the original
- single-chip layout. ASN-known chip carries the
- short DACH label ("Telekom", "Vodafone", "1&1",
- "O2") with unknown organisations falling through
- to the raw GeoLite2 string.
- */}
- {entry.carrier ? (
-
- {carrierShortLabel(entry.carrier)}
-
- ) : entry.location ? (
-
- {t("admin.carrierUnavailableGeoFallback", {
- location: entry.location,
- })}
-
- ) : null}
{entry.ipAddress ?? "—"}
@@ -488,6 +469,20 @@ export function LoginOverviewSection() {
|
{entry.location ?? "—"}
|
+ {/*
+ v1.25.8 — carrier / network operator in its own
+ column. Known DACH operators fold to a short label
+ ("Telekom", "Vodafone", "1&1", "O2"); unknown ones
+ show the raw operator string the provider returned.
+ */}
+
+ {entry.carrier
+ ? carrierShortLabel(entry.carrier)
+ : "—"}
+ |
{formatDateTime(entry.createdAt)}
|
diff --git a/src/lib/__tests__/geo.test.ts b/src/lib/__tests__/geo.test.ts
index aa1807767..cf1b8c2f4 100644
--- a/src/lib/__tests__/geo.test.ts
+++ b/src/lib/__tests__/geo.test.ts
@@ -317,3 +317,111 @@ describe("lookupIpLocation umlaut roundtrip (v1.4.16 A8a)", () => {
expect(al.toLowerCase()).toMatch(/de/);
});
});
+
+// v1.25.8: the network operator (carrier) is now mined from the same online
+// provider response the location lookup fetched, so a host WITHOUT the
+// optional offline GeoLite2-ASN MMDB still resolves a carrier. `lookupIpGeo`
+// returns location + asn + carrier in one pass.
+describe("lookupIpGeo carrier resolution (v1.25.8)", () => {
+ function jsonOk(value: unknown): Response {
+ return new Response(
+ new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer,
+ {
+ status: 200,
+ headers: { "content-type": "application/json; charset=utf-8" },
+ },
+ );
+ }
+
+ it("reads ip-api top-level isp + parses the AS number from `as`", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue(
+ jsonOk({
+ status: "success",
+ city: "Bochum",
+ countryCode: "DE",
+ isp: "Deutsche Telekom AG",
+ org: "Deutsche Telekom AG",
+ as: "AS3320 Deutsche Telekom AG",
+ }),
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpGeo } = await import("../geo");
+
+ expect(await lookupIpGeo("8.8.8.8")).toEqual({
+ location: "Bochum, DE",
+ asn: 3320,
+ carrier: "Deutsche Telekom AG",
+ });
+ });
+
+ it("reads ipwho.is nested connection.{isp,asn}", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue(
+ jsonOk({
+ success: true,
+ city: "Berlin",
+ country_code: "DE",
+ connection: {
+ asn: 3320,
+ isp: "Deutsche Telekom AG",
+ org: "Deutsche Telekom AG",
+ },
+ }),
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpGeo } = await import("../geo");
+
+ expect(await lookupIpGeo("8.8.8.8")).toEqual({
+ location: "Berlin, DE",
+ asn: 3320,
+ carrier: "Deutsche Telekom AG",
+ });
+ });
+
+ it("resolves a carrier even when the AS number is absent", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue(
+ jsonOk({
+ status: "success",
+ city: "Bochum",
+ countryCode: "DE",
+ isp: "Deutsche Telekom AG",
+ }),
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpGeo } = await import("../geo");
+
+ expect(await lookupIpGeo("8.8.8.8")).toEqual({
+ location: "Bochum, DE",
+ asn: null,
+ carrier: "Deutsche Telekom AG",
+ });
+ });
+
+ it("falls back to org when no isp field is present", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue(
+ jsonOk({
+ status: "success",
+ city: "Hamburg",
+ countryCode: "DE",
+ org: "Vodafone GmbH",
+ }),
+ );
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpGeo } = await import("../geo");
+
+ const result = await lookupIpGeo("8.8.8.8");
+ expect(result.carrier).toBe("Vodafone GmbH");
+ });
+
+ it("returns an all-null record for private addresses without a request", async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+ const { lookupIpGeo } = await import("../geo");
+
+ expect(await lookupIpGeo("10.0.0.5")).toEqual({
+ location: null,
+ asn: null,
+ carrier: null,
+ });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/auth/__tests__/audit.test.ts b/src/lib/auth/__tests__/audit.test.ts
index b4769d239..262316f15 100644
--- a/src/lib/auth/__tests__/audit.test.ts
+++ b/src/lib/auth/__tests__/audit.test.ts
@@ -10,23 +10,28 @@ vi.mock("@/lib/db", () => ({
}));
vi.mock("@/lib/geo", () => ({
- lookupIpLocation: vi.fn(),
- lookupIpAsn: vi.fn(),
+ lookupIpGeo: vi.fn(),
}));
import { auditLog } from "../audit";
import { prisma } from "@/lib/db";
-import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
+import { lookupIpGeo } from "@/lib/geo";
const ENTRY = { id: "audit-1" };
+type GeoResolved = {
+ location: string | null;
+ asn: number | null;
+ carrier: string | null;
+};
+const EMPTY_GEO: GeoResolved = { location: null, asn: null, carrier: null };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue(ENTRY as never);
vi.mocked(prisma.auditLog.update).mockResolvedValue(ENTRY as never);
- // Default: no ASN row resolves. Tests that exercise carrier
- // resolution override this with `mockReturnValueOnce`.
- vi.mocked(lookupIpAsn).mockReturnValue(null);
+ // Default: nothing resolves. Tests that exercise location/carrier
+ // resolution override this with `mockResolvedValueOnce`.
+ vi.mocked(lookupIpGeo).mockResolvedValue(EMPTY_GEO);
});
afterEach(() => {
@@ -48,8 +53,6 @@ async function flush(): Promise {
describe("auditLog", () => {
it("creates an audit-log row with action, userId, JSON-stringified details, ipAddress and null location", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
-
await auditLog("auth.login", {
userId: "user-7",
details: { reason: "passkey", browser: "firefox" },
@@ -80,11 +83,15 @@ describe("auditLog", () => {
ipAddress: null,
});
// No IP → no geo lookup enqueued.
- expect(lookupIpLocation).not.toHaveBeenCalled();
+ expect(lookupIpGeo).not.toHaveBeenCalled();
});
it("for auth.* actions with an ip address, runs geo lookup and updates the row with the resolved location", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Berlin, DE",
+ asn: null,
+ carrier: null,
+ });
await auditLog("auth.login", {
userId: "user-8",
@@ -92,7 +99,7 @@ describe("auditLog", () => {
});
await flush();
- expect(lookupIpLocation).toHaveBeenCalledWith("8.8.8.8");
+ expect(lookupIpGeo).toHaveBeenCalledWith("8.8.8.8");
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
@@ -100,8 +107,8 @@ describe("auditLog", () => {
});
});
- it("does NOT update the row when geo lookup resolves to null", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
+ it("does NOT update the row when geo lookup resolves to nothing", async () => {
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce(EMPTY_GEO);
await auditLog("auth.login", {
userId: "user-9",
@@ -109,16 +116,16 @@ describe("auditLog", () => {
});
await flush();
- expect(lookupIpLocation).toHaveBeenCalledOnce();
+ expect(lookupIpGeo).toHaveBeenCalledOnce();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("does NOT update the row when geo lookup outruns the 3s race timeout", async () => {
// Lookup that never resolves within 3 s — must lose the Promise.race.
- let neverResolve: (value: string | null) => void;
- vi.mocked(lookupIpLocation).mockImplementationOnce(
+ let neverResolve: (value: typeof EMPTY_GEO) => void;
+ vi.mocked(lookupIpGeo).mockImplementationOnce(
() =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
neverResolve = resolve;
}),
);
@@ -138,7 +145,7 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).not.toHaveBeenCalled();
// Caller stays unblocked — the awaited promise above already resolved.
// Resolve the dangling lookup so the test process exits cleanly.
- neverResolve!("Tokyo, JP");
+ neverResolve!({ location: "Tokyo, JP", asn: null, carrier: null });
await flush();
});
@@ -150,14 +157,12 @@ describe("auditLog", () => {
await flush();
expect(prisma.auditLog.create).toHaveBeenCalledOnce();
- expect(lookupIpLocation).not.toHaveBeenCalled();
+ expect(lookupIpGeo).not.toHaveBeenCalled();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
it("silently swallows geo-lookup throws — caller stays unblocked, no update emitted", async () => {
- vi.mocked(lookupIpLocation).mockRejectedValueOnce(
- new Error("upstream 503"),
- );
+ vi.mocked(lookupIpGeo).mockRejectedValueOnce(new Error("upstream 503"));
// The auditLog promise itself must NOT reject.
await expect(
@@ -172,7 +177,11 @@ describe("auditLog", () => {
});
it("silently swallows update() throws (e.g. row deleted between create + update)", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("Paris, FR");
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Paris, FR",
+ asn: null,
+ carrier: null,
+ });
vi.mocked(prisma.auditLog.update).mockRejectedValueOnce(
new Error("row gone"),
);
@@ -188,10 +197,10 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).toHaveBeenCalledOnce();
});
- // ── v1.4.27 B3 — ASN + carrier resolution ────────────────────────
- it("writes asn + carrier alongside location when both resolvers fire", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
- vi.mocked(lookupIpAsn).mockReturnValueOnce({
+ // ── ASN + carrier resolution ──────────────────────────────────────
+ it("writes asn + carrier alongside location when the resolver fills all three", async () => {
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Berlin, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
});
@@ -202,7 +211,7 @@ describe("auditLog", () => {
});
await flush();
- expect(lookupIpAsn).toHaveBeenCalledWith("84.131.0.1");
+ expect(lookupIpGeo).toHaveBeenCalledWith("84.131.0.1");
expect(prisma.auditLog.update).toHaveBeenCalledTimes(1);
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
@@ -215,8 +224,8 @@ describe("auditLog", () => {
});
it("writes asn + carrier even when the location lookup returns null", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
- vi.mocked(lookupIpAsn).mockReturnValueOnce({
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: null,
asn: 3209,
carrier: "Vodafone GmbH",
});
@@ -236,9 +245,33 @@ describe("auditLog", () => {
});
});
- it("writes location only when the ASN lookup misses", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
- vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
+ it("writes carrier without asn when the online provider omits the AS number", async () => {
+ // v1.25.8 — ip-api can return `isp` without a parseable `as`, so a carrier
+ // resolves with a null asn. The carrier is still persisted.
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Bochum, DE",
+ asn: null,
+ carrier: "Deutsche Telekom AG",
+ });
+
+ await auditLog("auth.login.password", {
+ userId: "user-14b",
+ ipAddress: "84.131.0.2",
+ });
+ await flush();
+
+ expect(prisma.auditLog.update).toHaveBeenCalledWith({
+ where: { id: "audit-1" },
+ data: { location: "Bochum, DE", carrier: "Deutsche Telekom AG" },
+ });
+ });
+
+ it("writes location only when the carrier lookup misses", async () => {
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Berlin, DE",
+ asn: null,
+ carrier: null,
+ });
await auditLog("auth.login.password", {
userId: "user-16",
@@ -252,9 +285,8 @@ describe("auditLog", () => {
});
});
- it("does NOT update when both resolvers miss", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
- vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
+ it("does NOT update when the resolver misses entirely", async () => {
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce(EMPTY_GEO);
await auditLog("auth.login.password", {
userId: "user-17",
@@ -265,9 +297,12 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
- it("carries a null carrier through when the ASN row has no organisation", async () => {
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
- vi.mocked(lookupIpAsn).mockReturnValueOnce({ asn: 64500, carrier: null });
+ it("omits the carrier field when the resolver returns an asn but no organisation", async () => {
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: null,
+ asn: 64500,
+ carrier: null,
+ });
await auditLog("auth.login.password", {
userId: "user-18",
@@ -277,7 +312,7 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
- data: { asn: 64500, carrier: null },
+ data: { asn: 64500 },
});
});
});
diff --git a/src/lib/auth/audit.ts b/src/lib/auth/audit.ts
index d2e1efcbb..d25710273 100644
--- a/src/lib/auth/audit.ts
+++ b/src/lib/auth/audit.ts
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
-import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
+import { lookupIpGeo } from "@/lib/geo";
export async function auditLog(
action: string,
@@ -19,36 +19,29 @@ export async function auditLog(
});
// Fire-and-forget: resolve IP location + carrier and update the entry
- // with whichever fields the resolver could fill. v1.4.27 B3 — carrier
- // resolution is synchronous offline MMDB read so it cannot outrun the
- // location-lookup 3 s race; we run both inside the same race so a
- // hung online provider still bounds the await window.
+ // with whichever fields the resolver could fill. v1.25.8 — `lookupIpGeo`
+ // unifies the online location + carrier lookup with the offline ASN MMDB
+ // (carrier now resolves from the online provider when the optional offline
+ // DB is absent). The whole resolve still races a 3 s timeout so a hung
+ // provider can't stall the audit write.
if (opts.ipAddress && action.startsWith("auth.")) {
const ipAddress = opts.ipAddress;
const timeout = new Promise((resolve) =>
setTimeout(() => resolve(null), 3000),
);
- Promise.race([
- Promise.all([
- lookupIpLocation(ipAddress),
- Promise.resolve(lookupIpAsn(ipAddress)),
- ]),
- timeout,
- ])
+ Promise.race([lookupIpGeo(ipAddress), timeout])
.then((result) => {
if (!result) return;
- const [location, asnRow] = result;
+ const { location, asn, carrier } = result;
const data: {
location?: string;
asn?: number;
carrier?: string | null;
} = {};
if (location) data.location = location;
- if (asnRow) {
- data.asn = asnRow.asn;
- data.carrier = asnRow.carrier;
- }
+ if (typeof asn === "number") data.asn = asn;
+ if (carrier) data.carrier = carrier;
if (Object.keys(data).length === 0) return;
return prisma.auditLog.update({
where: { id: entry.id },
diff --git a/src/lib/geo.ts b/src/lib/geo.ts
index c76857a08..1a059bb9b 100644
--- a/src/lib/geo.ts
+++ b/src/lib/geo.ts
@@ -15,10 +15,13 @@
* 4. Offline MMDB present → local read fallback on an online miss.
*
* The legacy contract still holds: `lookupIpLocation` returns a
- * `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the
- * new sibling that resolves the autonomous-system number + the carrier
- * organisation string (so the admin login overview can render a
- * carrier chip next to the auth provider).
+ * `"City, CC"` string or `null`, never throws. `lookupIpGeo` is the
+ * unified resolver that returns location + autonomous-system number +
+ * carrier organisation in one pass (v1.25.8): the carrier is mined from
+ * the online provider's ISP field — so the admin login overview can render
+ * its own carrier column even without the optional offline ASN MMDB — and
+ * the offline `lookupIpAsn` MMDB read remains the authoritative source when
+ * it is configured.
*
* Both helpers are safe to call from any request context. The MMDB
* Reader is loaded lazily on first call and held in a module-level
@@ -78,16 +81,32 @@ interface IpwhoIsResponse {
success?: boolean;
city?: string;
country_code?: string;
+ // ipwho.is nests the network operator under `connection`. The free
+ // endpoint populates `asn` (a number), `org`, and `isp`.
+ connection?: { asn?: number; org?: string; isp?: string };
}
interface IpApiProResponse {
status?: "success" | "fail";
city?: string;
countryCode?: string;
+ // ip-api returns the operator at the top level: `isp` is the friendly
+ // ISP name ("Deutsche Telekom AG"), `org` the registered org, and `as`
+ // a combined "AS3320 Deutsche Telekom AG" string we mine for the number.
+ isp?: string;
+ org?: string;
+ as?: string;
}
type GeoResponse = IpwhoIsResponse & IpApiProResponse;
+/** Resolved geo facts for one IP — the cache + every caller speak this shape. */
+interface GeoResolved {
+ location: string | null;
+ asn: number | null;
+ carrier: string | null;
+}
+
const PRIVATE_IP =
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
@@ -118,25 +137,25 @@ function geoLiteDir(): string {
const LOCATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 h
const LOCATION_CACHE_MAX = 512;
-const LOCATION_CACHE = new Map();
+const GEO_CACHE = new Map();
-function getCachedLocation(ip: string): { value: string | null } | null {
- const hit = LOCATION_CACHE.get(ip);
+function getCachedGeo(ip: string): GeoResolved | null {
+ const hit = GEO_CACHE.get(ip);
if (!hit) return null;
if (Date.now() - hit.at > LOCATION_CACHE_TTL_MS) {
- LOCATION_CACHE.delete(ip);
+ GEO_CACHE.delete(ip);
return null;
}
- return { value: hit.value };
+ return hit.value;
}
-function setCachedLocation(ip: string, value: string | null): void {
+function setCachedGeo(ip: string, value: GeoResolved): void {
// Bound the map: drop the oldest insertion when full.
- if (LOCATION_CACHE.size >= LOCATION_CACHE_MAX) {
- const oldest = LOCATION_CACHE.keys().next().value;
- if (oldest !== undefined) LOCATION_CACHE.delete(oldest);
+ if (GEO_CACHE.size >= LOCATION_CACHE_MAX) {
+ const oldest = GEO_CACHE.keys().next().value;
+ if (oldest !== undefined) GEO_CACHE.delete(oldest);
}
- LOCATION_CACHE.set(ip, { value, at: Date.now() });
+ GEO_CACHE.set(ip, { value, at: Date.now() });
}
// ── Offline tier (MaxMind GeoLite2) ──────────────────────────────────
@@ -193,7 +212,7 @@ export function __resetGeoLite2CacheForTests(): void {
cache.city = undefined;
cache.asn = undefined;
notifiedThisProcess = false;
- LOCATION_CACHE.clear();
+ GEO_CACHE.clear();
}
// ── Offline readiness + one-shot admin notification ─────────────────
@@ -281,6 +300,39 @@ function pickCityName(city: CityResponse["city"]): string | null {
);
}
+/**
+ * v1.25.8 — extract the network operator (carrier) + AS number from an
+ * online provider response. The bundled offline GeoLite2-ASN MMDB is the
+ * authoritative source when present, but it is OPTIONAL (not baked into the
+ * default image), so on a host without it the carrier column stayed empty.
+ * Both supported providers expose the operator inline — ip-api at the top
+ * level (`isp`/`org`/`as`), ipwho.is under `connection` — so we mine it from
+ * the same response the location lookup already fetched. No second request.
+ */
+function parseOnlineCarrier(data: GeoResponse): {
+ asn: number | null;
+ carrier: string | null;
+} {
+ const conn = data.connection;
+ const carrier =
+ data.isp?.trim() ||
+ data.org?.trim() ||
+ conn?.isp?.trim() ||
+ conn?.org?.trim() ||
+ null;
+
+ let asn: number | null = null;
+ if (typeof conn?.asn === "number") {
+ asn = conn.asn;
+ } else if (typeof data.as === "string") {
+ // ip-api ships "AS3320 Deutsche Telekom AG" — keep just the number.
+ const m = data.as.match(/AS(\d+)/i);
+ if (m) asn = Number(m[1]);
+ }
+
+ return { asn, carrier: carrier || null };
+}
+
function lookupIpLocationOffline(ip: string): string | null {
const reader = getCityReader();
if (!reader) return null;
@@ -339,7 +391,7 @@ async function readUtf8Json(res: Response): Promise {
return JSON.parse(text);
}
-async function lookupIpLocationOnline(ip: string): Promise {
+async function lookupIpOnline(ip: string): Promise {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
try {
const res = await safeFetch(
@@ -388,55 +440,76 @@ async function lookupIpLocationOnline(ip: string): Promise {
const city = data.city;
const country = data.country_code ?? data.countryCode;
- if (!city || !country) return null;
+ const location = city && country ? `${city}, ${country}` : null;
+ const { asn, carrier } = parseOnlineCarrier(data);
- return `${city}, ${country}`;
+ return { location, asn, carrier };
} catch {
return null;
}
}
+/**
+ * Resolve location + carrier + AS number for an IP in a single pass.
+ *
+ * v1.25.8 — the online provider lookup now also yields the carrier, so a
+ * self-host without the optional offline GeoLite2-ASN MMDB still surfaces a
+ * network operator in the admin sign-in overview. The merge order:
+ *
+ * 1. Online lookup (one request) → location + carrier + ASN.
+ * 2. Offline City MMDB → location fallback on an online miss (if present).
+ * 3. Offline ASN MMDB → AUTHORITATIVE carrier/ASN when present (it carries
+ * the canonical org name the short-label folder expects); the online
+ * carrier is the fallback so a host without the DBs still resolves one.
+ *
+ * Never throws; the auth-audit caller is fire-and-forget. The resolved
+ * record (including all-null misses) is cached per IP for a bounded TTL.
+ */
+export async function lookupIpGeo(ip: string | null): Promise {
+ const empty: GeoResolved = { location: null, asn: null, carrier: null };
+ if (!ip || PRIVATE_IP.test(ip)) return empty;
+
+ const cached = getCachedGeo(ip);
+ if (cached) return cached;
+
+ // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
+ // is the primary resolver for every self-host: it needs no MaxMind licence
+ // and resolves out of the box. The bundled GeoLite2 offline tier is an
+ // OPTIONAL fallback, so a missing offline tier is the expected baseline.
+ const online = await lookupIpOnline(ip);
+ let location = online?.location ?? null;
+ let asn = online?.asn ?? null;
+ let carrier = online?.carrier ?? null;
+
+ // Offline City MMDB fills the location when the online tier missed it.
+ if (!location && offlineGeoReady()) {
+ location = lookupIpLocationOffline(ip);
+ }
+
+ // Offline ASN MMDB is authoritative for the carrier/ASN when configured;
+ // prefer it over the online ISP string but keep the online value as the
+ // fallback. `lookupIpAsn` also fires the one-shot "no resolver" admin alert
+ // when neither an offline ASN reader nor the offline tier is present.
+ const offlineAsn = lookupIpAsn(ip);
+ if (offlineAsn) {
+ asn = offlineAsn.asn;
+ if (offlineAsn.carrier) carrier = offlineAsn.carrier;
+ }
+
+ const resolved: GeoResolved = { location, asn, carrier };
+ setCachedGeo(ip, resolved);
+ return resolved;
+}
+
+/**
+ * Legacy thin wrapper — returns just the `"City, CC"` string (or null).
+ * Kept for the session list + login-alert call sites that only need the
+ * location; both route through the unified `lookupIpGeo` cache.
+ */
export async function lookupIpLocation(
ip: string | null,
): Promise {
- if (!ip || PRIVATE_IP.test(ip)) return null;
-
- const cached = getCachedLocation(ip);
- if (cached) return cached.value;
-
- // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
- // is now the primary resolver for every self-host: it needs no MaxMind
- // licence and resolves a location out of the box. The bundled GeoLite2
- // offline tier is an OPTIONAL fallback kept only for the egress-disabled
- // case (`IP_GEO_LOOKUP_DISABLED=1`) or for an online miss when the DBs
- // happen to be present — it is no longer the default path, and a missing
- // offline tier is the expected baseline rather than a gap to alert on.
- const online = await lookupIpLocationOnline(ip);
- if (online) {
- setCachedLocation(ip, online);
- return online;
- }
-
- // Online missed (provider down, rate-limited, or egress disabled). Fall
- // back to the offline MMDB if the operator configured one.
- if (offlineGeoReady()) {
- const offline = lookupIpLocationOffline(ip);
- if (offline) {
- setCachedLocation(ip, offline);
- return offline;
- }
- } else {
- // Neither resolver produced a location: the online lookup missed (or
- // egress is disabled) AND no offline tier is configured. That is the
- // only genuine "no resolver at all" gap — fire the one-shot admin alert
- // so the maintainer can wire `MAXMIND_LICENSE_KEY` or re-enable egress.
- // The happy path (ipwho.is resolving by default) never reaches here, so
- // a self-host without the offline DBs is not nagged on every lookup.
- void notifyOfflineGeoUnavailable();
- }
-
- setCachedLocation(ip, null);
- return null;
+ return (await lookupIpGeo(ip)).location;
}
/**
diff --git a/src/lib/jobs/__tests__/geo-backfill.test.ts b/src/lib/jobs/__tests__/geo-backfill.test.ts
index 9e624c252..0bd4bdec3 100644
--- a/src/lib/jobs/__tests__/geo-backfill.test.ts
+++ b/src/lib/jobs/__tests__/geo-backfill.test.ts
@@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
vi.mock("@/lib/geo", () => ({
- lookupIpLocation: vi.fn(),
- lookupIpAsn: vi.fn(),
+ lookupIpGeo: vi.fn(),
}));
import {
@@ -13,7 +12,7 @@ import {
GEO_BACKFILL_WINDOW_DAYS,
runGeoBackfill,
} from "../geo-backfill";
-import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
+import { lookupIpGeo } from "@/lib/geo";
interface FakeRow {
id: string;
@@ -29,10 +28,11 @@ function makePrismaMock(rows: FakeRow[]) {
} as unknown as PrismaClient;
}
+const EMPTY_GEO = { location: null, asn: null, carrier: null };
+
beforeEach(() => {
vi.resetAllMocks();
- vi.mocked(lookupIpAsn).mockReturnValue(null);
- vi.mocked(lookupIpLocation).mockResolvedValue(null);
+ vi.mocked(lookupIpGeo).mockResolvedValue(EMPTY_GEO);
});
afterEach(() => {
@@ -53,14 +53,14 @@ describe("runGeoBackfill", () => {
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
- it("queries rows where location is null + ipAddress is not null + createdAt > now()-30d", async () => {
+ it("queries rows missing location OR carrier + ipAddress is not null + createdAt > now()-30d", async () => {
const prisma = makePrismaMock([]);
const now = new Date("2026-05-15T12:00:00Z");
await runGeoBackfill(prisma, now);
const args = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0];
expect(args?.where).toEqual({
- location: null,
+ OR: [{ location: null }, { carrier: null }],
ipAddress: { not: null },
createdAt: {
gt: new Date(now.getTime() - GEO_BACKFILL_WINDOW_DAYS * 86_400_000),
@@ -73,10 +73,13 @@ describe("runGeoBackfill", () => {
expect(GEO_BACKFILL_BATCH_CAP).toBe(500);
});
- it("updates a row with location only when ASN resolver misses", async () => {
+ it("updates a row with location only when the carrier resolver misses", async () => {
const prisma = makePrismaMock([{ id: "a1", ipAddress: "203.0.113.7" }]);
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("Berlin, DE");
- vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Berlin, DE",
+ asn: null,
+ carrier: null,
+ });
const summary = await runGeoBackfill(prisma);
@@ -94,8 +97,8 @@ describe("runGeoBackfill", () => {
it("updates a row with asn + carrier alongside location", async () => {
const prisma = makePrismaMock([{ id: "a2", ipAddress: "84.131.0.1" }]);
- vi.mocked(lookupIpLocation).mockResolvedValueOnce("München, DE");
- vi.mocked(lookupIpAsn).mockReturnValueOnce({
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "München, DE",
asn: 3320,
carrier: "Deutsche Telekom AG",
});
@@ -118,10 +121,29 @@ describe("runGeoBackfill", () => {
});
});
- it("updates a row with ASN only when location resolver misses but ASN hits", async () => {
+ it("updates a row with carrier (no asn) when the online provider omits the AS number", async () => {
+ // v1.25.8 — ip-api can return `isp` without a parseable `as` field, so a
+ // carrier resolves without an ASN. The carrier alone still counts.
+ const prisma = makePrismaMock([{ id: "a2b", ipAddress: "84.131.0.2" }]);
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: "Bochum, DE",
+ asn: null,
+ carrier: "Deutsche Telekom AG",
+ });
+
+ const summary = await runGeoBackfill(prisma);
+
+ expect(summary.carrierResolved).toBe(1);
+ expect(prisma.auditLog.update).toHaveBeenCalledWith({
+ where: { id: "a2b" },
+ data: { location: "Bochum, DE", carrier: "Deutsche Telekom AG" },
+ });
+ });
+
+ it("updates a row with carrier only when the location resolver misses", async () => {
const prisma = makePrismaMock([{ id: "a3", ipAddress: "139.7.0.1" }]);
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
- vi.mocked(lookupIpAsn).mockReturnValueOnce({
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce({
+ location: null,
asn: 3209,
carrier: "Vodafone GmbH",
});
@@ -136,14 +158,13 @@ describe("runGeoBackfill", () => {
});
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "a3" },
- data: { asn: 3209, carrier: "Vodafone GmbH" },
+ data: { carrier: "Vodafone GmbH", asn: 3209 },
});
});
- it("skips the update when both resolvers miss, counts as still-unresolved", async () => {
+ it("skips the update when the resolver misses entirely, counts as still-unresolved", async () => {
const prisma = makePrismaMock([{ id: "a4", ipAddress: "192.0.2.1" }]);
- vi.mocked(lookupIpLocation).mockResolvedValueOnce(null);
- vi.mocked(lookupIpAsn).mockReturnValueOnce(null);
+ vi.mocked(lookupIpGeo).mockResolvedValueOnce(EMPTY_GEO);
const summary = await runGeoBackfill(prisma);
@@ -162,14 +183,18 @@ describe("runGeoBackfill", () => {
{ id: "b", ipAddress: "192.0.2.99" },
{ id: "c", ipAddress: "139.7.0.1" },
]);
- vi.mocked(lookupIpLocation)
- .mockResolvedValueOnce("Berlin, DE") // a
- .mockResolvedValueOnce(null) // b
- .mockResolvedValueOnce("Hamburg, DE"); // c
- vi.mocked(lookupIpAsn)
- .mockReturnValueOnce({ asn: 3320, carrier: "Deutsche Telekom AG" }) // a
- .mockReturnValueOnce(null) // b
- .mockReturnValueOnce({ asn: 3209, carrier: "Vodafone GmbH" }); // c
+ vi.mocked(lookupIpGeo)
+ .mockResolvedValueOnce({
+ location: "Berlin, DE",
+ asn: 3320,
+ carrier: "Deutsche Telekom AG",
+ }) // a
+ .mockResolvedValueOnce(EMPTY_GEO) // b
+ .mockResolvedValueOnce({
+ location: "Hamburg, DE",
+ asn: 3209,
+ carrier: "Vodafone GmbH",
+ }); // c
const summary = await runGeoBackfill(prisma);
@@ -187,9 +212,17 @@ describe("runGeoBackfill", () => {
{ id: "x", ipAddress: "84.131.0.1" },
{ id: "y", ipAddress: "139.7.0.1" },
]);
- vi.mocked(lookupIpLocation)
- .mockResolvedValueOnce("Berlin, DE")
- .mockResolvedValueOnce("Hamburg, DE");
+ vi.mocked(lookupIpGeo)
+ .mockResolvedValueOnce({
+ location: "Berlin, DE",
+ asn: null,
+ carrier: null,
+ })
+ .mockResolvedValueOnce({
+ location: "Hamburg, DE",
+ asn: null,
+ carrier: null,
+ });
vi.mocked(prisma.auditLog.update)
.mockRejectedValueOnce(new Error("row gone"))
.mockResolvedValueOnce({} as never);
@@ -213,7 +246,7 @@ describe("runGeoBackfill", () => {
carrierResolved: 0,
stillUnresolved: 1,
});
- expect(lookupIpLocation).not.toHaveBeenCalled();
+ expect(lookupIpGeo).not.toHaveBeenCalled();
expect(prisma.auditLog.update).not.toHaveBeenCalled();
});
});
diff --git a/src/lib/jobs/geo-backfill.ts b/src/lib/jobs/geo-backfill.ts
index 767e97145..87bb2f6c6 100644
--- a/src/lib/jobs/geo-backfill.ts
+++ b/src/lib/jobs/geo-backfill.ts
@@ -12,8 +12,11 @@
*
* The scope is intentionally narrow:
*
- * - Only rows where `location IS NULL` and `ipAddress IS NOT NULL`.
- * A null IP has no signal to backfill against.
+ * - Only rows where `location IS NULL` OR `carrier IS NULL`, and
+ * `ipAddress IS NOT NULL`. A null IP has no signal to backfill against.
+ * (v1.25.8 widened this from location-only so rows whose location had
+ * already resolved online but whose carrier was left null on a host
+ * without the offline ASN MMDB get the now-online carrier filled in.)
* - Only rows from the last 30 days. Rows beyond that retention
* window typically belong to deleted users or have aged out of
* the operational triage window where the `location` chip
@@ -40,7 +43,7 @@
* scheduling.
*/
import type { PrismaClient } from "@/generated/prisma/client";
-import { lookupIpAsn, lookupIpLocation } from "@/lib/geo";
+import { lookupIpGeo } from "@/lib/geo";
export const GEO_BACKFILL_BATCH_CAP = 500;
export const GEO_BACKFILL_WINDOW_DAYS = 30;
@@ -74,7 +77,12 @@ export async function runGeoBackfill(
const rows = await prisma.auditLog.findMany({
where: {
- location: null,
+ // v1.25.8 — pick up rows missing EITHER the location or the carrier.
+ // Before, the carrier resolved only from the optional offline ASN MMDB,
+ // so hosts without it left `carrier` null on rows whose `location` had
+ // already resolved online. Now that the online provider yields the
+ // carrier too, those rows are re-resolved on the next pass.
+ OR: [{ location: null }, { carrier: null }],
ipAddress: { not: null },
createdAt: { gt: cutoff },
},
@@ -97,13 +105,9 @@ export async function runGeoBackfill(
continue;
}
- // Run both resolvers; the offline path is microsecond-scale and
- // the online fallback inside `lookupIpLocation` already bounds
- // itself with `AbortSignal.timeout(3000)`.
- const [location, asnRow] = await Promise.all([
- lookupIpLocation(ip),
- Promise.resolve(lookupIpAsn(ip)),
- ]);
+ // One unified resolve: online location + carrier merged with the offline
+ // ASN MMDB. The online fallback bounds itself with a request timeout.
+ const { location, asn, carrier } = await lookupIpGeo(ip);
const data: { location?: string; asn?: number; carrier?: string | null } =
{};
@@ -111,9 +115,9 @@ export async function runGeoBackfill(
data.location = location;
summary.located += 1;
}
- if (asnRow) {
- data.asn = asnRow.asn;
- data.carrier = asnRow.carrier;
+ if (carrier) {
+ data.carrier = carrier;
+ if (typeof asn === "number") data.asn = asn;
summary.carrierResolved += 1;
}