@prosopo/ipinfo 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +3 -3
- package/.turbo/turbo-build$colon$tsc.log +10 -10
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +51 -0
- package/dist/backends/maxmind.d.ts +1 -0
- package/dist/backends/maxmind.d.ts.map +1 -1
- package/dist/backends/maxmind.js +51 -20
- package/dist/backends/maxmind.js.map +1 -1
- package/dist/cjs/backends/maxmind.cjs +51 -20
- package/dist/tests/maxmind.unit.test.js +113 -0
- package/dist/tests/maxmind.unit.test.js.map +1 -1
- package/package.json +6 -6
- package/src/backends/maxmind.ts +84 -24
- package/src/tests/maxmind.unit.test.ts +168 -2
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prosopo/ipinfo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.23",
|
|
4
4
|
"description": "IP information service with MaxMind and ipapi.is backends",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,18 +28,18 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@maxmind/geoip2-node": "7.1.0",
|
|
31
|
-
"@prosopo/logger": "2.0.
|
|
32
|
-
"@prosopo/types": "5.
|
|
31
|
+
"@prosopo/logger": "2.0.9",
|
|
32
|
+
"@prosopo/types": "5.7.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@prosopo/config": "3.3.
|
|
35
|
+
"@prosopo/config": "3.3.14",
|
|
36
36
|
"@types/node": "22.10.2",
|
|
37
|
-
"@vitest/coverage-v8": "4.1.
|
|
37
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
38
38
|
"del-cli": "6.0.0",
|
|
39
39
|
"tslib": "2.7.0",
|
|
40
40
|
"typescript": "5.6.2",
|
|
41
41
|
"vite": "8.1.5",
|
|
42
|
-
"vitest": "4.1.
|
|
42
|
+
"vitest": "4.1.11"
|
|
43
43
|
},
|
|
44
44
|
"license": "Apache-2.0",
|
|
45
45
|
"repository": {
|
package/src/backends/maxmind.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import type { Asn, City, ReaderModel } from "@maxmind/geoip2-node";
|
|
15
|
+
import type { Asn, City, Country, ReaderModel } from "@maxmind/geoip2-node";
|
|
16
16
|
import type { Logger } from "@prosopo/logger";
|
|
17
17
|
import type { IPInfoResponse, IPInfoResult } from "@prosopo/types";
|
|
18
18
|
|
|
@@ -40,9 +40,29 @@ export interface MaxMindBackendConfig {
|
|
|
40
40
|
openReader?: OpenReader;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Which accessor the geo reader actually supports.
|
|
45
|
+
*
|
|
46
|
+
* `cityDbPath` is a path, not a promise of a City database — production points
|
|
47
|
+
* it at GeoLite2-Country.mmdb. `Reader.open()` accepts any valid .mmdb, so the
|
|
48
|
+
* reader opens and `isAvailable()` reports true, but `city()` checks
|
|
49
|
+
* `metadata.databaseType` and throws `BadMethodCallError` on every call. The
|
|
50
|
+
* result was a backend that claimed to be up and failed 100% of lookups —
|
|
51
|
+
* invisible until the ipapi.is sidecar it was meant to back up went down.
|
|
52
|
+
*
|
|
53
|
+
* `metadata` is not on `ReaderModel`'s public type, so the kind is latched
|
|
54
|
+
* lazily on the first `BadMethodCallError` rather than probed at open time.
|
|
55
|
+
* "unknown" only ever costs one extra throw for the process lifetime.
|
|
56
|
+
*/
|
|
57
|
+
type GeoReaderKind = "unknown" | "city" | "country";
|
|
58
|
+
|
|
59
|
+
/** Thrown by geoip2-node when an accessor doesn't match the database type. */
|
|
60
|
+
const BAD_METHOD_CALL_ERROR = "BadMethodCallError";
|
|
61
|
+
|
|
43
62
|
export class MaxMindBackend {
|
|
44
63
|
private cityReader: ReaderModel | null = null;
|
|
45
64
|
private asnReader: ReaderModel | null = null;
|
|
65
|
+
private geoReaderKind: GeoReaderKind = "unknown";
|
|
46
66
|
private config: MaxMindBackendConfig;
|
|
47
67
|
|
|
48
68
|
constructor(config: MaxMindBackendConfig) {
|
|
@@ -100,17 +120,44 @@ export class MaxMindBackend {
|
|
|
100
120
|
|
|
101
121
|
try {
|
|
102
122
|
let cityData: City | undefined;
|
|
123
|
+
let countryData: Country | undefined;
|
|
103
124
|
let asnData: Asn | undefined;
|
|
104
125
|
|
|
105
126
|
if (this.cityReader) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
if (this.geoReaderKind !== "country") {
|
|
128
|
+
try {
|
|
129
|
+
cityData = this.cityReader.city(ip);
|
|
130
|
+
this.geoReaderKind = "city";
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (isBadMethodCall(error)) {
|
|
133
|
+
// Not a City database. Latch so subsequent lookups go
|
|
134
|
+
// straight to country() instead of paying the throw.
|
|
135
|
+
this.geoReaderKind = "country";
|
|
136
|
+
this.config.logger?.warn(() => ({
|
|
137
|
+
msg: "MaxMind geo database is not a City database; falling back to country-level lookups",
|
|
138
|
+
data: { dbPath: this.config.cityDbPath },
|
|
139
|
+
err: error,
|
|
140
|
+
}));
|
|
141
|
+
} else {
|
|
142
|
+
this.config.logger?.debug(() => ({
|
|
143
|
+
msg: "MaxMind City lookup failed",
|
|
144
|
+
data: { ip },
|
|
145
|
+
err: error,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.geoReaderKind === "country") {
|
|
152
|
+
try {
|
|
153
|
+
countryData = this.cityReader.country(ip);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.config.logger?.debug(() => ({
|
|
156
|
+
msg: "MaxMind Country lookup failed",
|
|
157
|
+
data: { ip },
|
|
158
|
+
err: error,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
114
161
|
}
|
|
115
162
|
}
|
|
116
163
|
|
|
@@ -126,7 +173,11 @@ export class MaxMindBackend {
|
|
|
126
173
|
}
|
|
127
174
|
}
|
|
128
175
|
|
|
129
|
-
|
|
176
|
+
// `City extends Country`, so everything below the city/subdivision/
|
|
177
|
+
// location fields reads off whichever one the database gave us.
|
|
178
|
+
const geoData: City | Country | undefined = cityData ?? countryData;
|
|
179
|
+
|
|
180
|
+
if (!geoData && !asnData) {
|
|
130
181
|
return {
|
|
131
182
|
isValid: false,
|
|
132
183
|
error: "No MaxMind data available for IP",
|
|
@@ -139,39 +190,40 @@ export class MaxMindBackend {
|
|
|
139
190
|
isValid: true,
|
|
140
191
|
|
|
141
192
|
// Threat indicators - GeoLite2 free DBs do not populate these
|
|
142
|
-
isVPN:
|
|
143
|
-
isTor:
|
|
193
|
+
isVPN: geoData?.traits?.isAnonymousVpn ?? false,
|
|
194
|
+
isTor: geoData?.traits?.isTorExitNode ?? false,
|
|
144
195
|
isProxy:
|
|
145
|
-
(
|
|
146
|
-
(
|
|
147
|
-
isDatacenter:
|
|
196
|
+
(geoData?.traits?.isPublicProxy ?? false) ||
|
|
197
|
+
(geoData?.traits?.isResidentialProxy ?? false),
|
|
198
|
+
isDatacenter: geoData?.traits?.isHostingProvider ?? false,
|
|
148
199
|
isAbuser: false,
|
|
149
200
|
isMobile: false,
|
|
150
|
-
isSatellite:
|
|
201
|
+
isSatellite: geoData?.traits?.isSatelliteProvider ?? false,
|
|
151
202
|
isCrawler: false,
|
|
152
203
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
204
|
+
// Country is available from both database types; the rest needs a
|
|
205
|
+
// City database and stays undefined on a Country-only one.
|
|
206
|
+
country: geoData?.country?.names?.en,
|
|
207
|
+
countryCode: geoData?.country?.isoCode,
|
|
156
208
|
region: cityData?.subdivisions?.[0]?.names?.en,
|
|
157
209
|
city: cityData?.city?.names?.en,
|
|
158
210
|
latitude: cityData?.location?.latitude,
|
|
159
211
|
longitude: cityData?.location?.longitude,
|
|
160
212
|
timezone: cityData?.location?.timeZone,
|
|
161
213
|
|
|
162
|
-
// ASN info - prefer
|
|
214
|
+
// ASN info - prefer geo DB traits, fall back to ASN DB
|
|
163
215
|
asnNumber:
|
|
164
|
-
|
|
216
|
+
geoData?.traits?.autonomousSystemNumber ??
|
|
165
217
|
asnData?.autonomousSystemNumber,
|
|
166
218
|
asnOrganization:
|
|
167
|
-
|
|
219
|
+
geoData?.traits?.autonomousSystemOrganization ??
|
|
168
220
|
asnData?.autonomousSystemOrganization,
|
|
169
221
|
|
|
170
222
|
// Provider info from ASN
|
|
171
223
|
providerName:
|
|
172
|
-
|
|
224
|
+
geoData?.traits?.autonomousSystemOrganization ??
|
|
173
225
|
asnData?.autonomousSystemOrganization,
|
|
174
|
-
providerType: mapUserType(
|
|
226
|
+
providerType: mapUserType(geoData?.traits?.userType),
|
|
175
227
|
};
|
|
176
228
|
|
|
177
229
|
return result;
|
|
@@ -185,6 +237,14 @@ export class MaxMindBackend {
|
|
|
185
237
|
}
|
|
186
238
|
}
|
|
187
239
|
|
|
240
|
+
/**
|
|
241
|
+
* geoip2-node sets `name` on its error classes, so this survives the class
|
|
242
|
+
* identity being lost across the lazy `import()` in `openReaderFromFile`.
|
|
243
|
+
*/
|
|
244
|
+
function isBadMethodCall(error: unknown): boolean {
|
|
245
|
+
return error instanceof Error && error.name === BAD_METHOD_CALL_ERROR;
|
|
246
|
+
}
|
|
247
|
+
|
|
188
248
|
export type MaxMindUserType =
|
|
189
249
|
| "business"
|
|
190
250
|
| "cafe"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// impossible to reach at all. The reader is therefore injected, and everything
|
|
19
19
|
// the backend does with its output runs for real.
|
|
20
20
|
|
|
21
|
-
import type { Asn, City, ReaderModel } from "@maxmind/geoip2-node";
|
|
21
|
+
import type { Asn, City, Country, ReaderModel } from "@maxmind/geoip2-node";
|
|
22
22
|
import type { IPInfoResult } from "@prosopo/types";
|
|
23
23
|
import { describe, expect, it, vi } from "vitest";
|
|
24
24
|
import {
|
|
@@ -31,9 +31,10 @@ const IP = "8.8.8.8";
|
|
|
31
31
|
const CITY_DB = "/dbs/GeoLite2-City.mmdb";
|
|
32
32
|
const ASN_DB = "/dbs/GeoLite2-ASN.mmdb";
|
|
33
33
|
|
|
34
|
-
/** Builds a reader that answers city/asn lookups however the test needs. */
|
|
34
|
+
/** Builds a reader that answers city/country/asn lookups however the test needs. */
|
|
35
35
|
const reader = (behaviour: {
|
|
36
36
|
city?: () => City;
|
|
37
|
+
country?: () => Country;
|
|
37
38
|
asn?: () => Asn;
|
|
38
39
|
}): ReaderModel => {
|
|
39
40
|
const notSupported = (): never => {
|
|
@@ -42,10 +43,24 @@ const reader = (behaviour: {
|
|
|
42
43
|
};
|
|
43
44
|
return {
|
|
44
45
|
city: behaviour.city ?? notSupported,
|
|
46
|
+
country: behaviour.country ?? notSupported,
|
|
45
47
|
asn: behaviour.asn ?? notSupported,
|
|
46
48
|
} as unknown as ReaderModel;
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The error geoip2-node raises when an accessor doesn't match the opened
|
|
53
|
+
* database — `city()` against GeoLite2-Country, for instance. It is identified
|
|
54
|
+
* by `name`, so the name is what the fake sets.
|
|
55
|
+
*/
|
|
56
|
+
const badMethodCall = (): never => {
|
|
57
|
+
const error = new Error(
|
|
58
|
+
"The city() method cannot be used with the GeoLite2-Country database",
|
|
59
|
+
);
|
|
60
|
+
error.name = "BadMethodCallError";
|
|
61
|
+
throw error;
|
|
62
|
+
};
|
|
63
|
+
|
|
49
64
|
const cityData = (overrides: Partial<City> = {}): City =>
|
|
50
65
|
({
|
|
51
66
|
country: { isoCode: "US", names: { en: "United States" } },
|
|
@@ -60,6 +75,13 @@ const cityData = (overrides: Partial<City> = {}): City =>
|
|
|
60
75
|
...overrides,
|
|
61
76
|
}) as City;
|
|
62
77
|
|
|
78
|
+
const countryData = (overrides: Partial<Country> = {}): Country =>
|
|
79
|
+
({
|
|
80
|
+
country: { isoCode: "US", names: { en: "United States" } },
|
|
81
|
+
traits: {},
|
|
82
|
+
...overrides,
|
|
83
|
+
}) as Country;
|
|
84
|
+
|
|
63
85
|
const asnData = (overrides: Partial<Asn> = {}): Asn =>
|
|
64
86
|
({
|
|
65
87
|
autonomousSystemNumber: 15169,
|
|
@@ -547,6 +569,150 @@ describe("MaxMindBackend.lookup", () => {
|
|
|
547
569
|
});
|
|
548
570
|
});
|
|
549
571
|
|
|
572
|
+
describe("MaxMindBackend with a Country-only geo database", () => {
|
|
573
|
+
// Production points cityDbPath at GeoLite2-Country.mmdb (MAXMIND_DB_PATH).
|
|
574
|
+
// Reader.open accepts it, so isAvailable() reports true, but city() rejects
|
|
575
|
+
// the database type on every call. Before this was handled the backend
|
|
576
|
+
// answered "No MaxMind data available for IP" for every address — a fallback
|
|
577
|
+
// that looked healthy and had never once produced an answer.
|
|
578
|
+
const countryBackend = async (options: {
|
|
579
|
+
city?: () => City;
|
|
580
|
+
country?: () => Country;
|
|
581
|
+
}): Promise<MaxMindBackend> => {
|
|
582
|
+
const backend = new MaxMindBackend({
|
|
583
|
+
cityDbPath: CITY_DB,
|
|
584
|
+
openReader: opens({
|
|
585
|
+
city: reader({
|
|
586
|
+
city: options.city ?? badMethodCall,
|
|
587
|
+
country: options.country ?? ((): Country => countryData()),
|
|
588
|
+
}),
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
await backend.initialize();
|
|
592
|
+
return backend;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
it("answers from country() when city() rejects the database type", async () => {
|
|
596
|
+
const backend = await countryBackend({});
|
|
597
|
+
|
|
598
|
+
const result = expectValid(await backend.lookup(IP));
|
|
599
|
+
|
|
600
|
+
expect(result).toMatchObject({
|
|
601
|
+
ip: IP,
|
|
602
|
+
isValid: true,
|
|
603
|
+
country: "United States",
|
|
604
|
+
countryCode: "US",
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("leaves the city-only fields unset rather than inventing them", async () => {
|
|
609
|
+
// Country-level data is enough for geoblocking but cannot support the
|
|
610
|
+
// distance comparison, and consumers must be able to tell.
|
|
611
|
+
const backend = await countryBackend({});
|
|
612
|
+
|
|
613
|
+
const result = expectValid(await backend.lookup(IP));
|
|
614
|
+
|
|
615
|
+
expect(result.city).toBeUndefined();
|
|
616
|
+
expect(result.region).toBeUndefined();
|
|
617
|
+
expect(result.latitude).toBeUndefined();
|
|
618
|
+
expect(result.longitude).toBeUndefined();
|
|
619
|
+
expect(result.timezone).toBeUndefined();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("still reads the traits the country record carries", async () => {
|
|
623
|
+
const backend = await countryBackend({
|
|
624
|
+
country: (): Country =>
|
|
625
|
+
countryData({
|
|
626
|
+
traits: {
|
|
627
|
+
isAnonymousVpn: true,
|
|
628
|
+
autonomousSystemNumber: 15169,
|
|
629
|
+
autonomousSystemOrganization: "Google LLC",
|
|
630
|
+
},
|
|
631
|
+
} as Partial<Country>),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
const result = expectValid(await backend.lookup(IP));
|
|
635
|
+
|
|
636
|
+
expect(result.isVPN).toBe(true);
|
|
637
|
+
expect(result.asnNumber).toBe(15169);
|
|
638
|
+
expect(result.providerName).toBe("Google LLC");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("stops calling city() once the database type is known", async () => {
|
|
642
|
+
// Constructing the rejection is the expensive part; paying it per request
|
|
643
|
+
// on every lookup for the life of the process is not acceptable.
|
|
644
|
+
const city = vi.fn(badMethodCall);
|
|
645
|
+
const backend = await countryBackend({ city });
|
|
646
|
+
|
|
647
|
+
await backend.lookup(IP);
|
|
648
|
+
await backend.lookup(IP);
|
|
649
|
+
await backend.lookup(IP);
|
|
650
|
+
|
|
651
|
+
expect(city).toHaveBeenCalledTimes(1);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("warns once, naming the database, when it discovers the mismatch", async () => {
|
|
655
|
+
// Silent degradation is what let this sit unnoticed; the path an operator
|
|
656
|
+
// has to correct is in the payload.
|
|
657
|
+
const warn = vi.fn();
|
|
658
|
+
const backend = new MaxMindBackend({
|
|
659
|
+
cityDbPath: CITY_DB,
|
|
660
|
+
openReader: opens({
|
|
661
|
+
city: reader({
|
|
662
|
+
city: badMethodCall,
|
|
663
|
+
country: (): Country => countryData(),
|
|
664
|
+
}),
|
|
665
|
+
}),
|
|
666
|
+
logger: { warn, info: vi.fn(), debug: vi.fn() } as never,
|
|
667
|
+
});
|
|
668
|
+
await backend.initialize();
|
|
669
|
+
|
|
670
|
+
await backend.lookup(IP);
|
|
671
|
+
await backend.lookup(IP);
|
|
672
|
+
|
|
673
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
674
|
+
type WarnPayload = { msg: string; data: { dbPath: string } };
|
|
675
|
+
const payload: WarnPayload = (
|
|
676
|
+
warn.mock.calls[0]?.[0] as () => WarnPayload
|
|
677
|
+
)();
|
|
678
|
+
expect(payload.msg).toBe(
|
|
679
|
+
"MaxMind geo database is not a City database; falling back to country-level lookups",
|
|
680
|
+
);
|
|
681
|
+
expect(payload.data.dbPath).toBe(CITY_DB);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("reports no data when the country lookup finds nothing either", async () => {
|
|
685
|
+
const backend = await countryBackend({
|
|
686
|
+
country: (): never => {
|
|
687
|
+
throw new Error("address not found");
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
await expect(backend.lookup(IP)).resolves.toEqual({
|
|
692
|
+
isValid: false,
|
|
693
|
+
error: "No MaxMind data available for IP",
|
|
694
|
+
ip: IP,
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("never calls country() when the database really is a City database", async () => {
|
|
699
|
+
// The fallback must not become a second lookup on the happy path.
|
|
700
|
+
const country = vi.fn((): Country => countryData());
|
|
701
|
+
const backend = new MaxMindBackend({
|
|
702
|
+
cityDbPath: CITY_DB,
|
|
703
|
+
openReader: opens({
|
|
704
|
+
city: reader({ city: () => cityData(), country }),
|
|
705
|
+
}),
|
|
706
|
+
});
|
|
707
|
+
await backend.initialize();
|
|
708
|
+
|
|
709
|
+
await backend.lookup(IP);
|
|
710
|
+
await backend.lookup(IP);
|
|
711
|
+
|
|
712
|
+
expect(country).not.toHaveBeenCalled();
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
|
|
550
716
|
describe("MaxMindBackend default reader", () => {
|
|
551
717
|
it("does not throw when the real database file is missing", async () => {
|
|
552
718
|
// Exercises the un-injected path: the lazy import of the MaxMind reader
|