feat(admin): resolve the sign-in carrier online + give it its own column

The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.

Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.

A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.

In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
This commit is contained in:
Marc-André Bombeck committed 2026-06-29 23:51:40 +02:00
1 parent c14d760654
commit bdac5b2c0d
17 files changed
+439 -200

No files matched your search

+27 -32
View File
@@ -7,6 +7,7 @@ import {
Download,
Loader2,
MapPin,
Network,
ScrollText,
XCircle,
} from "lucide-react";
@@ -409,6 +410,18 @@ export function LoginOverviewSection() {
{t("admin.location")}
</span>
</th>
{/*
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.
*/}
<th className="px-3 py-2 text-left font-medium">
<span className="inline-flex items-center gap-1">
<Network className="h-3 w-3" aria-hidden="true" />
{t("admin.carrier")}
</span>
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
@@ -449,38 +462,6 @@ export function LoginOverviewSection() {
/>
{AUTH_PROVIDER_LABELS[provider]}
</span>
{/*
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 ? (
<span
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
data-slot="login-overview-carrier"
>
{carrierShortLabel(entry.carrier)}
</span>
) : entry.location ? (
<span
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
data-slot="login-overview-carrier"
data-source="geo-fallback"
>
{t("admin.carrierUnavailableGeoFallback", {
location: entry.location,
})}
</span>
) : null}
</td>
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
{entry.ipAddress ?? "—"}
@@ -488,6 +469,20 @@ export function LoginOverviewSection() {
<td className="text-muted-foreground px-3 py-2 text-xs">
{entry.location ?? "—"}
</td>
{/*
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.
*/}
<td
className="text-muted-foreground px-3 py-2 text-xs"
data-slot="login-overview-carrier"
>
{entry.carrier
? carrierShortLabel(entry.carrier)
: "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
+108
View File
@@ -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();
});
});
+70 -40
View File
@@ -10,23 +10,23 @@ 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" };
const EMPTY_GEO = { 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 +48,6 @@ async function flush(): Promise<void> {
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 +78,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 +94,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 +102,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 +111,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<string | null>((resolve) => {
new Promise<typeof EMPTY_GEO>((resolve) => {
neverResolve = resolve;
}),
);
@@ -138,7 +140,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 +152,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 +172,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 +192,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 +206,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 +219,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 +240,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 +280,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 +292,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 +307,7 @@ describe("auditLog", () => {
expect(prisma.auditLog.update).toHaveBeenCalledWith({
where: { id: "audit-1" },
data: { asn: 64500, carrier: null },
data: { asn: 64500 },
});
});
});
+10 -17
View File
@@ -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<null>((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 },
+129 -56
View File
@@ -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<string, { value: string | null; at: number }>();
const GEO_CACHE = new Map<string, { value: GeoResolved; at: number }>();
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<unknown> {
return JSON.parse(text);
}
async function lookupIpLocationOnline(ip: string): Promise<string | null> {
async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
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<string | null> {
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<GeoResolved> {
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<string | null> {
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;
}
/**
+64 -31
View File
@@ -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();
});
});
+18 -14
View File
@@ -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;
}