@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/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 {
@@ -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;IAC5C,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"}
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.24",
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.7.0"
32
+ "@prosopo/types": "5.8.1"
33
33
  },
34
34
  "devDependencies": {
35
- "@prosopo/config": "3.3.14",
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",
@@ -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 {
@@ -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