@prosopo/ipinfo 0.3.24 → 0.4.0
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 +4 -4
- package/.turbo/turbo-build$colon$tsc.log +10 -10
- package/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +33 -0
- package/dist/IpInfoService.d.ts +1 -0
- package/dist/IpInfoService.d.ts.map +1 -1
- package/dist/IpInfoService.js +20 -0
- package/dist/IpInfoService.js.map +1 -1
- package/dist/backends/maxmind.d.ts +1 -0
- package/dist/backends/maxmind.d.ts.map +1 -1
- package/dist/backends/maxmind.js +27 -0
- package/dist/backends/maxmind.js.map +1 -1
- package/dist/cjs/IpInfoService.cjs +20 -0
- package/dist/cjs/backends/maxmind.cjs +27 -0
- package/dist/tests/ipInfoService.unit.test.js +37 -1
- package/dist/tests/ipInfoService.unit.test.js.map +1 -1
- package/dist/tests/maxmind.unit.test.js +60 -0
- package/dist/tests/maxmind.unit.test.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/IpInfoService.ts +21 -0
- package/src/backends/maxmind.ts +36 -0
- package/src/tests/ipInfoService.unit.test.ts +59 -0
- package/src/tests/maxmind.unit.test.ts +73 -0
- package/src/types.ts +6 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { IPInfoResponse } from "@prosopo/types";
|
|
|
3
3
|
export interface IIpInfoService {
|
|
4
4
|
initialize(): Promise<void>;
|
|
5
5
|
lookup(ip: string): Promise<IPInfoResponse>;
|
|
6
|
+
country(ip: string): string | undefined;
|
|
6
7
|
isAvailable(): boolean;
|
|
7
8
|
}
|
|
8
9
|
export interface IpInfoServiceConfig {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC9B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD,MAAM,WAAW,cAAc;IAC9B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAM5C,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACxC,WAAW,IAAI,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,mBAAmB;IACnC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prosopo/ipinfo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@maxmind/geoip2-node": "7.1.0",
|
|
31
31
|
"@prosopo/logger": "2.0.9",
|
|
32
|
-
"@prosopo/types": "5.
|
|
32
|
+
"@prosopo/types": "5.8.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@prosopo/config": "3.3.
|
|
35
|
+
"@prosopo/config": "3.3.15",
|
|
36
36
|
"@types/node": "22.10.2",
|
|
37
37
|
"@vitest/coverage-v8": "4.1.11",
|
|
38
38
|
"del-cli": "6.0.0",
|
package/src/IpInfoService.ts
CHANGED
|
@@ -117,6 +117,27 @@ export class IpInfoService implements IIpInfoService {
|
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* MaxMind-only country fast path: the ISO 3166-1 alpha-2 code for `ip`, or
|
|
122
|
+
* undefined when it cannot be determined.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately NOT `lookup()`. `lookup()` prefers ipapi.is for its threat
|
|
125
|
+
* data, which puts a network call to the sidecar on the caller's path; a
|
|
126
|
+
* caller that only wants a country does not need that data and must not pay
|
|
127
|
+
* for it. The MaxMind database is memory-mapped and read in-process, so this
|
|
128
|
+
* is synchronous and cannot fail on a network or a slow sidecar.
|
|
129
|
+
*
|
|
130
|
+
* Returns undefined rather than throwing for every failure mode — no
|
|
131
|
+
* MaxMind backend configured, backend not initialised yet, non-routable
|
|
132
|
+
* address, address absent from the database — so callers have exactly one
|
|
133
|
+
* "no answer" case to handle.
|
|
134
|
+
*/
|
|
135
|
+
country(ip: string): string | undefined {
|
|
136
|
+
if (isNonRoutable(ip)) return undefined;
|
|
137
|
+
if (!this.maxmindBackend?.isAvailable()) return undefined;
|
|
138
|
+
return this.maxmindBackend.countryCode(ip);
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
async lookup(ip: string): Promise<IPInfoResponse> {
|
|
121
142
|
if (isNonRoutable(ip)) {
|
|
122
143
|
return {
|
package/src/backends/maxmind.ts
CHANGED
|
@@ -109,6 +109,42 @@ export class MaxMindBackend {
|
|
|
109
109
|
return this.cityReader !== null || this.asnReader !== null;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* ISO 3166-1 alpha-2 country code, or undefined when the reader has no
|
|
114
|
+
* answer. Synchronous and allocation-light: the .mmdb is memory-mapped, so
|
|
115
|
+
* this is a tree walk with no I/O and no network.
|
|
116
|
+
*
|
|
117
|
+
* Separate from `lookup()` because callers that only need the country
|
|
118
|
+
* should not pay for the ASN read, the threat-field assembly, or the
|
|
119
|
+
* `IPInfoResponse` object. The reader-kind latch is shared with `lookup()`
|
|
120
|
+
* — `country()` rejects a City database and `city()` rejects a Country one,
|
|
121
|
+
* so the accessor has to be chosen from the database's own metadata.
|
|
122
|
+
*/
|
|
123
|
+
countryCode(ip: string): string | undefined {
|
|
124
|
+
if (!this.cityReader) return undefined;
|
|
125
|
+
|
|
126
|
+
if (this.geoReaderKind !== "country") {
|
|
127
|
+
try {
|
|
128
|
+
const isoCode = this.cityReader.city(ip).country?.isoCode;
|
|
129
|
+
this.geoReaderKind = "city";
|
|
130
|
+
return isoCode;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!isBadMethodCall(error)) {
|
|
133
|
+
// Address not in the database, or an invalid address: no
|
|
134
|
+
// country, and nothing to latch.
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
this.geoReaderKind = "country";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
return this.cityReader.country(ip).country?.isoCode;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
async lookup(ip: string): Promise<IPInfoResponse> {
|
|
113
149
|
if (!this.isAvailable()) {
|
|
114
150
|
return {
|
|
@@ -55,15 +55,18 @@ interface StubBackend {
|
|
|
55
55
|
initialize: () => Promise<void>;
|
|
56
56
|
isAvailable: () => boolean;
|
|
57
57
|
lookup: (ip: string) => Promise<IPInfoResponse>;
|
|
58
|
+
countryCode: (ip: string) => string | undefined;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
const stub = (
|
|
61
62
|
available: boolean,
|
|
62
63
|
answer: IPInfoResponse = result("stub"),
|
|
64
|
+
countryCode: string | undefined = "US",
|
|
63
65
|
): StubBackend => ({
|
|
64
66
|
initialize: vi.fn<() => Promise<void>>(async () => {}),
|
|
65
67
|
isAvailable: vi.fn<() => boolean>(() => available),
|
|
66
68
|
lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () => answer),
|
|
69
|
+
countryCode: vi.fn<(ip: string) => string | undefined>(() => countryCode),
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -410,6 +413,7 @@ describe("IpInfoService.lookup", () => {
|
|
|
410
413
|
lookup: vi.fn<(ip: string) => Promise<IPInfoResponse>>(async () =>
|
|
411
414
|
result("recovered"),
|
|
412
415
|
),
|
|
416
|
+
countryCode: vi.fn<(ip: string) => string | undefined>(() => undefined),
|
|
413
417
|
};
|
|
414
418
|
const svc = service({ ipapi: flaky });
|
|
415
419
|
|
|
@@ -420,3 +424,58 @@ describe("IpInfoService.lookup", () => {
|
|
|
420
424
|
expect(await svc.lookup(IP)).toEqual(result("recovered"));
|
|
421
425
|
});
|
|
422
426
|
});
|
|
427
|
+
|
|
428
|
+
describe("IpInfoService.country", () => {
|
|
429
|
+
it("answers from MaxMind", () => {
|
|
430
|
+
expect(
|
|
431
|
+
service({ maxmind: stub(true, result("maxmind"), "GB") }).country(IP),
|
|
432
|
+
).toBe("GB");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("never consults ipapi, even when ipapi is the preferred backend", () => {
|
|
436
|
+
// `lookup()` prefers ipapi for its threat data, which costs a call to
|
|
437
|
+
// the sidecar. A country lookup does not need that data and must not
|
|
438
|
+
// pay for it.
|
|
439
|
+
const ipapi = stub(true, result("ipapi"));
|
|
440
|
+
const maxmind = stub(true, result("maxmind"), "FR");
|
|
441
|
+
|
|
442
|
+
expect(service({ maxmind, ipapi }).country(IP)).toBe("FR");
|
|
443
|
+
|
|
444
|
+
expect(ipapi.lookup).not.toHaveBeenCalled();
|
|
445
|
+
expect(ipapi.isAvailable).not.toHaveBeenCalled();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("has no answer for a loopback caller, without reading the database", () => {
|
|
449
|
+
// Deploy gates and container health checks call over loopback; they
|
|
450
|
+
// must cost nothing and must never resolve to a country.
|
|
451
|
+
const maxmind = stub(true, result("maxmind"), "US");
|
|
452
|
+
|
|
453
|
+
expect(service({ maxmind }).country("127.0.0.1")).toBeUndefined();
|
|
454
|
+
expect(service({ maxmind }).country("::1")).toBeUndefined();
|
|
455
|
+
expect(service({ maxmind }).country("10.1.2.3")).toBeUndefined();
|
|
456
|
+
|
|
457
|
+
expect(maxmind.countryCode).not.toHaveBeenCalled();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("has no answer when no MaxMind backend is configured", () => {
|
|
461
|
+
expect(service({ ipapi: stub(true) }).country(IP)).toBeUndefined();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("has no answer while MaxMind is still unavailable", () => {
|
|
465
|
+
// Availability is false until the environment finishes becoming ready,
|
|
466
|
+
// which is exactly when a caller that cannot await readiness asks.
|
|
467
|
+
const maxmind = stub(false, result("maxmind"), "DE");
|
|
468
|
+
|
|
469
|
+
expect(service({ maxmind }).country(IP)).toBeUndefined();
|
|
470
|
+
expect(maxmind.countryCode).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("has no answer when the address is absent from the database", () => {
|
|
474
|
+
const maxmind: StubBackend = {
|
|
475
|
+
...stub(true, result("maxmind")),
|
|
476
|
+
countryCode: vi.fn<(ip: string) => string | undefined>(() => undefined),
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
expect(service({ maxmind }).country(IP)).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
@@ -726,3 +726,76 @@ describe("MaxMindBackend default reader", () => {
|
|
|
726
726
|
expect(backend.isAvailable()).toBe(false);
|
|
727
727
|
});
|
|
728
728
|
});
|
|
729
|
+
|
|
730
|
+
describe("MaxMindBackend.countryCode", () => {
|
|
731
|
+
it("reads the country from a City database", async () => {
|
|
732
|
+
const backend = new MaxMindBackend({
|
|
733
|
+
cityDbPath: CITY_DB,
|
|
734
|
+
openReader: opens({ city: reader({ city: () => cityData() }) }),
|
|
735
|
+
});
|
|
736
|
+
await backend.initialize();
|
|
737
|
+
|
|
738
|
+
expect(backend.countryCode(IP)).toBe("US");
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("falls back to country() on a Country database and latches the choice", async () => {
|
|
742
|
+
const city = vi.fn(badMethodCall);
|
|
743
|
+
const country = vi.fn((): Country => countryData());
|
|
744
|
+
const backend = new MaxMindBackend({
|
|
745
|
+
cityDbPath: CITY_DB,
|
|
746
|
+
openReader: opens({ city: reader({ city, country }) }),
|
|
747
|
+
});
|
|
748
|
+
await backend.initialize();
|
|
749
|
+
|
|
750
|
+
expect(backend.countryCode(IP)).toBe("US");
|
|
751
|
+
expect(backend.countryCode(IP)).toBe("US");
|
|
752
|
+
|
|
753
|
+
// The mismatched accessor is tried once, not once per call.
|
|
754
|
+
expect(city).toHaveBeenCalledTimes(1);
|
|
755
|
+
expect(country).toHaveBeenCalledTimes(2);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("has no answer when the address is absent from the database", async () => {
|
|
759
|
+
const backend = new MaxMindBackend({
|
|
760
|
+
cityDbPath: CITY_DB,
|
|
761
|
+
openReader: opens({
|
|
762
|
+
city: reader({
|
|
763
|
+
city: (): never => {
|
|
764
|
+
throw new Error("The address 8.8.8.8 is not in the database");
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
}),
|
|
768
|
+
});
|
|
769
|
+
await backend.initialize();
|
|
770
|
+
|
|
771
|
+
expect(backend.countryCode(IP)).toBeUndefined();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("has no answer when no geo database was opened", async () => {
|
|
775
|
+
const backend = new MaxMindBackend({
|
|
776
|
+
asnDbPath: ASN_DB,
|
|
777
|
+
openReader: opens({ asn: reader({ asn: () => asnData() }) }),
|
|
778
|
+
});
|
|
779
|
+
await backend.initialize();
|
|
780
|
+
|
|
781
|
+
expect(backend.isAvailable()).toBe(true);
|
|
782
|
+
expect(backend.countryCode(IP)).toBeUndefined();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("does not read the ASN database", async () => {
|
|
786
|
+
// The country is the whole answer; nothing else is worth a second read.
|
|
787
|
+
const asn = vi.fn((): Asn => asnData());
|
|
788
|
+
const backend = new MaxMindBackend({
|
|
789
|
+
cityDbPath: CITY_DB,
|
|
790
|
+
asnDbPath: ASN_DB,
|
|
791
|
+
openReader: opens({
|
|
792
|
+
city: reader({ city: () => cityData() }),
|
|
793
|
+
asn: reader({ asn }),
|
|
794
|
+
}),
|
|
795
|
+
});
|
|
796
|
+
await backend.initialize();
|
|
797
|
+
|
|
798
|
+
expect(backend.countryCode(IP)).toBe("US");
|
|
799
|
+
expect(asn).not.toHaveBeenCalled();
|
|
800
|
+
});
|
|
801
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,12 @@ import type { IPInfoResponse } from "@prosopo/types";
|
|
|
18
18
|
export interface IIpInfoService {
|
|
19
19
|
initialize(): Promise<void>;
|
|
20
20
|
lookup(ip: string): Promise<IPInfoResponse>;
|
|
21
|
+
/**
|
|
22
|
+
* ISO 3166-1 alpha-2 country code from the local MaxMind database only.
|
|
23
|
+
* Synchronous and network-free, unlike `lookup()`. Undefined whenever no
|
|
24
|
+
* answer is available.
|
|
25
|
+
*/
|
|
26
|
+
country(ip: string): string | undefined;
|
|
21
27
|
isAvailable(): boolean;
|
|
22
28
|
}
|
|
23
29
|
|