From 0446893bdc09b977ff35a35eb9317e9c155460e4 Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Thu, 5 Nov 2026 23:19:59 +0500 Subject: [PATCH 1/3] Update TypeScript SDK request handling, types and Arabic support for 3.0.1 --- README.md | 10 +- index.d.mts | 1 + package-lock.json | 4 +- package.json | 14 +- packages/types/README.md | 75 ++---- packages/types/package-lock.json | 4 +- packages/types/package.json | 15 +- packages/types/src/index.ts | 16 +- scripts/check-packages.cjs | 37 ++- src/client.ts | 38 ++- src/config.ts | 11 +- src/enums.ts | 1 + src/internal/redaction.ts | 125 ++++++++++ src/internal/serde.ts | 32 ++- src/internal/shared.ts | 62 ++++- src/internal/transport.ts | 82 +++++-- src/json-output.ts | 7 +- src/models.ts | 17 +- src/request-models.ts | 11 +- src/version.ts | 2 +- tests/live/live-field-parity.test.ts | 2 +- tests/package-consumer.ts | 26 ++ tests/unit/redirects.test.ts | 145 +++++++++++ tests/unit/request-safety.test.ts | 353 +++++++++++++++++++++++++++ 24 files changed, 937 insertions(+), 153 deletions(-) create mode 100644 index.d.mts create mode 100644 src/internal/redaction.ts create mode 100644 tests/package-consumer.ts create mode 100644 tests/unit/redirects.test.ts create mode 100644 tests/unit/request-safety.test.ts diff --git a/README.md b/README.md index 10ccb7f..4529e8f 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on You can also pass a custom `HttpTransport` as the second constructor argument. +If you set `baseUrl`, use the final API address. In Node.js, the built-in client follows redirects only within the same protocol, host and port. Browsers hide the redirect address, so the client stops at a redirect. + ## Available Methods | Method | Returns | Notes | @@ -209,7 +211,7 @@ You can also pass a custom `HttpTransport` as the second constructor argument. |--------|------------|-------| | `ip` | Single lookup | IPv4, IPv6, or domain. Omit it for caller IP lookup. Domain lookup requires a paid plan. | | `ips` | Bulk lookup | Iterable of 1 to 50,000 IPs or domains. | -| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`. | +| `lang` | Single and bulk | One of `en`, `de`, `ru`, `ja`, `fr`, `cn`, `es`, `cs`, `it`, `ko`, `fa`, `pt`, `ar` (Arabic). | | `include` | Single and bulk | Iterable of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. | | `fields` | Single and bulk | Iterable of field paths to keep, for example `["location.country_name", "security.threat_score"]`. | | `excludes` | Single and bulk | Iterable of field paths to remove from the response. | @@ -413,7 +415,7 @@ for (const result of response.data) { ### Success and Error Results -Each bulk result is either a success with `data` or an error with `error.message`. TypeScript narrows it automatically on `result.success`. +Each bulk result is either a success with `data` or an error with `error.message`. TypeScript narrows it automatically on `result.success`. If the API sends a different number of results than you requested, the typed lookup throws `SerializationError`. The raw lookup returns the response as received. ```ts const response = await client.bulkLookupIpGeolocation({ @@ -465,7 +467,7 @@ console.log(response.metadata.firstHeaderValue("x-credits-charged")); // the sam ## JSON Helpers -Use `toJson()` or `toPrettyJson()` when you want a stable JSON view of SDK objects. +Use `toJson()` or `toPrettyJson()` to print SDK objects as JSON. When you print a client or its settings as JSON, the SDK shows `[REDACTED]` in place of your API key. `config.apiKey` still gives you the key. ```ts import { toPrettyJson } from "ip-geolocation-api-sdk-typescript"; @@ -479,7 +481,7 @@ console.log(toPrettyJson(response.data)); ## Error Handling -All SDK errors extend `IpGeolocationError`. +All SDK errors extend `IpGeolocationError`. If an API error includes your key, the SDK hides it. This also applies to error messages in typed bulk results. If the SDK receives an HTTP status but can’t read the error body, you still get the status code. Runtime and validation errors: diff --git a/index.d.mts b/index.d.mts new file mode 100644 index 0000000..ebc7108 --- /dev/null +++ b/index.d.mts @@ -0,0 +1 @@ +export * from "./dist/index.cjs"; diff --git a/package-lock.json b/package-lock.json index adff510..4844730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ip-geolocation-api-sdk-typescript", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ip-geolocation-api-sdk-typescript", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "devDependencies": { "@types/node": "^24.5.2", diff --git a/package.json b/package.json index 7e5200e..e129d11 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "IPGeolocation.io", "name": "ip-geolocation-api-sdk-typescript", - "version": "3.0.0", + "version": "3.0.1", "description": "Official TypeScript SDK for the IPGeolocation IP Location API with single and bulk lookup, typed JSON, and raw JSON/XML support.", "license": "MIT", "type": "module", @@ -10,14 +10,20 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./index.d.mts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, "./package.json": "./package.json" }, "files": [ "dist", + "index.d.mts", "README.md", "CHANGELOG.md", "LICENSE" diff --git a/packages/types/README.md b/packages/types/README.md index 2d8498d..053b943 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -103,74 +103,33 @@ This package is type declarations plus minimal JS stubs for package resolution. ## Quick Start -This example uses your own `fetch` wrapper and the public request and response types from this package. +This Node.js example requests the country name with `fetch`. The API sends `country_name`, so the example copies it to the SDK’s `countryName` field. ```ts -import type { - ApiResponse, - IpGeolocationResponse, - LookupIpGeolocationRequestInit, -} from "ip-geolocation-api-typescript-types"; - -async function lookupIp( - request: LookupIpGeolocationRequestInit, -): Promise> { - const url = new URL("https://api.ipgeolocation.io/v3/ipgeo"); - - url.searchParams.set("apiKey", process.env.IPGEO_API_KEY!); - - if (request.ip) { - url.searchParams.set("ip", request.ip); - } - - if (request.lang) { - url.searchParams.set("lang", request.lang); - } - - if (request.include) { - url.searchParams.set("include", [...request.include].join(",")); - } +import type { IpGeolocationResponse } from "ip-geolocation-api-typescript-types"; - if (request.fields) { - url.searchParams.set("fields", [...request.fields].join(",")); - } +async function lookupCountry(ip: string): Promise { + const apiKey = process.env.IPGEO_API_KEY; + if (!apiKey) throw new Error("Set IPGEO_API_KEY before making a lookup."); - if (request.excludes) { - url.searchParams.set("excludes", [...request.excludes].join(",")); - } + const url = new URL("https://api.ipgeolocation.io/v3/ipgeo"); + url.searchParams.set("apiKey", apiKey); + url.searchParams.set("ip", ip); + url.searchParams.set("fields", "location.country_name"); - if (request.output) { - url.searchParams.set("output", request.output); - } + const response = await fetch(url, { redirect: "error" }); + if (!response.ok) throw new Error(`Lookup failed (HTTP ${response.status}).`); - const response = await fetch(url.toString(), { - // Adapt request.headers here if your wrapper supports custom headers. - }); - - const data = (await response.json()) as IpGeolocationResponse; - - return { - data, - metadata: { - statusCode: response.status, - durationMs: 0, - creditsCharged: undefined, - successfulRecords: undefined, - rawHeaders: {}, - headerValues() { - return []; - }, - firstHeaderValue() { - return undefined; - }, - }, - }; + const body = await response.json(); + const countryName: unknown = body?.location?.country_name; + if (typeof countryName !== "string") throw new Error("No country name in the response."); + return { location: { countryName } }; } ``` ## Bulk Result Typing -Bulk lookups return a mixed array of success and error results. `BulkLookupResult` matches that shape. +Use this function with rows returned by the SDK’s typed bulk lookup. If you use `fetch` yourself, convert each successful row’s field names and wrap it as `{ success: true, data }`. Wrap each error as `{ success: false, error: { message } }`. ```ts import type { BulkLookupResult } from "ip-geolocation-api-typescript-types"; @@ -225,7 +184,7 @@ const headers: RequestHeaders = [ ## Links - [npm package](https://www.npmjs.com/package/ip-geolocation-api-typescript-types) -- [GitHub repository](https://github.com/IPGeolocation/ip-geolocation-api-typescript-sdk/tree/main/packages/types) +- [GitHub repository](https://github.com/IPGeolocation/ip-geolocation-api-typescript-sdk/tree/master/packages/types) - [API documentation](https://ipgeolocation.io/documentation/ip-location-api.html) - [Pricing](https://ipgeolocation.io/pricing.html) - [Authentication](https://ipgeolocation.io/documentation/api-authentication.html) diff --git a/packages/types/package-lock.json b/packages/types/package-lock.json index 54082e8..8ba6e10 100644 --- a/packages/types/package-lock.json +++ b/packages/types/package-lock.json @@ -1,12 +1,12 @@ { "name": "ip-geolocation-api-typescript-types", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ip-geolocation-api-typescript-types", - "version": "3.0.0", + "version": "3.0.1", "license": "MIT", "devDependencies": { "tsup": "^8.5.0", diff --git a/packages/types/package.json b/packages/types/package.json index 5d9a761..1f9931d 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,7 +1,7 @@ { "author": "IPGeolocation.io", "name": "ip-geolocation-api-typescript-types", - "version": "3.0.0", + "version": "3.0.1", "description": "Public TypeScript types for the IPGeolocation IP Location API v3 SDKs.", "license": "MIT", "type": "module", @@ -10,9 +10,14 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } }, "./package.json": "./package.json" }, @@ -33,7 +38,7 @@ "bugs": { "url": "https://github.com/IPGeolocation/ip-geolocation-api-typescript-sdk/issues" }, - "homepage": "https://github.com/IPGeolocation/ip-geolocation-api-typescript-sdk/tree/main/packages/types", + "homepage": "https://github.com/IPGeolocation/ip-geolocation-api-typescript-sdk/tree/master/packages/types", "keywords": [ "ipgeolocation", "geolocation", diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ebb260d..f10df9f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,7 +18,8 @@ export type Language = | "it" | "ko" | "fa" - | "pt"; + | "pt" + | "ar"; export type ResponseFormat = "json" | "xml"; @@ -155,6 +156,8 @@ export interface Network { connectionType?: string; route?: string; isAnycast?: boolean; + isCdn?: boolean; + cdnProviderName?: string; } export interface Security { @@ -164,6 +167,9 @@ export interface Security { proxyProviderNames?: readonly string[]; proxyConfidenceScore?: number; proxyLastSeen?: string; + proxyDaysSeen7d?: number; + proxyDaysSeen30d?: number; + proxyProviderCount30d?: number; isResidentialProxy?: boolean; isVpn?: boolean; vpnProviderNames?: readonly string[]; @@ -174,6 +180,14 @@ export interface Security { isAnonymous?: boolean; isKnownAttacker?: boolean; isBot?: boolean; + botConfidenceScore?: number; + botOperatorName?: string; + botType?: string; + botLastSeen?: string; + isKnownGoodBot?: boolean; + isCorporateGateway?: boolean; + corporateGatewayType?: string; + corporateGatewayProviderName?: string; isSpam?: boolean; isCloudProvider?: boolean; cloudProviderName?: string; diff --git a/scripts/check-packages.cjs b/scripts/check-packages.cjs index 8ebab4d..57df695 100644 --- a/scripts/check-packages.cjs +++ b/scripts/check-packages.cjs @@ -1,5 +1,7 @@ const assert = require('node:assert/strict'); -const { access } = require('node:fs/promises'); +const { access, copyFile, mkdir, mkdtemp, rm, symlink } = require('node:fs/promises'); +const { execFileSync } = require('node:child_process'); +const { tmpdir } = require('node:os'); const { createRequire } = require('node:module'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); @@ -9,7 +11,8 @@ async function main() { const esm = await import(pkg.name); const cjs = require(pkg.name); assert.deepEqual(Object.keys(esm).sort(), Object.keys(cjs).sort()); - assert.equal(esm.getVersion(), cjs.getVersion()); + assert.equal(esm.getVersion(), pkg.version); + assert.equal(cjs.getVersion(), pkg.version); await access(path.resolve(__dirname, '..', pkg.types)); await access(path.resolve(__dirname, '..', 'dist/index.d.cts')); @@ -17,6 +20,10 @@ async function main() { const config = new sdk.IpGeolocationClientConfig({ apiKey: 'package-test' }); assert.equal(config.baseUrl, 'https://api.ipgeolocation.io'); assert.equal(Object.isFrozen(config), true); + for (const output of [esm.toJson(config), cjs.toPrettyJson(config), JSON.stringify(config)]) { + assert.equal(JSON.parse(output).apiKey, '[REDACTED]'); + } + assert.equal(sdk.IpGeolocationClient.defaultUserAgent(), 'ipgeolocation-typescript-sdk/' + pkg.version); const request = new sdk.LookupIpGeolocationRequest({ ip: '2001:db8::1' }); assert.equal(request.ip, '2001:db8::1'); assert.equal(request.output, sdk.ResponseFormat.JSON); @@ -29,12 +36,36 @@ async function main() { const typesManifest = path.join(typesRoot, 'package.json'); const typesPkg = require(typesManifest); const typesCjs = createRequire(typesManifest)(typesPkg.name); - const typesEsm = await import(pathToFileURL(path.join(typesRoot, typesPkg.exports['.'].import)).href); + const typesEsm = await import(pathToFileURL(path.join(typesRoot, typesPkg.exports['.'].import.default)).href); assert.deepEqual(Object.keys(typesEsm), []); assert.deepEqual(Object.keys(typesCjs), []); await access(path.join(typesRoot, typesPkg.types)); await access(path.join(typesRoot, 'dist/index.d.cts')); + assert.equal(typesPkg.version, pkg.version); + await checkConsumerTypes(pkg, typesPkg, typesRoot); console.log(`SDK and types package ESM, CommonJS and declarations checked on ${process.version}`); } +async function checkConsumerTypes(sdk, types, typesRoot) { + const consumer = await mkdtemp(path.join(tmpdir(), 'ipgeo-types-')); + try { + const modules = path.join(consumer, 'node_modules'); + await mkdir(modules); + const root = path.resolve(__dirname, '..'); + await symlink(root, path.join(modules, sdk.name), 'junction'); + await symlink(typesRoot, path.join(modules, types.name), 'junction'); + const fixture = path.join(root, 'tests/package-consumer.ts'); + for (const file of ['consumer.cts', 'consumer.mts']) { + await copyFile(fixture, path.join(consumer, file)); + } + for (const mode of ['Node16', 'NodeNext']) { + execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '--strict', '--noEmit', + '--target', 'ES2020', '--module', mode, '--moduleResolution', mode, 'consumer.cts', 'consumer.mts'], + { cwd: consumer, stdio: 'inherit' }); + } + } finally { + await rm(consumer, { recursive: true, force: true }); + } +} + main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/src/client.ts b/src/client.ts index 285bd07..40f6c2c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,7 @@ import { buildQuery, mergeHeaders, resolveUserAgentHeader, + safeTransportError, toApiError, toMetadata, validateJsonOutput, @@ -31,7 +32,7 @@ import { getVersion } from "./version"; export class IpGeolocationClient { private readonly config: IpGeolocationClientConfig; - private readonly transport: HttpTransport; + private readonly getTransport!: () => HttpTransport; private readonly ownsTransport: boolean; private closed = false; @@ -47,7 +48,8 @@ export class IpGeolocationClient { config instanceof IpGeolocationClientConfig ? config : new IpGeolocationClientConfig(config); - this.transport = transport ?? new FetchHttpTransport(); + const activeTransport = transport ?? new FetchHttpTransport(); + Object.defineProperty(this, "getTransport", { value: () => activeTransport }); this.ownsTransport = transport == null; } @@ -66,12 +68,8 @@ export class IpGeolocationClient { const httpRequest = this.buildLookupHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { - data: parseSingleLookup(response.body), + data: parseSingleLookup(response.body, this.config.apiKey), metadata: toMetadata(response.statusCode, durationMs, response.headers), }; } @@ -86,10 +84,6 @@ export class IpGeolocationClient { const httpRequest = this.buildLookupHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { data: response.body, metadata: toMetadata(response.statusCode, durationMs, response.headers), @@ -109,12 +103,8 @@ export class IpGeolocationClient { const httpRequest = this.buildBulkHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { - data: parseBulkLookup(response.body), + data: parseBulkLookup(response.body, normalizedRequest.ips.length, this.config.apiKey), metadata: toMetadata(response.statusCode, durationMs, response.headers), }; } @@ -131,10 +121,6 @@ export class IpGeolocationClient { const httpRequest = this.buildBulkHttpRequest(normalizedRequest); const { response, durationMs } = await this.executeWithMetrics(httpRequest); - if (Math.floor(response.statusCode / 100) !== 2) { - throw toApiError(response.statusCode, response.body); - } - return { data: response.body, metadata: toMetadata(response.statusCode, durationMs, response.headers), @@ -148,7 +134,7 @@ export class IpGeolocationClient { this.closed = true; if (this.ownsTransport) { - await this.transport.close(); + await this.getTransport().close(); } } @@ -269,7 +255,15 @@ export class IpGeolocationClient { request: HttpRequestData, ): Promise<{ response: HttpResponseData; durationMs: number }> { const started = Date.now(); - const response = await this.transport.send(request); + let response: HttpResponseData; + try { + response = await this.getTransport().send(request); + } catch (error) { + throw safeTransportError(error, this.config.apiKey); + } + if (Math.floor(response.statusCode / 100) !== 2) { + throw toApiError(response.statusCode, response.body, this.config.apiKey); + } return { response, durationMs: Math.max(0, Date.now() - started), diff --git a/src/config.ts b/src/config.ts index 40a18dc..dcfb7cf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { ValidationError } from "./errors"; +import { configForOutput, configJson } from "./internal/redaction"; import { containsCrOrLf, isPlainObjectRecord } from "./internal/shared"; const DEFAULT_BASE_URL = "https://api.ipgeolocation.io"; @@ -15,7 +16,7 @@ export interface IpGeolocationClientConfigInit { } export class IpGeolocationClientConfig { - public readonly apiKey: string | undefined; + declare public readonly apiKey: string | undefined; public readonly requestOrigin: string | undefined; public readonly baseUrl: string; public readonly connectTimeoutMs: number; @@ -41,7 +42,13 @@ export class IpGeolocationClientConfig { DEFAULT_READ_TIMEOUT_MS, ); - this.apiKey = apiKey; + const forOutput = () => configForOutput(this); + Object.defineProperties(this, { + apiKey: { enumerable: true, get: () => apiKey }, + toJSON: { value: forOutput }, + [configJson]: { value: forOutput }, + [Symbol.for("nodejs.util.inspect.custom")]: { value: forOutput }, + }); this.requestOrigin = requestOrigin; this.baseUrl = baseUrl; this.connectTimeoutMs = connectTimeoutMs; diff --git a/src/enums.ts b/src/enums.ts index 7161e00..f571728 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -11,6 +11,7 @@ export const Language = Object.freeze({ KO: "ko", FA: "fa", PT: "pt", + AR: "ar", } as const); export type Language = (typeof Language)[keyof typeof Language]; diff --git a/src/internal/redaction.ts b/src/internal/redaction.ts new file mode 100644 index 0000000..fe66988 --- /dev/null +++ b/src/internal/redaction.ts @@ -0,0 +1,125 @@ +import type { IpGeolocationClientConfig } from "../config"; + +const MASK = "[REDACTED]"; +export const configJson = Symbol.for("ipgeolocation.clientConfig.toJSON"); + +export function redactApiKey(text: string, key?: string): string { + if (!key) return text; + if (/^[A-Za-z0-9._-]+$/.test(key)) return text.split(key).join(MASK); + + const form = new URLSearchParams({ k: key }); + const encoded = encodeURIComponent(form.get("k")!); + let result = text; + for (const token of new Set([ + encoded.replace(/'/g, "%27"), + encoded, + form.toString().slice(2), + ])) { + if (token === key || token.length > result.length) continue; + const upperEscapes = (s: string) => + s.replace(/%[a-f\d]{2}/gi, (escape) => escape.toUpperCase()); + const haystack = upperEscapes(result); + const needle = upperEscapes(token); + let start = 0; + const parts: string[] = []; + for ( + let at = haystack.indexOf(needle); + at >= 0; + at = haystack.indexOf(needle, start) + ) { + parts.push(result.slice(start, at), MASK); + start = at + token.length; + } + if (parts.length) result = parts.join("") + result.slice(start); + } + const escaped = JSON.stringify(key).slice(1, -1); + if (escaped !== key) result = result.split(escaped).join(MASK); + return result.split(key).join(MASK); +} + +const nativeStackGetter = Object.getOwnPropertyDescriptor( + new Error(), + "stack", +)?.get; +const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + +export function hasSafeErrorDetails( + value: unknown, + key: string, + seen = new Set(), +): boolean { + if (typeof value === "string") return redactApiKey(value, key) === value; + if (value === null || typeof value !== "object") { + return typeof value !== "function" && typeof value !== "symbol"; + } + if (seen.has(value)) return true; + const proto = Object.getPrototypeOf(value); + if ( + seen.size >= 32 || + "toJSON" in value || + Symbol.toPrimitive in value || + inspectSymbol in value || + (!(value instanceof Error) && + !Array.isArray(value) && + proto !== null && + proto !== Object.prototype) + ) { + return false; + } + seen.add(value); + if (value instanceof Error) { + for ( + let p = proto; + p && p !== Error.prototype; + p = Object.getPrototypeOf(p) + ) { + for (const name of Reflect.ownKeys(p)) { + if (name === "constructor") continue; + const field = Object.getOwnPropertyDescriptor(p, name)!; + if ( + typeof name !== "string" || + name === "toString" || + name === "valueOf" || + !("value" in field) || + redactApiKey(name, key) !== name + ) + return false; + if ( + typeof field.value !== "function" && + !hasSafeErrorDetails(field.value, key, seen) + ) + return false; + } + } + } + for (const name of Reflect.ownKeys(value)) { + if (typeof name !== "string" || redactApiKey(name, key) !== name) + return false; + const property = Object.getOwnPropertyDescriptor(value, name)!; + let detail = property.value; + if (!("value" in property)) { + if ( + name !== "stack" || + !(value instanceof Error) || + !nativeStackGetter || + property.get !== nativeStackGetter + ) + return false; + detail = nativeStackGetter.call(value); + } + if (!hasSafeErrorDetails(detail, key, seen)) return false; + } + return true; +} + +export function configForOutput(config: IpGeolocationClientConfig) { + const key = config.apiKey; + return { + apiKey: key === undefined ? undefined : MASK, + requestOrigin: + config.requestOrigin && redactApiKey(config.requestOrigin, key), + baseUrl: redactApiKey(config.baseUrl, key), + connectTimeoutMs: config.connectTimeoutMs, + readTimeoutMs: config.readTimeoutMs, + }; +} diff --git a/src/internal/serde.ts b/src/internal/serde.ts index d21e216..70c4e91 100644 --- a/src/internal/serde.ts +++ b/src/internal/serde.ts @@ -1,5 +1,6 @@ import { LocationConfidence } from "../enums"; import { SerializationError } from "../errors"; +import { redactApiKey } from "./redaction"; import { isPlainObjectRecord } from "./shared"; import type { Abuse, @@ -22,29 +23,33 @@ import type { UserAgentOperatingSystem, } from "../models"; -export function parseSingleLookup(body: string): IpGeolocationResponse { - const payload = parseJson(body, "Failed to deserialize API response"); +export function parseSingleLookup(body: string, apiKey?: string): IpGeolocationResponse { + const payload = parseJson(body, "Failed to deserialize API response", apiKey); if (!isPlainObjectRecord(payload)) { throw new SerializationError("Failed to deserialize API response"); } return parseIpGeolocationResponse(payload); } -export function parseBulkLookup(body: string): readonly BulkLookupResult[] { - const payload = parseJson(body, "Failed to deserialize bulk lookup response"); +export function parseBulkLookup(body: string, expectedCount?: number, apiKey?: string): readonly BulkLookupResult[] { + const payload = parseJson(body, "Failed to deserialize bulk lookup response", apiKey); if (!Array.isArray(payload)) { throw new SerializationError( "Failed to deserialize bulk response: expected an array payload", ); } + if (expectedCount !== undefined && payload.length !== expectedCount) { + throw new SerializationError(`Expected ${expectedCount} bulk results but received ${payload.length}`); + } return Object.freeze( payload.map((item) => { if (isBulkErrorItem(item)) { + const message = coerceOptionalString(item.message, "message"); return freezeDefined({ success: false, error: freezeDefined({ - message: coerceOptionalString(item.message, "message"), + message: message === undefined ? undefined : redactApiKey(message, apiKey), }), }); } @@ -184,6 +189,8 @@ function parseNetwork(node: Record): Network { connectionType: coerceOptionalString(node.connection_type, "network.connection_type"), route: coerceOptionalString(node.route, "network.route"), isAnycast: coerceOptionalBoolean(node.is_anycast, "network.is_anycast"), + isCdn: coerceOptionalBoolean(node.is_cdn, "network.is_cdn"), + cdnProviderName: coerceOptionalString(node.cdn_provider_name, "network.cdn_provider_name"), }); } @@ -201,6 +208,9 @@ function parseSecurity(node: Record): Security { "security.proxy_confidence_score", ), proxyLastSeen: coerceOptionalString(node.proxy_last_seen, "security.proxy_last_seen"), + proxyDaysSeen7d: coerceOptionalNumber(node.proxy_days_seen_7d, "security.proxy_days_seen_7d"), + proxyDaysSeen30d: coerceOptionalNumber(node.proxy_days_seen_30d, "security.proxy_days_seen_30d"), + proxyProviderCount30d: coerceOptionalNumber(node.proxy_provider_count_30d, "security.proxy_provider_count_30d"), isResidentialProxy: coerceOptionalBoolean( node.is_residential_proxy, "security.is_residential_proxy", @@ -223,6 +233,14 @@ function parseSecurity(node: Record): Security { "security.is_known_attacker", ), isBot: coerceOptionalBoolean(node.is_bot, "security.is_bot"), + botConfidenceScore: coerceOptionalNumber(node.bot_confidence_score, "security.bot_confidence_score"), + botOperatorName: coerceOptionalString(node.bot_operator_name, "security.bot_operator_name"), + botType: coerceOptionalString(node.bot_type, "security.bot_type"), + botLastSeen: coerceOptionalString(node.bot_last_seen, "security.bot_last_seen"), + isKnownGoodBot: coerceOptionalBoolean(node.is_known_good_bot, "security.is_known_good_bot"), + isCorporateGateway: coerceOptionalBoolean(node.is_corporate_gateway, "security.is_corporate_gateway"), + corporateGatewayType: coerceOptionalString(node.corporate_gateway_type, "security.corporate_gateway_type"), + corporateGatewayProviderName: coerceOptionalString(node.corporate_gateway_provider_name, "security.corporate_gateway_provider_name"), isSpam: coerceOptionalBoolean(node.is_spam, "security.is_spam"), isCloudProvider: coerceOptionalBoolean( node.is_cloud_provider, @@ -339,11 +357,11 @@ function freezeDefined(value: Record): T { return Object.freeze(result) as T; } -function parseJson(body: string, message: string): unknown { +function parseJson(body: string, message: string, apiKey?: string): unknown { try { return JSON.parse(body); } catch (error) { - throw new SerializationError(message, { cause: error }); + throw new SerializationError(message, apiKey ? undefined : { cause: error }); } } diff --git a/src/internal/shared.ts b/src/internal/shared.ts index 8255c9f..9c4a529 100644 --- a/src/internal/shared.ts +++ b/src/internal/shared.ts @@ -8,12 +8,16 @@ import { NotFoundError, PayloadTooLargeError, RateLimitError, + RequestTimeoutError, + SerializationError, ServerError, + TransportError, UnauthorizedError, UnsupportedMediaTypeError, ValidationError, } from "../errors"; import { ApiResponseMetadata, type HeaderMultiMap } from "../models"; +import { hasSafeErrorDetails, redactApiKey } from "./redaction"; export type QueryParamValue = string | null | undefined; export type QueryParams = @@ -48,7 +52,7 @@ export function buildQuery(params: QueryParams): string { export function mergeHeaders( ...headerMaps: ReadonlyArray ): HeaderMultiMap { - const merged: Record = {}; + const merged: Record = Object.create(null); const namesByLower = new Map(); for (const headerMap of headerMaps) { @@ -72,7 +76,7 @@ export function mergeHeaders( } } - return Object.freeze(merged); + return Object.freeze({ ...merged }); } export function resolveUserAgentHeader( @@ -85,13 +89,17 @@ export function resolveUserAgentHeader( return Object.freeze([resolvedUserAgent]); } -export function toApiError(statusCode: number, body: string): ApiError { - const apiMessage = extractApiMessage(body); +export function toApiError(statusCode: number, body: string, apiKey?: string): ApiError { + const apiMessage = extractApiMessage(body, apiKey); const message = apiMessage == null ? `API request failed with HTTP status ${statusCode}` : `API request failed with HTTP status ${statusCode}: ${apiMessage}`; + return createApiError(statusCode, message, apiMessage); +} + +function createApiError(statusCode: number, message: string, apiMessage?: string): ApiError { switch (statusCode) { case 400: return new BadRequestError(message, statusCode, apiMessage); @@ -119,7 +127,7 @@ export function toApiError(statusCode: number, body: string): ApiError { } } -export function extractApiMessage(body: string | null | undefined): string | undefined { +export function extractApiMessage(body: string | null | undefined, apiKey?: string): string | undefined { if (body == null) { return undefined; } @@ -128,11 +136,45 @@ export function extractApiMessage(body: string | null | undefined): string | und return undefined; } + let payload: unknown; + try { + payload = JSON.parse(normalizedBody); + } catch { + return truncateMessageBody(normalizedBody, apiKey); + } + const message = extractJsonMessage(payload); + if (message !== undefined) return redactApiKey(message, apiKey); + if (apiKey && normalizedBody.includes("\\")) { + try { + const decoded = JSON.stringify(payload); + const redacted = redactApiKey(decoded, apiKey); + if (redacted !== decoded) return truncateMessageBody(redacted, apiKey); + } catch { + return undefined; + } + } + return truncateMessageBody(normalizedBody, apiKey); +} + +export function safeTransportError(error: unknown, apiKey?: string): unknown { + if (!apiKey) return error; try { - const payload = JSON.parse(normalizedBody); - return extractJsonMessage(payload) ?? truncateMessageBody(normalizedBody); + if (hasSafeErrorDetails(error, apiKey)) return error; + if (typeof error === "string") return redactApiKey(error, apiKey); + if (!(error instanceof Error)) return new TransportError("HTTP transport error"); + const text = Object.getOwnPropertyDescriptor(error, "message")?.value; + const message = typeof text === "string" ? redactApiKey(text, apiKey) : "HTTP transport error"; + if (error instanceof ApiError) { + const status = Object.getOwnPropertyDescriptor(error, "statusCode")?.value; + const detail = Object.getOwnPropertyDescriptor(error, "apiMessage")?.value; + if (typeof status !== "number" || !Number.isInteger(status)) return new TransportError(message); + return createApiError(status, message, typeof detail === "string" ? redactApiKey(detail, apiKey) : undefined); + } + const ErrorType = [RequestTimeoutError, TransportError, SerializationError, ValidationError, + TypeError, RangeError, SyntaxError].find(type => error instanceof type) ?? Error; + return new ErrorType(message); } catch { - return truncateMessageBody(normalizedBody); + return new TransportError("HTTP transport error"); } } @@ -266,8 +308,8 @@ function toMessageText(value: unknown): string | undefined { return undefined; } -function truncateMessageBody(body: string): string | undefined { - const normalizedBody = body.replace(/\s+/g, " ").trim(); +function truncateMessageBody(body: string, apiKey?: string): string | undefined { + const normalizedBody = redactApiKey(redactApiKey(body, apiKey).replace(/\s+/g, " ").trim(), apiKey); if (normalizedBody === "") { return undefined; } diff --git a/src/internal/transport.ts b/src/internal/transport.ts index e1e9dbd..2d863ad 100644 --- a/src/internal/transport.ts +++ b/src/internal/transport.ts @@ -1,5 +1,6 @@ import { RequestTimeoutError, TransportError } from "../errors"; import type { HeaderMultiMap } from "../models"; +import { safeTransportError } from "./shared"; export interface HttpRequestData { url: string; @@ -60,34 +61,37 @@ export class FetchHttpTransport implements HttpTransport { method: request.method, headers: toFetchHeaders(request.headers), signal: controller.signal, + redirect: "manual", }; if (request.body != null) { init.body = request.body; } - const response = await this.fetchImpl(request.url, init); + const response = await fetchWithRedirects(this.fetchImpl, request.url, init, controller.signal); clearTimeout(timeoutHandle); phase = "read"; timeoutHandle = startTimeout(controller, request.readTimeoutMs); - const body = await readResponseBody( - response, - this.maxResponseBodyChars, - controller.signal, - ); + let body = ""; + try { + body = await readResponseBody(response, this.maxResponseBodyChars, controller.signal); + } catch (error) { + if (response.status < 300) throw error; + } return { statusCode: response.status, body, headers: extractHeaders(response.headers), }; } catch (error: unknown) { + const key = requestApiKey(request.url); if (isAbortError(error)) { - throw new RequestTimeoutError(toTimeoutMessage(request, phase), { cause: error }); + throw safeTransportError(new RequestTimeoutError(toTimeoutMessage(request, phase), { cause: error }), key); } if (error instanceof TransportError) { - throw error; + throw safeTransportError(error, key); } - throw new TransportError("HTTP transport error", { cause: error }); + throw safeTransportError(new TransportError("HTTP transport error", { cause: error }), key); } finally { clearTimeout(timeoutHandle); } @@ -98,15 +102,56 @@ export class FetchHttpTransport implements HttpTransport { } } -function toFetchHeaders(headers: HeaderMultiMap): Record { - const result: Record = {}; +function requestApiKey(url: string): string | undefined { + try { + return new URL(url, "https://localhost").searchParams.get("apiKey") ?? undefined; + } catch { + return undefined; + } +} + +const redirectStatuses = new Set([301, 302, 303, 307, 308]); + +async function fetchWithRedirects(fetchImpl: FetchLike, url: string, init: RequestInit, signal: AbortSignal): Promise { + for (let redirects = 0; ; redirects++) { + const response = await withAbort(fetchImpl(url, init), signal); + if (response.type === "opaqueredirect") { + throw new TransportError("The browser blocked an API redirect. Use the final API address."); + } + if (!redirectStatuses.has(response.status)) return response; + const location = response.headers.get("Location"); + if (location === null) return response; + void response.body?.cancel().catch(() => {}); + if (redirects === 20) throw new TransportError("Too many API redirects"); + const current = new URL(url, globalThis.location?.href); + const next = new URL(location, current); + if (next.protocol !== current.protocol || next.origin !== current.origin || next.username || next.password) { + throw new TransportError("The API redirected the request to another address"); + } + const method = init.method?.toUpperCase(); + if ((response.status === 303 && method !== "HEAD" && method !== "GET") || + ((response.status === 301 || response.status === 302) && method === "POST")) { + init.method = "GET"; + delete init.body; + const headers = new Headers(init.headers); + for (const name of ["content-encoding", "content-language", "content-location", "content-type", "content-length"]) { + headers.delete(name); + } + init.headers = headers; + } + url = next.href; + } +} + +function toFetchHeaders(headers: HeaderMultiMap): HeadersInit { + const result: Record = Object.create(null); for (const [key, values] of Object.entries(headers)) { if (values.length === 0) { continue; } result[key] = values.length === 1 ? values[0] ?? "" : values.join(", "); } - return result; + return "__proto__" in result ? Object.entries(result) : { ...result }; } async function readResponseBody( @@ -164,19 +209,19 @@ async function readResponseBody( return chunks.join(""); } finally { - if (shouldCancelReader) { - await cancelReaderQuietly(reader); + if (shouldCancelReader || signal.aborted) { + void cancelReaderQuietly(reader); } reader.releaseLock(); } } function extractHeaders(headers: Headers): HeaderMultiMap { - const result: Record = {}; + const result: Record = Object.create(null); headers.forEach((value, key) => { result[key] = Object.freeze([value]); }); - return Object.freeze(result); + return Object.freeze({ ...result }); } function isAbortError(error: unknown): boolean { @@ -216,10 +261,6 @@ function toTimeoutMessage(request: HttpRequestData, phase: TimeoutPhase): string } function withAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new DOMException("aborted", "AbortError")); - } - return new Promise((resolve, reject) => { const onAbort = (): void => { cleanup(); @@ -231,6 +272,7 @@ function withAbort(promise: Promise, signal: AbortSignal): Promise { }; signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); promise.then( (value) => { cleanup(); diff --git a/src/json-output.ts b/src/json-output.ts index 3fb3285..527c1d5 100644 --- a/src/json-output.ts +++ b/src/json-output.ts @@ -4,6 +4,7 @@ import { type JsonOutputMode as JsonOutputModeType, } from "./enums"; import { SerializationError } from "./errors"; +import { configJson } from "./internal/redaction"; const OMIT = Symbol("omit"); @@ -124,7 +125,9 @@ function toPlainData( }); } - return serializeEntries(Object.entries(value), mode, seen); + const configView = (value as { [configJson]?: () => object })[configJson]; + const object = typeof configView === "function" ? configView() : value; + return serializeEntries(Object.entries(object), mode, seen); } finally { seen.delete(value); } @@ -135,7 +138,7 @@ function serializeEntries( mode: JsonOutputModeType, seen: WeakSet, ): Record { - const result: Record = {}; + const result: Record = Object.create(null); for (const [rawKey, rawValue] of entries) { const key = String(rawKey); const normalizedValue = toPlainData(rawValue, mode, seen, "object"); diff --git a/src/models.ts b/src/models.ts index 279a1d3..8f805a5 100644 --- a/src/models.ts +++ b/src/models.ts @@ -81,7 +81,7 @@ function normalizeHeaderMultiMap(input: HeaderMultiMap | null | undefined): Head return Object.freeze({}); } - const normalized: Record = {}; + const normalized: Record = Object.create(null); for (const [key, values] of Object.entries(input)) { if (typeof key !== "string" || key.trim() === "") { continue; @@ -102,7 +102,7 @@ function normalizeHeaderMultiMap(input: HeaderMultiMap | null | undefined): Head normalized[key] = Object.freeze(normalizedValues); } - return Object.freeze(normalized); + return Object.freeze({ ...normalized }); } export interface Abuse { @@ -182,6 +182,8 @@ export interface Network { connectionType?: string; route?: string; isAnycast?: boolean; + isCdn?: boolean; + cdnProviderName?: string; } export interface Security { @@ -191,6 +193,9 @@ export interface Security { proxyProviderNames?: readonly string[]; proxyConfidenceScore?: number; proxyLastSeen?: string; + proxyDaysSeen7d?: number; + proxyDaysSeen30d?: number; + proxyProviderCount30d?: number; isResidentialProxy?: boolean; isVpn?: boolean; vpnProviderNames?: readonly string[]; @@ -201,6 +206,14 @@ export interface Security { isAnonymous?: boolean; isKnownAttacker?: boolean; isBot?: boolean; + botConfidenceScore?: number; + botOperatorName?: string; + botType?: string; + botLastSeen?: string; + isKnownGoodBot?: boolean; + isCorporateGateway?: boolean; + corporateGatewayType?: string; + corporateGatewayProviderName?: string; isSpam?: boolean; isCloudProvider?: boolean; cloudProviderName?: string; diff --git a/src/request-models.ts b/src/request-models.ts index 68ea5fd..b75d861 100644 --- a/src/request-models.ts +++ b/src/request-models.ts @@ -139,14 +139,11 @@ function normalizeLanguage(value: Language | string | null | undefined): Languag } const normalizedValue = value.trim().toLowerCase(); - if (normalizedValue === "") { - throw new ValidationError(`Unsupported language code: ${value}`); - } if (LANGUAGE_VALUES.includes(normalizedValue as Language)) { return normalizedValue as Language; } - throw new ValidationError(`Unsupported language code: ${value}`); + throw new ValidationError("Unsupported language code"); } function normalizeOutput( @@ -200,7 +197,7 @@ function normalizeHeaders(value: RequestHeaders | null | undefined): NormalizedH return Object.freeze({}); } - const normalized: Record = {}; + const normalized: Record = Object.create(null); const namesByLower = new Map(); for (const [rawName, rawValue] of toHeaderEntries(value)) { @@ -301,11 +298,11 @@ function toHeaderEntry(entry: unknown): readonly [string, HeaderValue] { function freezeNormalizedHeaders( headers: Record, ): NormalizedHeaders { - const normalized: Record = {}; + const normalized: Record = Object.create(null); for (const [name, values] of Object.entries(headers)) { normalized[name] = Object.freeze([...values]); } - return Object.freeze(normalized); + return Object.freeze({ ...normalized }); } function isHeadersInstance(value: unknown): value is Headers { diff --git a/src/version.ts b/src/version.ts index 64ca547..42a402c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,4 +1,4 @@ -export const SDK_VERSION = "3.0.0"; +export const SDK_VERSION = "3.0.1"; export function getVersion(): string { return SDK_VERSION; diff --git a/tests/live/live-field-parity.test.ts b/tests/live/live-field-parity.test.ts index 37738f0..059e842 100644 --- a/tests/live/live-field-parity.test.ts +++ b/tests/live/live-field-parity.test.ts @@ -248,7 +248,7 @@ function toApiShape(value: unknown): unknown { } function camelToSnake(value: string): string { - return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); + return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/(\d+d)$/, "_$1"); } function isPlainObject(value: unknown): value is Record { diff --git a/tests/package-consumer.ts b/tests/package-consumer.ts new file mode 100644 index 0000000..2a68c36 --- /dev/null +++ b/tests/package-consumer.ts @@ -0,0 +1,26 @@ +import { IpGeolocationClient, Language, SDK_VERSION } from 'ip-geolocation-api-sdk-typescript'; +import type { IpGeolocationResponse, Network, Security } from 'ip-geolocation-api-sdk-typescript'; +import type { + IpGeolocationResponse as StandaloneResponse, + Language as LanguageCode, + Network as StandaloneNetwork, + Security as StandaloneSecurity, +} from 'ip-geolocation-api-typescript-types'; + +import type { IpGeolocationClient as ESMClient } from 'ip-geolocation-api-sdk-typescript' with { 'resolution-mode': 'import' }; +import type { IpGeolocationClient as CJSClient } from 'ip-geolocation-api-sdk-typescript' with { 'resolution-mode': 'require' }; + +const lang: LanguageCode = Language.AR; +const version: `${number}.${number}.${number}` = SDK_VERSION; +type SameNames = Exclude | Exclude; +type Match = T; +export type ClientImportsMatch = Match | Exclude>; +export type NetworkNamesMatch = Match>; +export type SecurityNamesMatch = Match>; + +const example = { + network: { isCdn: false, cdnProviderName: '' }, + security: { proxyDaysSeen7d: 0, botType: 'crawler', isCorporateGateway: false }, +} satisfies IpGeolocationResponse & StandaloneResponse; +const makeClient = () => new IpGeolocationClient({}); +void [lang, version, example, makeClient]; diff --git a/tests/unit/redirects.test.ts b/tests/unit/redirects.test.ts new file mode 100644 index 0000000..677db40 --- /dev/null +++ b/tests/unit/redirects.test.ts @@ -0,0 +1,145 @@ +import { createServer, type Server } from "node:http"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, expect, it } from "vitest"; +import { + FetchHttpTransport, + IpGeolocationClient, + TransportError, +} from "../../src"; + +let origin: string; +let destination: string; +let foreignHits = 0; +const requests: { + method: string; + body: string; + token?: string; + contentType?: string; +}[] = []; +const receiver = createServer((_req, res) => { + foreignHits++; + res.end("[]"); +}); +const api = createServer(async (req, res) => { + const path = new URL(req.url!, origin).pathname; + const route = path.split("/")[1]; + if (route === "final") { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + requests.push({ + method: req.method!, + body: Buffer.concat(chunks).toString(), + token: req.headers["x-lookup-token"] as string, + contentType: req.headers["content-type"], + }); + res.end('[{"ip":"192.0.2.1"}]'); + return; + } + const parts = route!.split("-"); + const location = + parts[0] === "cross" + ? destination + : parts[0] === "loop" + ? req.url! + : "/final"; + res.writeHead(Number(parts[1]), { Location: location }); + res.end(); +}); + +async function start(server: Server): Promise { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +beforeAll(async () => { + destination = await start(receiver); + origin = await start(api); +}); +afterAll(async () => { + await Promise.all( + [api, receiver].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); +}); + +const lookup = { + ips: ["192.0.2.1"], + headers: { "X-Lookup-Token": "test-marker" }, +}; +function client(route: string) { + return new IpGeolocationClient({ + baseUrl: `${origin}/${route}`, + apiKey: "test-only", + }); +} + +it.each([301, 302, 303, 307, 308])( + "blocks a cross-origin %i before the destination receives anything", + async (status) => { + await expect( + client(`cross-${status}`).bulkLookupIpGeolocation(lookup), + ).rejects.toBeInstanceOf(TransportError); + expect(foreignHits).toBe(0); + }, +); + +it.each([307, 308])( + "preserves the POST and headers on a same-origin %i", + async (status) => { + const result = await client(`same-${status}`).bulkLookupIpGeolocation( + lookup, + ); + expect(result.data).toEqual([ + { success: true, data: { ip: lookup.ips[0] } }, + ]); + expect(requests.at(-1)).toMatchObject({ + method: "POST", + body: JSON.stringify({ ips: lookup.ips }), + token: "test-marker", + }); + }, +); + +it.each([301, 302, 303])( + "follows a same-origin %i as a GET without the POST body", + async (status) => { + await client(`same-${status}`).bulkLookupIpGeolocation(lookup); + expect(requests.at(-1)).toMatchObject({ + method: "GET", + body: "", + contentType: undefined, + token: "test-marker", + }); + }, +); + +it("stops a redirect loop", async () => { + await expect( + client("loop-307").bulkLookupIpGeolocation(lookup), + ).rejects.toThrow("Too many API redirects"); +}); + +it.each([ + ["post", 302, "GET"], + ["head", 303, "HEAD"], +] as const)( + "preserves Fetch redirect semantics for %s and %i", + async (method, status, expected) => { + await new FetchHttpTransport().send({ + method, + url: `${origin}/same-${status}/v3/ipgeo`, + body: method === "post" ? "payload" : undefined, + headers: {}, + connectTimeoutMs: 1000, + readTimeoutMs: 1000, + }); + expect(requests.at(-1)).toMatchObject({ method: expected, body: "" }); + }, +); diff --git a/tests/unit/request-safety.test.ts b/tests/unit/request-safety.test.ts new file mode 100644 index 0000000..03a8746 --- /dev/null +++ b/tests/unit/request-safety.test.ts @@ -0,0 +1,353 @@ +import { randomUUID } from "node:crypto"; +import { inspect } from "node:util"; +import { expect, it } from "vitest"; +import { + FetchHttpTransport, + IpGeolocationClient, + IpGeolocationClientConfig, + Language, + RateLimitError, + SerializationError, + TransportError, + UnauthorizedError, + toJson, + toPrettyJson, +} from "../../src"; + +const ip = "192.0.2.1"; +const key = `fixture-${randomUUID()} /+`; +const config = new IpGeolocationClientConfig({ apiKey: key }); +function clientFor( + fetch: typeof globalThis.fetch, + maxResponseBodyChars?: number, +) { + return new IpGeolocationClient( + config, + new FetchHttpTransport({ fetch, maxResponseBodyChars }), + ); +} + +it("keeps the configured key usable and hides it when displaying a client that has made a request", async () => { + const transport = { + requests: [] as string[], + async send(request: { url: string }) { + this.requests.push(request.url); + return { body: "{}", statusCode: 200, headers: {} }; + }, + async close() {}, + }; + const client = new IpGeolocationClient(config, transport); + await client.lookupIpGeolocation({ ip }); + expect(new URL(transport.requests[0]!).searchParams.get("apiKey")).toBe(key); + expect({ ...config }.apiKey).toBe(key); + for (const value of [config, client]) { + for (const output of [ + JSON.stringify(value), + toJson(value), + toPrettyJson(value, "full"), + inspect(value, { showHidden: true, getters: true }), + ]) { + expect(output).toContain("[REDACTED]"); + expect(output).not.toContain(key); + expect(output).not.toContain(encodeURIComponent(key)); + expect(output).not.toContain("/v3/ipgeo?"); + } + } + expect( + JSON.parse(toJson({ config }, "full")).config.requestOrigin, + ).toBeNull(); +}); + +it("masks keys before truncating text and after decoding JSON", async () => { + const formKey = new URLSearchParams({ k: key }).toString().slice(2); + const errors = [ + "x".repeat(509) + key, + JSON.stringify({ + message: `bad ${encodeURIComponent(key).replace(/%2F/g, "%2f")} and ${formKey}`, + }), + JSON.stringify({ error: { message: key } }).replace( + key, + `\\u0066${key.slice(1)}`, + ), + ]; + for (const body of errors) { + const client = clientFor(async () => new Response(body, { status: 401 })); + const error = await client.lookupIpGeolocationRaw({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.apiMessage).not.toContain(key.slice(0, 3)); + expect(error.message).toContain("[RE"); + expect(error.statusCode).toBe(401); + } +}); + +it("keeps request URLs and response snippets out of error causes", async () => { + const transport = { + async send(request: { url: string }) { + throw Object.assign(new TypeError(`failed ${request.url}`), { + cause: { key }, + request, + }); + }, + async close() {}, + }; + const client = new IpGeolocationClient(config, transport); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toContain("[REDACTED]"); + expect(inspect(error, { showHidden: true })).not.toContain(key.slice(0, 8)); + const invalid = await clientFor(async () => new Response("bad: " + key)) + .lookupIpGeolocation({ ip }) + .catch((e) => e); + expect(invalid).toBeInstanceOf(SerializationError); + expect(invalid.cause).toBeUndefined(); +}); + +it("preserves an HTTP error if its body is oversized, unreadable or late", async () => { + const responses = [ + new Response("too large", { status: 429 }), + new Response( + new ReadableStream({ + start(c) { + c.error(new Error("broken")); + }, + }), + { status: 429 }, + ), + new Response(new ReadableStream({}), { status: 429 }), + ]; + for (const response of responses) { + const client = new IpGeolocationClient( + { apiKey: key, readTimeoutMs: 25 }, + new FetchHttpTransport({ + fetch: async () => response, + maxResponseBodyChars: 4, + }), + ); + const error = await client + .bulkLookupIpGeolocation({ ips: [ip] }) + .catch((e) => e); + expect(error).toBeInstanceOf(RateLimitError); + expect(error.statusCode).toBe(429); + } + await expect( + clientFor(async () => new Response("too large"), 4).lookupIpGeolocationRaw({ + ip, + }), + ).rejects.toBeInstanceOf(TransportError); +}); + +it("sends Arabic on both endpoints and leaves successful data and raw response text intact", async () => { + const raw = JSON.stringify([{ message: key }, { ip: key }], null, 2); + const requests: URL[] = []; + const client = clientFor(async (url) => { + const request = new URL(String(url)); + requests.push(request); + return new Response( + request.pathname.endsWith("-bulk") + ? raw + : JSON.stringify({ domain: key }), + ); + }); + const single = await client.lookupIpGeolocation({ ip, lang: Language.AR }); + const request = { ips: [ip, ip], lang: "ar" }; + const bulk = await client.bulkLookupIpGeolocation(request); + expect(single.data.domain).toBe(key); + expect(bulk.data).toEqual([ + { success: false, error: { message: "[REDACTED]" } }, + { success: true, data: { ip: key } }, + ]); + expect((await client.bulkLookupIpGeolocationRaw(request)).data).toBe(raw); + expect(requests.map((url) => url.searchParams.get("lang"))).toEqual([ + "ar", + "ar", + "ar", + ]); +}); + +it.each([0, 2])( + "rejects %i rows for one IP but preserves raw JSON", + async (count) => { + const body = JSON.stringify(Array.from({ length: count }, () => ({ ip }))); + const client = clientFor(async () => new Response(body)); + await expect( + client.bulkLookupIpGeolocation({ ips: [ip] }), + ).rejects.toBeInstanceOf(SerializationError); + expect((await client.bulkLookupIpGeolocationRaw({ ips: [ip] })).data).toBe( + body, + ); + }, +); + +it("preserves special header names and JSON keys without changing object prototypes", async () => { + const headers = new Map([ + ["__proto__", "one"], + ["constructor", "two"], + ]); + const client = clientFor(async (_url, init) => { + expect(new Headers(init!.headers).get("__proto__")).toBe("one"); + return new Response("{}", { headers: [...headers] }); + }); + const { metadata } = await client.lookupIpGeolocation({ ip, headers }); + expect(metadata.firstHeaderValue("__proto__")).toBe("one"); + const value = JSON.parse('{"__proto__":{"keep":true},"constructor":"two"}'); + expect(JSON.parse(toJson(value))).toEqual(value); + expect(Object.getPrototypeOf(metadata.rawHeaders)).toBe(Object.prototype); +}); + +it("returns all 50,000 typed bulk rows in order, including empty and false field values", async () => { + const ips = Array.from( + { length: 50_000 }, + (_, n) => `198.18.${Math.floor(n / 256)}.${n % 256}`, + ); + const client = clientFor(async (_url, init) => { + expect(JSON.parse(String(init!.body)).ips).toEqual(ips); + return Response.json( + ips.map((ip, i) => + i === 1 + ? { message: "invalid" } + : { + ip, + network: { is_cdn: false, cdn_provider_name: "" }, + security: { proxy_days_seen_7d: 0, is_corporate_gateway: false }, + }, + ), + ); + }); + const result = await client.bulkLookupIpGeolocation({ + ips, + lang: Language.AR, + }); + expect( + result.data.map((row) => (row.success ? row.data.ip : "invalid")), + ).toEqual(ips.map((value, i) => (i === 1 ? "invalid" : value))); + expect(result.data[0]).toEqual({ + success: true, + data: { + ip: ips[0], + network: { isCdn: false, cdnProviderName: "" }, + security: { proxyDaysSeen7d: 0, isCorporateGateway: false }, + }, + }); +}); + +it("preserves a custom transport's retry error and safe details", async () => { + class RetryError extends Error { + readonly code = "ECONNRESET"; + readonly retryable = true; + readonly cause = { detail: new Error("connection reset") }; + } + const failure = new RetryError("connection refused"); + const client = new IpGeolocationClient(config, { + async send() { + throw failure; + }, + async close() {}, + }); + await expect(client.lookupIpGeolocation({ ip })).rejects.toBe(failure); + expect(failure.cause.detail).toBeInstanceOf(Error); +}); + +it("hides the key in normalized text, unrecognized JSON and the URL actually sent by Fetch", async () => { + const samples = [ + { apiKey: "fixture proof", body: "errors: fixture \n proof" }, + { + apiKey: "fixtureKeyZ", + body: '{"nested":{"context":"fixture\\u004beyZ"}}', + }, + { apiKey: "fixture'!proof", body: undefined }, + ]; + for (const { apiKey, body } of samples) { + const client = new IpGeolocationClient( + { apiKey }, + new FetchHttpTransport({ + fetch: async (input, init) => + new Response( + body ?? JSON.stringify({ message: new Request(input, init).url }), + { status: 401 }, + ), + }), + ); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.apiMessage).toContain("[REDACTED]"); + expect(decodeURIComponent(error.apiMessage)).not.toContain(apiKey); + if (body?.startsWith("{")) + expect(JSON.parse(error.apiMessage).nested.context).toBe("[REDACTED]"); + } +}); + +it("retains common network error codes on authenticated requests", async () => { + const cause = Object.assign(new Error("connection refused"), { + code: "ECONNREFUSED", + }); + const client = clientFor(async () => { + throw cause; + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(error).toBeInstanceOf(TransportError); + expect(error.cause).toBe(cause); +}); + +it("does not retain private request URLs exposed by custom error formatting", async () => { + class RequestError extends Error { + #url: string; + constructor(url: string) { + super("request failed"); + this.#url = url; + } + toString() { + return `${this.message}: ${this.#url}`; + } + } + const client = new IpGeolocationClient(config, { + async send(request) { + throw new RequestError(request.url); + }, + async close() {}, + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(String(error)).toContain("request failed"); + expect(String(error)).not.toContain("apiKey="); +}); + +it.each(["apiMessage", "statusCode"])("does not read a custom error's %s getter", async (field) => { + let reads = 0; + const client = new IpGeolocationClient(config, { + async send(request) { + throw Object.defineProperty(new UnauthorizedError("upstream failed", 401), field, { + get() { + reads++; + throw new Error(request.url); + }, + }); + }, + async close() {}, + }); + const error = await client.lookupIpGeolocation({ ip }).catch((e) => e); + expect(reads).toBe(0); + expect(error.message).toBe("upstream failed"); + if (field === "apiMessage") { + expect(error).toBeInstanceOf(UnauthorizedError); + expect(error.statusCode).toBe(401); + } else { + expect(error).toBeInstanceOf(TransportError); + } +}); + +it("supports a client wrapped in a proxy without showing its transport history", async () => { + const transport = { + history: [] as string[], + async send(request: { url: string }) { + this.history.push(request.url); + return { body: JSON.stringify({ ip }), statusCode: 200, headers: {} }; + }, + async close() {}, + }; + const client = new Proxy(new IpGeolocationClient(config, transport), {}); + expect((await client.lookupIpGeolocation({ ip })).data.ip).toBe(ip); + expect(transport.history).toHaveLength(1); + expect(toJson(client)).not.toContain("/v3/ipgeo"); + expect(inspect(client, { showHidden: true, getters: true })).not.toContain("/v3/ipgeo"); + await client.close(); + await expect(client.lookupIpGeolocation({ ip })).rejects.toThrow(/closed/i); +}); From bf7740703d58f92dce4bbf33c77cbe7055eafdae Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Thu, 5 Nov 2026 23:24:41 +0500 Subject: [PATCH 2/3] Check named ES module imports in package tests --- scripts/check-packages.cjs | 3 ++- tests/package-esm.mts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 tests/package-esm.mts diff --git a/scripts/check-packages.cjs b/scripts/check-packages.cjs index 57df695..2dffd34 100644 --- a/scripts/check-packages.cjs +++ b/scripts/check-packages.cjs @@ -58,9 +58,10 @@ async function checkConsumerTypes(sdk, types, typesRoot) { for (const file of ['consumer.cts', 'consumer.mts']) { await copyFile(fixture, path.join(consumer, file)); } + await copyFile(path.join(root, 'tests/package-esm.mts'), path.join(consumer, 'package-esm.mts')); for (const mode of ['Node16', 'NodeNext']) { execFileSync(process.execPath, [require.resolve('typescript/bin/tsc'), '--strict', '--noEmit', - '--target', 'ES2020', '--module', mode, '--moduleResolution', mode, 'consumer.cts', 'consumer.mts'], + '--target', 'ES2020', '--module', mode, '--moduleResolution', mode, 'consumer.cts', 'consumer.mts', 'package-esm.mts'], { cwd: consumer, stdio: 'inherit' }); } } finally { diff --git a/tests/package-esm.mts b/tests/package-esm.mts new file mode 100644 index 0000000..dbbea6b --- /dev/null +++ b/tests/package-esm.mts @@ -0,0 +1,2 @@ +// @ts-expect-error +import MissingDefaultExport from 'ip-geolocation-api-sdk-typescript'; From 0ab3c7a0ab6f327fda1be97c77f75ac021a23153 Mon Sep 17 00:00:00 2001 From: MudassarTariq Date: Thu, 5 Nov 2026 23:32:05 +0500 Subject: [PATCH 3/3] Add 3.0.1 release notes for the SDK and types package --- CHANGELOG.md | 10 ++++++++++ packages/types/CHANGELOG.md | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9267862..6ac01fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 3.0.1 + +- Hide API keys when printing clients and settings, and in request errors and typed bulk error messages. +- Stop redirects from sending lookup data or custom headers to another server. +- Keep the HTTP status if an error response cannot be read. Reject missing or extra rows in typed bulk results. +- Add Arabic (`ar`) for single and bulk lookups. Include CDN, proxy, bot and corporate gateway details in typed results. +- Fix TypeScript imports in CommonJS projects. Preserve names such as `__proto__` in headers and JSON output. + +If your code handles every language separately, add a case for `ar`. + ## 3.0.0 - Initial TypeScript SDK release for `/v3/ipgeo` and `/v3/ipgeo-bulk` diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index c50fe22..6231584 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.0.1 + +- Add Arabic (`ar`) and the CDN, proxy, bot and corporate gateway response fields. +- Fix TypeScript imports in CommonJS projects. +- Correct the lookup and bulk examples and fix the GitHub link. + ## 3.0.0 - Initial v3 release of the public TypeScript types package.