mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
c14d760654
commit
bdac5b2c0d
17 files changed
+439
-200
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user