@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/ipinfo",
3
- "version": "0.3.21",
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.7",
32
- "@prosopo/types": "5.5.3"
31
+ "@prosopo/logger": "2.0.9",
32
+ "@prosopo/types": "5.7.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@prosopo/config": "3.3.12",
35
+ "@prosopo/config": "3.3.14",
36
36
  "@types/node": "22.10.2",
37
- "@vitest/coverage-v8": "4.1.10",
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.10"
42
+ "vitest": "4.1.11"
43
43
  },
44
44
  "license": "Apache-2.0",
45
45
  "repository": {
@@ -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
- try {
107
- cityData = this.cityReader.city(ip);
108
- } catch (error) {
109
- this.config.logger?.debug(() => ({
110
- msg: "MaxMind City lookup failed",
111
- data: { ip },
112
- err: error,
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
- if (!cityData && !asnData) {
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: cityData?.traits?.isAnonymousVpn ?? false,
143
- isTor: cityData?.traits?.isTorExitNode ?? false,
193
+ isVPN: geoData?.traits?.isAnonymousVpn ?? false,
194
+ isTor: geoData?.traits?.isTorExitNode ?? false,
144
195
  isProxy:
145
- (cityData?.traits?.isPublicProxy ?? false) ||
146
- (cityData?.traits?.isResidentialProxy ?? false),
147
- isDatacenter: cityData?.traits?.isHostingProvider ?? false,
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: cityData?.traits?.isSatelliteProvider ?? false,
201
+ isSatellite: geoData?.traits?.isSatelliteProvider ?? false,
151
202
  isCrawler: false,
152
203
 
153
- // Geolocation from City DB
154
- country: cityData?.country?.names?.en,
155
- countryCode: cityData?.country?.isoCode,
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 City DB traits, fall back to ASN DB
214
+ // ASN info - prefer geo DB traits, fall back to ASN DB
163
215
  asnNumber:
164
- cityData?.traits?.autonomousSystemNumber ??
216
+ geoData?.traits?.autonomousSystemNumber ??
165
217
  asnData?.autonomousSystemNumber,
166
218
  asnOrganization:
167
- cityData?.traits?.autonomousSystemOrganization ??
219
+ geoData?.traits?.autonomousSystemOrganization ??
168
220
  asnData?.autonomousSystemOrganization,
169
221
 
170
222
  // Provider info from ASN
171
223
  providerName:
172
- cityData?.traits?.autonomousSystemOrganization ??
224
+ geoData?.traits?.autonomousSystemOrganization ??
173
225
  asnData?.autonomousSystemOrganization,
174
- providerType: mapUserType(cityData?.traits?.userType),
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