@fonderie/geo 0.1.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/LICENSE +21 -0
- package/README.md +52 -0
- package/brain/outcomes.md +36 -0
- package/brain/signatures.md +66 -0
- package/dist/index.cjs +198 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +84 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +165 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_geo.sql +40 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fonderie, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @fonderie/geo
|
|
2
|
+
|
|
3
|
+
Self-hosted **IP → location**. Resolve an IPv4/IPv6 address to a country /
|
|
4
|
+
region / city / lat-lng against a Postgres table loaded from MaxMind GeoLite2
|
|
5
|
+
(or Hurricane Electric) CSVs — **no external API, no key shipped**.
|
|
6
|
+
|
|
7
|
+
Status: **experimental** (0.x).
|
|
8
|
+
|
|
9
|
+
> Provider-abstracted via `IGeoProvider` — the default `PostgresGeoProvider` is
|
|
10
|
+
> self-hosted; a consumer who prefers a hosted source (MaxMind API, ipinfo, …)
|
|
11
|
+
> plugs one in behind the same interface, the way `billing` swaps payment
|
|
12
|
+
> providers. You're never locked into the self-hosted map.
|
|
13
|
+
|
|
14
|
+
## Why it's a brick
|
|
15
|
+
|
|
16
|
+
It's a **signal source**, not a product: `@fonderie/risk` consumes it (geo /
|
|
17
|
+
impossible-travel), and any Fonderie app gets a day-one "where did this request
|
|
18
|
+
come from" — access-log geo, suspicious-login hints — for free. In-process,
|
|
19
|
+
against your own database.
|
|
20
|
+
|
|
21
|
+
## Use it
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { PostgresGeoProvider, loadMaxMindCity } from '@fonderie/geo';
|
|
25
|
+
import { getMigrationsPath } from '@fonderie/geo/migrations';
|
|
26
|
+
// 1. run getMigrationsPath()'s SQL with your store's migration runner
|
|
27
|
+
// 2. one-time load (full snapshot; re-run to refresh):
|
|
28
|
+
await loadMaxMindCity(store, {
|
|
29
|
+
locationsPath: 'GeoLite2-City-Locations-en.csv',
|
|
30
|
+
blocksV4Path: 'GeoLite2-City-Blocks-IPv4.csv',
|
|
31
|
+
blocksV6Path: 'GeoLite2-City-Blocks-IPv6.csv',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const geo = new PostgresGeoProvider(store);
|
|
35
|
+
const loc = await geo.lookup(ip); // → { country, subdivision, city, latitude, longitude, … } | null
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How the lookup works
|
|
39
|
+
|
|
40
|
+
Blocks are stored as native `cidr`, so one table holds IPv4 **and** IPv6. A
|
|
41
|
+
lookup is a CIDR containment — `WHERE network >>= $ip::inet ORDER BY
|
|
42
|
+
masklen(network) DESC LIMIT 1` — the most-specific block that contains the
|
|
43
|
+
address wins. A GiST `inet_ops` index (core Postgres, no extension) makes it
|
|
44
|
+
fast. Invalid input resolves to `null`, never an error.
|
|
45
|
+
|
|
46
|
+
## Data
|
|
47
|
+
|
|
48
|
+
The CSVs are MaxMind's (GeoLite2 City, free with an account) or a compatible
|
|
49
|
+
source — you download and host them; nothing proprietary ships in this package.
|
|
50
|
+
`loadMaxMindCity` truncates and reloads (the dataset is a full snapshot).
|
|
51
|
+
|
|
52
|
+
Peer-depends on `@fonderie/core` + `@fonderie/store`; imports no other brick.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/geo — outcomes
|
|
4
|
+
|
|
5
|
+
What this package does to a running app: tables its migrations create,
|
|
6
|
+
rows it seeds, routes it registers. Generated from the migration SQL and
|
|
7
|
+
route tables in source — trust this file instead of reading `dist/` or
|
|
8
|
+
downloading tarballs.
|
|
9
|
+
|
|
10
|
+
## Database tables (after all migrations)
|
|
11
|
+
|
|
12
|
+
### `geo_blocks`
|
|
13
|
+
|
|
14
|
+
```sql
|
|
15
|
+
network CIDR NOT NULL
|
|
16
|
+
geoname_id BIGINT
|
|
17
|
+
latitude DOUBLE PRECISION
|
|
18
|
+
longitude DOUBLE PRECISION
|
|
19
|
+
accuracy_radius INTEGER
|
|
20
|
+
-- INDEX idx_geo_blocks_geoname (geoname_id)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### `geo_names`
|
|
24
|
+
|
|
25
|
+
```sql
|
|
26
|
+
geoname_id BIGINT PRIMARY KEY
|
|
27
|
+
continent_code TEXT
|
|
28
|
+
country_iso TEXT
|
|
29
|
+
country_name TEXT
|
|
30
|
+
subdivision_iso TEXT
|
|
31
|
+
subdivision_name TEXT
|
|
32
|
+
city_name TEXT
|
|
33
|
+
time_zone TEXT
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Raw SQL ships in `node_modules/@fonderie/geo/dist/migrations/sql/` — read it there if you must; never download tarballs.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!-- GENERATED — do not edit. Regenerate with: npm run docs:signatures -->
|
|
2
|
+
|
|
3
|
+
# @fonderie/geo — signatures
|
|
4
|
+
|
|
5
|
+
## @fonderie/geo
|
|
6
|
+
|
|
7
|
+
Subpath exports: `@fonderie/geo/migrations`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
new PostgresGeoProvider(store: Queryable): PostgresGeoProvider
|
|
11
|
+
.name: "postgres"
|
|
12
|
+
.lookup(ip: string): Promise<GeoLocation | null>
|
|
13
|
+
|
|
14
|
+
function loadMaxMindCity(store: Queryable, files: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string; }): Promise<{ names: number; blocks: number; }>
|
|
15
|
+
|
|
16
|
+
function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>
|
|
17
|
+
|
|
18
|
+
function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number>
|
|
19
|
+
|
|
20
|
+
function parseBlocksCsv(text: string): BlockRow[]
|
|
21
|
+
|
|
22
|
+
function parseLocationsCsv(text: string): NameRow[]
|
|
23
|
+
|
|
24
|
+
function parseCsvLine(line: string): string[]
|
|
25
|
+
|
|
26
|
+
interface BlockRow {
|
|
27
|
+
network: string;
|
|
28
|
+
geonameId: number | null;
|
|
29
|
+
latitude: number | null;
|
|
30
|
+
longitude: number | null;
|
|
31
|
+
accuracyRadius: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface NameRow {
|
|
35
|
+
geonameId: number;
|
|
36
|
+
continentCode: string | null;
|
|
37
|
+
countryIso: string | null;
|
|
38
|
+
countryName: string | null;
|
|
39
|
+
subdivisionIso: string | null;
|
|
40
|
+
subdivisionName: string | null;
|
|
41
|
+
cityName: string | null;
|
|
42
|
+
timeZone: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GeoLocation {
|
|
46
|
+
country: string | null;
|
|
47
|
+
countryName: string | null;
|
|
48
|
+
subdivision: string | null;
|
|
49
|
+
subdivisionName: string | null;
|
|
50
|
+
city: string | null;
|
|
51
|
+
continent: string | null;
|
|
52
|
+
timeZone: string | null;
|
|
53
|
+
latitude: number | null;
|
|
54
|
+
longitude: number | null;
|
|
55
|
+
accuracyRadius: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface IGeoProvider {
|
|
59
|
+
name: string;
|
|
60
|
+
lookup(ip: string): Promise<GeoLocation | null>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Queryable {
|
|
64
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
65
|
+
}
|
|
66
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
PostgresGeoProvider: () => PostgresGeoProvider,
|
|
24
|
+
ingestBlocks: () => ingestBlocks,
|
|
25
|
+
ingestNames: () => ingestNames,
|
|
26
|
+
loadMaxMindCity: () => loadMaxMindCity,
|
|
27
|
+
parseBlocksCsv: () => parseBlocksCsv,
|
|
28
|
+
parseCsvLine: () => parseCsvLine,
|
|
29
|
+
parseLocationsCsv: () => parseLocationsCsv
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/provider.ts
|
|
34
|
+
var LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;
|
|
35
|
+
var PostgresGeoProvider = class {
|
|
36
|
+
constructor(store) {
|
|
37
|
+
this.store = store;
|
|
38
|
+
}
|
|
39
|
+
store;
|
|
40
|
+
name = "postgres";
|
|
41
|
+
async lookup(ip) {
|
|
42
|
+
const addr = (ip ?? "").trim();
|
|
43
|
+
if (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;
|
|
44
|
+
try {
|
|
45
|
+
const rows = await this.store.query(
|
|
46
|
+
`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,
|
|
47
|
+
n.city_name, n.continent_code, n.time_zone,
|
|
48
|
+
b.latitude, b.longitude, b.accuracy_radius
|
|
49
|
+
FROM geo_blocks b
|
|
50
|
+
LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id
|
|
51
|
+
WHERE b.network >>= $1::inet
|
|
52
|
+
ORDER BY masklen(b.network) DESC
|
|
53
|
+
LIMIT 1`,
|
|
54
|
+
[addr]
|
|
55
|
+
);
|
|
56
|
+
const r = rows[0];
|
|
57
|
+
if (!r) return null;
|
|
58
|
+
return {
|
|
59
|
+
country: r.country_iso ?? null,
|
|
60
|
+
countryName: r.country_name ?? null,
|
|
61
|
+
subdivision: r.subdivision_iso ?? null,
|
|
62
|
+
subdivisionName: r.subdivision_name ?? null,
|
|
63
|
+
city: r.city_name ?? null,
|
|
64
|
+
continent: r.continent_code ?? null,
|
|
65
|
+
timeZone: r.time_zone ?? null,
|
|
66
|
+
latitude: r.latitude != null ? Number(r.latitude) : null,
|
|
67
|
+
longitude: r.longitude != null ? Number(r.longitude) : null,
|
|
68
|
+
accuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// src/ingest.ts
|
|
77
|
+
var import_node_fs = require("fs");
|
|
78
|
+
function parseCsvLine(line) {
|
|
79
|
+
const out = [];
|
|
80
|
+
let field = "";
|
|
81
|
+
let inQuotes = false;
|
|
82
|
+
for (let i = 0; i < line.length; i++) {
|
|
83
|
+
const c = line[i];
|
|
84
|
+
if (inQuotes) {
|
|
85
|
+
if (c === '"') {
|
|
86
|
+
if (line[i + 1] === '"') {
|
|
87
|
+
field += '"';
|
|
88
|
+
i++;
|
|
89
|
+
} else inQuotes = false;
|
|
90
|
+
} else field += c;
|
|
91
|
+
} else if (c === '"') inQuotes = true;
|
|
92
|
+
else if (c === ",") {
|
|
93
|
+
out.push(field);
|
|
94
|
+
field = "";
|
|
95
|
+
} else field += c;
|
|
96
|
+
}
|
|
97
|
+
out.push(field);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
var num = (s) => {
|
|
101
|
+
if (s == null || s === "") return null;
|
|
102
|
+
const n = Number(s);
|
|
103
|
+
return Number.isFinite(n) ? n : null;
|
|
104
|
+
};
|
|
105
|
+
var str = (s) => s == null || s === "" ? null : s;
|
|
106
|
+
function parseBlocksCsv(text) {
|
|
107
|
+
const rows = [];
|
|
108
|
+
const lines = text.split(/\r?\n/);
|
|
109
|
+
for (let i = 1; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i];
|
|
111
|
+
if (!line) continue;
|
|
112
|
+
const c = parseCsvLine(line);
|
|
113
|
+
if (!c[0]) continue;
|
|
114
|
+
rows.push({
|
|
115
|
+
network: c[0],
|
|
116
|
+
geonameId: num(c[1]),
|
|
117
|
+
latitude: num(c[7]),
|
|
118
|
+
longitude: num(c[8]),
|
|
119
|
+
accuracyRadius: num(c[9])
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
function parseLocationsCsv(text) {
|
|
125
|
+
const rows = [];
|
|
126
|
+
const lines = text.split(/\r?\n/);
|
|
127
|
+
for (let i = 1; i < lines.length; i++) {
|
|
128
|
+
const line = lines[i];
|
|
129
|
+
if (!line) continue;
|
|
130
|
+
const c = parseCsvLine(line);
|
|
131
|
+
const id = num(c[0]);
|
|
132
|
+
if (id == null) continue;
|
|
133
|
+
rows.push({
|
|
134
|
+
geonameId: id,
|
|
135
|
+
continentCode: str(c[2]),
|
|
136
|
+
countryIso: str(c[4]),
|
|
137
|
+
countryName: str(c[5]),
|
|
138
|
+
subdivisionIso: str(c[6]),
|
|
139
|
+
subdivisionName: str(c[7]),
|
|
140
|
+
cityName: str(c[10]),
|
|
141
|
+
timeZone: str(c[12])
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
var CHUNK = 500;
|
|
147
|
+
async function insertChunked(store, rows, cols, sqlHead, toParams) {
|
|
148
|
+
let n = 0;
|
|
149
|
+
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
150
|
+
const batch = rows.slice(i, i + CHUNK);
|
|
151
|
+
const values = batch.map((_, b) => `(${Array.from({ length: cols }, (_2, k) => `$${b * cols + k + 1}`).join(", ")})`).join(", ");
|
|
152
|
+
const params = batch.flatMap(toParams);
|
|
153
|
+
await store.query(`${sqlHead} VALUES ${values}`, params);
|
|
154
|
+
n += batch.length;
|
|
155
|
+
}
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
async function ingestNames(store, rows) {
|
|
159
|
+
return insertChunked(
|
|
160
|
+
store,
|
|
161
|
+
rows,
|
|
162
|
+
8,
|
|
163
|
+
`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,
|
|
164
|
+
(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone]
|
|
165
|
+
).then(async (c) => {
|
|
166
|
+
return c;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async function ingestBlocks(store, rows) {
|
|
170
|
+
return insertChunked(
|
|
171
|
+
store,
|
|
172
|
+
rows,
|
|
173
|
+
5,
|
|
174
|
+
`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,
|
|
175
|
+
(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius]
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
async function loadMaxMindCity(store, files) {
|
|
179
|
+
await store.query("TRUNCATE geo_blocks");
|
|
180
|
+
await store.query("TRUNCATE geo_names");
|
|
181
|
+
const names = await ingestNames(store, parseLocationsCsv((0, import_node_fs.readFileSync)(files.locationsPath, "utf8")));
|
|
182
|
+
let blocks = 0;
|
|
183
|
+
for (const p of [files.blocksV4Path, files.blocksV6Path]) {
|
|
184
|
+
if (p) blocks += await ingestBlocks(store, parseBlocksCsv((0, import_node_fs.readFileSync)(p, "utf8")));
|
|
185
|
+
}
|
|
186
|
+
return { names, blocks };
|
|
187
|
+
}
|
|
188
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
189
|
+
0 && (module.exports = {
|
|
190
|
+
PostgresGeoProvider,
|
|
191
|
+
ingestBlocks,
|
|
192
|
+
ingestNames,
|
|
193
|
+
loadMaxMindCity,
|
|
194
|
+
parseBlocksCsv,
|
|
195
|
+
parseCsvLine,
|
|
196
|
+
parseLocationsCsv
|
|
197
|
+
});
|
|
198
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/provider.ts","../src/ingest.ts"],"sourcesContent":["// @fonderie/geo — self-hosted IP → location.\n//\n// The default PostgresGeoProvider resolves against a table of MaxMind/HE CIDR\n// blocks (native inet/GiST — IPv4 + IPv6, no external API). IGeoProvider is the\n// swap seam for a hosted source later. A signal source for @fonderie/risk and a\n// day-one \"where is this request from\" for any Fonderie app.\n//\n// Run getMigrationsPath()'s SQL with your store's migration runner, then load\n// data with loadMaxMindCity() (or the parse*/ingest* pieces).\nexport { PostgresGeoProvider } from './provider.js';\nexport {\n\tloadMaxMindCity,\n\tingestNames,\n\tingestBlocks,\n\tparseBlocksCsv,\n\tparseLocationsCsv,\n\tparseCsvLine,\n} from './ingest.js';\nexport type { BlockRow, NameRow } from './ingest.js';\nexport type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n","import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip (and an error log) for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst addr = (ip ?? '').trim();\n\t\tif (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch {\n\t\t\t// Invalid inet (bad cast) or a transient store error → unknown, not a throw.\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\nimport { readFileSync } from 'node:fs';\nimport type { Queryable } from './types.js';\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and\n * \"\" escaped quotes (MaxMind city names like \"Washington, D.C.\" need this). */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:\n * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,\n * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\tconst rows: BlockRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tif (!c[0]) continue;\n\t\trows.push({\n\t\t\tnetwork: c[0],\n\t\t\tgeonameId: num(c[1]),\n\t\t\tlatitude: num(c[7]),\n\t\t\tlongitude: num(c[8]),\n\t\t\taccuracyRadius: num(c[9]),\n\t\t});\n\t}\n\treturn rows;\n}\n\n/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:\n * geoname_id, locale_code, continent_code, continent_name, country_iso_code,\n * country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,\n * subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */\nexport function parseLocationsCsv(text: string): NameRow[] {\n\tconst rows: NameRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tconst id = num(c[0]);\n\t\tif (id == null) continue;\n\t\trows.push({\n\t\t\tgeonameId: id,\n\t\t\tcontinentCode: str(c[2]),\n\t\t\tcountryIso: str(c[4]),\n\t\t\tcountryName: str(c[5]),\n\t\t\tsubdivisionIso: str(c[6]),\n\t\t\tsubdivisionName: str(c[7]),\n\t\t\tcityName: str(c[10]),\n\t\t\ttimeZone: str(c[12]),\n\t\t});\n\t}\n\treturn rows;\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tconst params = batch.flatMap(toParams);\n\t\tawait store.query(`${sqlHead} VALUES ${values}`, params);\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nexport async function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t8,\n\t\t`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,\n\t\t(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone],\n\t).then(async (c) => {\n\t\t// geo_names is a PK table; a re-run would conflict. Caller truncates first\n\t\t// for a full reload; this keeps ingest itself simple and idempotent-free.\n\t\treturn c;\n\t});\n}\n\nexport async function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t5,\n\t\t`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,\n\t\t(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius],\n\t);\n}\n\n/** Full load from MaxMind City CSV files. Truncates first so a reload is a\n * clean replace (the dataset is a full snapshot, not a delta). */\nexport async function loadMaxMindCity(\n\tstore: Queryable,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\tawait store.query('TRUNCATE geo_blocks');\n\tawait store.query('TRUNCATE geo_names');\n\tconst names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, 'utf8')));\n\tlet blocks = 0;\n\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\tif (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, 'utf8')));\n\t}\n\treturn { names, blocks };\n}\n"],"mappings":"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","names":["_"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** A resolved IP location. Any field may be null — MaxMind has blocks with a
|
|
2
|
+
* country but no city, etc. */
|
|
3
|
+
interface GeoLocation {
|
|
4
|
+
country: string | null;
|
|
5
|
+
countryName: string | null;
|
|
6
|
+
subdivision: string | null;
|
|
7
|
+
subdivisionName: string | null;
|
|
8
|
+
city: string | null;
|
|
9
|
+
continent: string | null;
|
|
10
|
+
timeZone: string | null;
|
|
11
|
+
latitude: number | null;
|
|
12
|
+
longitude: number | null;
|
|
13
|
+
accuracyRadius: number | null;
|
|
14
|
+
}
|
|
15
|
+
/** The swap seam: the default resolves against the self-hosted Postgres table,
|
|
16
|
+
* but a consumer can plug a hosted provider (MaxMind API, ipinfo, …) behind
|
|
17
|
+
* the same interface — same pattern as IBillingProvider / IStorageProvider. */
|
|
18
|
+
interface IGeoProvider {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Resolve an IPv4/IPv6 address to a location, or null if unknown/invalid. */
|
|
21
|
+
lookup(ip: string): Promise<GeoLocation | null>;
|
|
22
|
+
}
|
|
23
|
+
/** Store adapter surface the Postgres provider needs (accepts the adapter or a
|
|
24
|
+
* transaction handle). */
|
|
25
|
+
interface Queryable {
|
|
26
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default, self-hosted provider: resolves an IP against the geo_blocks /
|
|
31
|
+
* geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR
|
|
32
|
+
* containment — the most-specific block that contains the address wins —
|
|
33
|
+
* handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.
|
|
34
|
+
*/
|
|
35
|
+
declare class PostgresGeoProvider implements IGeoProvider {
|
|
36
|
+
private readonly store;
|
|
37
|
+
readonly name = "postgres";
|
|
38
|
+
constructor(store: Queryable);
|
|
39
|
+
lookup(ip: string): Promise<GeoLocation | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BlockRow {
|
|
43
|
+
network: string;
|
|
44
|
+
geonameId: number | null;
|
|
45
|
+
latitude: number | null;
|
|
46
|
+
longitude: number | null;
|
|
47
|
+
accuracyRadius: number | null;
|
|
48
|
+
}
|
|
49
|
+
interface NameRow {
|
|
50
|
+
geonameId: number;
|
|
51
|
+
continentCode: string | null;
|
|
52
|
+
countryIso: string | null;
|
|
53
|
+
countryName: string | null;
|
|
54
|
+
subdivisionIso: string | null;
|
|
55
|
+
subdivisionName: string | null;
|
|
56
|
+
cityName: string | null;
|
|
57
|
+
timeZone: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and
|
|
60
|
+
* "" escaped quotes (MaxMind city names like "Washington, D.C." need this). */
|
|
61
|
+
declare function parseCsvLine(line: string): string[];
|
|
62
|
+
/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:
|
|
63
|
+
* network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,
|
|
64
|
+
* is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */
|
|
65
|
+
declare function parseBlocksCsv(text: string): BlockRow[];
|
|
66
|
+
/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:
|
|
67
|
+
* geoname_id, locale_code, continent_code, continent_name, country_iso_code,
|
|
68
|
+
* country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,
|
|
69
|
+
* subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */
|
|
70
|
+
declare function parseLocationsCsv(text: string): NameRow[];
|
|
71
|
+
declare function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>;
|
|
72
|
+
declare function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number>;
|
|
73
|
+
/** Full load from MaxMind City CSV files. Truncates first so a reload is a
|
|
74
|
+
* clean replace (the dataset is a full snapshot, not a delta). */
|
|
75
|
+
declare function loadMaxMindCity(store: Queryable, files: {
|
|
76
|
+
locationsPath: string;
|
|
77
|
+
blocksV4Path?: string;
|
|
78
|
+
blocksV6Path?: string;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
names: number;
|
|
81
|
+
blocks: number;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, ingestBlocks, ingestNames, loadMaxMindCity, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** A resolved IP location. Any field may be null — MaxMind has blocks with a
|
|
2
|
+
* country but no city, etc. */
|
|
3
|
+
interface GeoLocation {
|
|
4
|
+
country: string | null;
|
|
5
|
+
countryName: string | null;
|
|
6
|
+
subdivision: string | null;
|
|
7
|
+
subdivisionName: string | null;
|
|
8
|
+
city: string | null;
|
|
9
|
+
continent: string | null;
|
|
10
|
+
timeZone: string | null;
|
|
11
|
+
latitude: number | null;
|
|
12
|
+
longitude: number | null;
|
|
13
|
+
accuracyRadius: number | null;
|
|
14
|
+
}
|
|
15
|
+
/** The swap seam: the default resolves against the self-hosted Postgres table,
|
|
16
|
+
* but a consumer can plug a hosted provider (MaxMind API, ipinfo, …) behind
|
|
17
|
+
* the same interface — same pattern as IBillingProvider / IStorageProvider. */
|
|
18
|
+
interface IGeoProvider {
|
|
19
|
+
name: string;
|
|
20
|
+
/** Resolve an IPv4/IPv6 address to a location, or null if unknown/invalid. */
|
|
21
|
+
lookup(ip: string): Promise<GeoLocation | null>;
|
|
22
|
+
}
|
|
23
|
+
/** Store adapter surface the Postgres provider needs (accepts the adapter or a
|
|
24
|
+
* transaction handle). */
|
|
25
|
+
interface Queryable {
|
|
26
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The default, self-hosted provider: resolves an IP against the geo_blocks /
|
|
31
|
+
* geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR
|
|
32
|
+
* containment — the most-specific block that contains the address wins —
|
|
33
|
+
* handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.
|
|
34
|
+
*/
|
|
35
|
+
declare class PostgresGeoProvider implements IGeoProvider {
|
|
36
|
+
private readonly store;
|
|
37
|
+
readonly name = "postgres";
|
|
38
|
+
constructor(store: Queryable);
|
|
39
|
+
lookup(ip: string): Promise<GeoLocation | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BlockRow {
|
|
43
|
+
network: string;
|
|
44
|
+
geonameId: number | null;
|
|
45
|
+
latitude: number | null;
|
|
46
|
+
longitude: number | null;
|
|
47
|
+
accuracyRadius: number | null;
|
|
48
|
+
}
|
|
49
|
+
interface NameRow {
|
|
50
|
+
geonameId: number;
|
|
51
|
+
continentCode: string | null;
|
|
52
|
+
countryIso: string | null;
|
|
53
|
+
countryName: string | null;
|
|
54
|
+
subdivisionIso: string | null;
|
|
55
|
+
subdivisionName: string | null;
|
|
56
|
+
cityName: string | null;
|
|
57
|
+
timeZone: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and
|
|
60
|
+
* "" escaped quotes (MaxMind city names like "Washington, D.C." need this). */
|
|
61
|
+
declare function parseCsvLine(line: string): string[];
|
|
62
|
+
/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:
|
|
63
|
+
* network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,
|
|
64
|
+
* is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */
|
|
65
|
+
declare function parseBlocksCsv(text: string): BlockRow[];
|
|
66
|
+
/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:
|
|
67
|
+
* geoname_id, locale_code, continent_code, continent_name, country_iso_code,
|
|
68
|
+
* country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,
|
|
69
|
+
* subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */
|
|
70
|
+
declare function parseLocationsCsv(text: string): NameRow[];
|
|
71
|
+
declare function ingestNames(store: Queryable, rows: NameRow[]): Promise<number>;
|
|
72
|
+
declare function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number>;
|
|
73
|
+
/** Full load from MaxMind City CSV files. Truncates first so a reload is a
|
|
74
|
+
* clean replace (the dataset is a full snapshot, not a delta). */
|
|
75
|
+
declare function loadMaxMindCity(store: Queryable, files: {
|
|
76
|
+
locationsPath: string;
|
|
77
|
+
blocksV4Path?: string;
|
|
78
|
+
blocksV6Path?: string;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
names: number;
|
|
81
|
+
blocks: number;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
export { type BlockRow, type GeoLocation, type IGeoProvider, type NameRow, PostgresGeoProvider, type Queryable, ingestBlocks, ingestNames, loadMaxMindCity, parseBlocksCsv, parseCsvLine, parseLocationsCsv };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/provider.ts
|
|
2
|
+
var LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;
|
|
3
|
+
var PostgresGeoProvider = class {
|
|
4
|
+
constructor(store) {
|
|
5
|
+
this.store = store;
|
|
6
|
+
}
|
|
7
|
+
store;
|
|
8
|
+
name = "postgres";
|
|
9
|
+
async lookup(ip) {
|
|
10
|
+
const addr = (ip ?? "").trim();
|
|
11
|
+
if (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;
|
|
12
|
+
try {
|
|
13
|
+
const rows = await this.store.query(
|
|
14
|
+
`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,
|
|
15
|
+
n.city_name, n.continent_code, n.time_zone,
|
|
16
|
+
b.latitude, b.longitude, b.accuracy_radius
|
|
17
|
+
FROM geo_blocks b
|
|
18
|
+
LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id
|
|
19
|
+
WHERE b.network >>= $1::inet
|
|
20
|
+
ORDER BY masklen(b.network) DESC
|
|
21
|
+
LIMIT 1`,
|
|
22
|
+
[addr]
|
|
23
|
+
);
|
|
24
|
+
const r = rows[0];
|
|
25
|
+
if (!r) return null;
|
|
26
|
+
return {
|
|
27
|
+
country: r.country_iso ?? null,
|
|
28
|
+
countryName: r.country_name ?? null,
|
|
29
|
+
subdivision: r.subdivision_iso ?? null,
|
|
30
|
+
subdivisionName: r.subdivision_name ?? null,
|
|
31
|
+
city: r.city_name ?? null,
|
|
32
|
+
continent: r.continent_code ?? null,
|
|
33
|
+
timeZone: r.time_zone ?? null,
|
|
34
|
+
latitude: r.latitude != null ? Number(r.latitude) : null,
|
|
35
|
+
longitude: r.longitude != null ? Number(r.longitude) : null,
|
|
36
|
+
accuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/ingest.ts
|
|
45
|
+
import { readFileSync } from "fs";
|
|
46
|
+
function parseCsvLine(line) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let field = "";
|
|
49
|
+
let inQuotes = false;
|
|
50
|
+
for (let i = 0; i < line.length; i++) {
|
|
51
|
+
const c = line[i];
|
|
52
|
+
if (inQuotes) {
|
|
53
|
+
if (c === '"') {
|
|
54
|
+
if (line[i + 1] === '"') {
|
|
55
|
+
field += '"';
|
|
56
|
+
i++;
|
|
57
|
+
} else inQuotes = false;
|
|
58
|
+
} else field += c;
|
|
59
|
+
} else if (c === '"') inQuotes = true;
|
|
60
|
+
else if (c === ",") {
|
|
61
|
+
out.push(field);
|
|
62
|
+
field = "";
|
|
63
|
+
} else field += c;
|
|
64
|
+
}
|
|
65
|
+
out.push(field);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
var num = (s) => {
|
|
69
|
+
if (s == null || s === "") return null;
|
|
70
|
+
const n = Number(s);
|
|
71
|
+
return Number.isFinite(n) ? n : null;
|
|
72
|
+
};
|
|
73
|
+
var str = (s) => s == null || s === "" ? null : s;
|
|
74
|
+
function parseBlocksCsv(text) {
|
|
75
|
+
const rows = [];
|
|
76
|
+
const lines = text.split(/\r?\n/);
|
|
77
|
+
for (let i = 1; i < lines.length; i++) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (!line) continue;
|
|
80
|
+
const c = parseCsvLine(line);
|
|
81
|
+
if (!c[0]) continue;
|
|
82
|
+
rows.push({
|
|
83
|
+
network: c[0],
|
|
84
|
+
geonameId: num(c[1]),
|
|
85
|
+
latitude: num(c[7]),
|
|
86
|
+
longitude: num(c[8]),
|
|
87
|
+
accuracyRadius: num(c[9])
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return rows;
|
|
91
|
+
}
|
|
92
|
+
function parseLocationsCsv(text) {
|
|
93
|
+
const rows = [];
|
|
94
|
+
const lines = text.split(/\r?\n/);
|
|
95
|
+
for (let i = 1; i < lines.length; i++) {
|
|
96
|
+
const line = lines[i];
|
|
97
|
+
if (!line) continue;
|
|
98
|
+
const c = parseCsvLine(line);
|
|
99
|
+
const id = num(c[0]);
|
|
100
|
+
if (id == null) continue;
|
|
101
|
+
rows.push({
|
|
102
|
+
geonameId: id,
|
|
103
|
+
continentCode: str(c[2]),
|
|
104
|
+
countryIso: str(c[4]),
|
|
105
|
+
countryName: str(c[5]),
|
|
106
|
+
subdivisionIso: str(c[6]),
|
|
107
|
+
subdivisionName: str(c[7]),
|
|
108
|
+
cityName: str(c[10]),
|
|
109
|
+
timeZone: str(c[12])
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
var CHUNK = 500;
|
|
115
|
+
async function insertChunked(store, rows, cols, sqlHead, toParams) {
|
|
116
|
+
let n = 0;
|
|
117
|
+
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
118
|
+
const batch = rows.slice(i, i + CHUNK);
|
|
119
|
+
const values = batch.map((_, b) => `(${Array.from({ length: cols }, (_2, k) => `$${b * cols + k + 1}`).join(", ")})`).join(", ");
|
|
120
|
+
const params = batch.flatMap(toParams);
|
|
121
|
+
await store.query(`${sqlHead} VALUES ${values}`, params);
|
|
122
|
+
n += batch.length;
|
|
123
|
+
}
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
async function ingestNames(store, rows) {
|
|
127
|
+
return insertChunked(
|
|
128
|
+
store,
|
|
129
|
+
rows,
|
|
130
|
+
8,
|
|
131
|
+
`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,
|
|
132
|
+
(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone]
|
|
133
|
+
).then(async (c) => {
|
|
134
|
+
return c;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async function ingestBlocks(store, rows) {
|
|
138
|
+
return insertChunked(
|
|
139
|
+
store,
|
|
140
|
+
rows,
|
|
141
|
+
5,
|
|
142
|
+
`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,
|
|
143
|
+
(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius]
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
async function loadMaxMindCity(store, files) {
|
|
147
|
+
await store.query("TRUNCATE geo_blocks");
|
|
148
|
+
await store.query("TRUNCATE geo_names");
|
|
149
|
+
const names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, "utf8")));
|
|
150
|
+
let blocks = 0;
|
|
151
|
+
for (const p of [files.blocksV4Path, files.blocksV6Path]) {
|
|
152
|
+
if (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, "utf8")));
|
|
153
|
+
}
|
|
154
|
+
return { names, blocks };
|
|
155
|
+
}
|
|
156
|
+
export {
|
|
157
|
+
PostgresGeoProvider,
|
|
158
|
+
ingestBlocks,
|
|
159
|
+
ingestNames,
|
|
160
|
+
loadMaxMindCity,
|
|
161
|
+
parseBlocksCsv,
|
|
162
|
+
parseCsvLine,
|
|
163
|
+
parseLocationsCsv
|
|
164
|
+
};
|
|
165
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/provider.ts","../src/ingest.ts"],"sourcesContent":["import type { GeoLocation, IGeoProvider, Queryable } from './types.js';\n\n// Cheap guard so obvious garbage never reaches the ::inet cast (which would\n// throw). Not a full validator — the cast is the real gate; this just avoids a\n// round-trip (and an error log) for empty / clearly-non-IP input.\nconst LOOKS_LIKE_IP = /^[0-9a-fA-F:.]+$/;\n\n/**\n * The default, self-hosted provider: resolves an IP against the geo_blocks /\n * geo_names tables loaded from MaxMind/HE CSVs. The lookup is a CIDR\n * containment — the most-specific block that contains the address wins —\n * handling IPv4 and IPv6 uniformly via Postgres's native `cidr`/`inet`.\n */\nexport class PostgresGeoProvider implements IGeoProvider {\n\treadonly name = 'postgres';\n\n\tconstructor(private readonly store: Queryable) {}\n\n\tasync lookup(ip: string): Promise<GeoLocation | null> {\n\t\tconst addr = (ip ?? '').trim();\n\t\tif (!addr || addr.length > 45 || !LOOKS_LIKE_IP.test(addr)) return null;\n\t\ttry {\n\t\t\tconst rows = await this.store.query<{\n\t\t\t\tcountry_iso: string | null;\n\t\t\t\tcountry_name: string | null;\n\t\t\t\tsubdivision_iso: string | null;\n\t\t\t\tsubdivision_name: string | null;\n\t\t\t\tcity_name: string | null;\n\t\t\t\tcontinent_code: string | null;\n\t\t\t\ttime_zone: string | null;\n\t\t\t\tlatitude: number | null;\n\t\t\t\tlongitude: number | null;\n\t\t\t\taccuracy_radius: number | null;\n\t\t\t}>(\n\t\t\t\t`SELECT n.country_iso, n.country_name, n.subdivision_iso, n.subdivision_name,\n\t\t\t\t n.city_name, n.continent_code, n.time_zone,\n\t\t\t\t b.latitude, b.longitude, b.accuracy_radius\n\t\t\t\t FROM geo_blocks b\n\t\t\t\t LEFT JOIN geo_names n ON n.geoname_id = b.geoname_id\n\t\t\t\t WHERE b.network >>= $1::inet\n\t\t\t\t ORDER BY masklen(b.network) DESC\n\t\t\t\t LIMIT 1`,\n\t\t\t\t[addr],\n\t\t\t);\n\t\t\tconst r = rows[0];\n\t\t\tif (!r) return null;\n\t\t\treturn {\n\t\t\t\tcountry: r.country_iso ?? null,\n\t\t\t\tcountryName: r.country_name ?? null,\n\t\t\t\tsubdivision: r.subdivision_iso ?? null,\n\t\t\t\tsubdivisionName: r.subdivision_name ?? null,\n\t\t\t\tcity: r.city_name ?? null,\n\t\t\t\tcontinent: r.continent_code ?? null,\n\t\t\t\ttimeZone: r.time_zone ?? null,\n\t\t\t\tlatitude: r.latitude != null ? Number(r.latitude) : null,\n\t\t\t\tlongitude: r.longitude != null ? Number(r.longitude) : null,\n\t\t\t\taccuracyRadius: r.accuracy_radius != null ? Number(r.accuracy_radius) : null,\n\t\t\t};\n\t\t} catch {\n\t\t\t// Invalid inet (bad cast) or a transient store error → unknown, not a throw.\n\t\t\treturn null;\n\t\t}\n\t}\n}\n","// Load MaxMind GeoLite2 City CSVs (the same files the prior arbinuity importer\n// used) into geo_blocks + geo_names. Hurricane Electric / other sources work\n// too as long as rows map to {network, geoname_id, lat, lng, accuracy} and\n// {geoname_id, country, subdivision, city}.\nimport { readFileSync } from 'node:fs';\nimport type { Queryable } from './types.js';\n\nexport interface BlockRow {\n\tnetwork: string;\n\tgeonameId: number | null;\n\tlatitude: number | null;\n\tlongitude: number | null;\n\taccuracyRadius: number | null;\n}\n\nexport interface NameRow {\n\tgeonameId: number;\n\tcontinentCode: string | null;\n\tcountryIso: string | null;\n\tcountryName: string | null;\n\tsubdivisionIso: string | null;\n\tsubdivisionName: string | null;\n\tcityName: string | null;\n\ttimeZone: string | null;\n}\n\n/** RFC4180-ish single-line parser: handles quoted fields, embedded commas, and\n * \"\" escaped quotes (MaxMind city names like \"Washington, D.C.\" need this). */\nexport function parseCsvLine(line: string): string[] {\n\tconst out: string[] = [];\n\tlet field = '';\n\tlet inQuotes = false;\n\tfor (let i = 0; i < line.length; i++) {\n\t\tconst c = line[i];\n\t\tif (inQuotes) {\n\t\t\tif (c === '\"') {\n\t\t\t\tif (line[i + 1] === '\"') { field += '\"'; i++; } else inQuotes = false;\n\t\t\t} else field += c;\n\t\t} else if (c === '\"') inQuotes = true;\n\t\telse if (c === ',') { out.push(field); field = ''; }\n\t\telse field += c;\n\t}\n\tout.push(field);\n\treturn out;\n}\n\nconst num = (s: string | undefined): number | null => {\n\tif (s == null || s === '') return null;\n\tconst n = Number(s);\n\treturn Number.isFinite(n) ? n : null;\n};\nconst str = (s: string | undefined): string | null => (s == null || s === '' ? null : s);\n\n/** Parse a GeoLite2-City-Blocks-IPv4/IPv6 CSV (header row skipped). Columns:\n * network, geoname_id, registered_country_geoname_id, represented_country_geoname_id,\n * is_anonymous_proxy, is_satellite_provider, postal_code, latitude, longitude, accuracy_radius. */\nexport function parseBlocksCsv(text: string): BlockRow[] {\n\tconst rows: BlockRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tif (!c[0]) continue;\n\t\trows.push({\n\t\t\tnetwork: c[0],\n\t\t\tgeonameId: num(c[1]),\n\t\t\tlatitude: num(c[7]),\n\t\t\tlongitude: num(c[8]),\n\t\t\taccuracyRadius: num(c[9]),\n\t\t});\n\t}\n\treturn rows;\n}\n\n/** Parse a GeoLite2-City-Locations-<locale> CSV (header row skipped). Columns:\n * geoname_id, locale_code, continent_code, continent_name, country_iso_code,\n * country_name, subdivision_1_iso_code, subdivision_1_name, subdivision_2_iso_code,\n * subdivision_2_name, city_name, metro_code, time_zone, is_in_european_union. */\nexport function parseLocationsCsv(text: string): NameRow[] {\n\tconst rows: NameRow[] = [];\n\tconst lines = text.split(/\\r?\\n/);\n\tfor (let i = 1; i < lines.length; i++) {\n\t\tconst line = lines[i];\n\t\tif (!line) continue;\n\t\tconst c = parseCsvLine(line);\n\t\tconst id = num(c[0]);\n\t\tif (id == null) continue;\n\t\trows.push({\n\t\t\tgeonameId: id,\n\t\t\tcontinentCode: str(c[2]),\n\t\t\tcountryIso: str(c[4]),\n\t\t\tcountryName: str(c[5]),\n\t\t\tsubdivisionIso: str(c[6]),\n\t\t\tsubdivisionName: str(c[7]),\n\t\t\tcityName: str(c[10]),\n\t\t\ttimeZone: str(c[12]),\n\t\t});\n\t}\n\treturn rows;\n}\n\nconst CHUNK = 500;\n\nasync function insertChunked<T>(\n\tstore: Queryable,\n\trows: T[],\n\tcols: number,\n\tsqlHead: string,\n\ttoParams: (r: T) => unknown[],\n): Promise<number> {\n\tlet n = 0;\n\tfor (let i = 0; i < rows.length; i += CHUNK) {\n\t\tconst batch = rows.slice(i, i + CHUNK);\n\t\tconst values = batch\n\t\t\t.map((_, b) => `(${Array.from({ length: cols }, (_, k) => `$${b * cols + k + 1}`).join(', ')})`)\n\t\t\t.join(', ');\n\t\tconst params = batch.flatMap(toParams);\n\t\tawait store.query(`${sqlHead} VALUES ${values}`, params);\n\t\tn += batch.length;\n\t}\n\treturn n;\n}\n\nexport async function ingestNames(store: Queryable, rows: NameRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t8,\n\t\t`INSERT INTO geo_names (geoname_id, continent_code, country_iso, country_name, subdivision_iso, subdivision_name, city_name, time_zone)`,\n\t\t(r) => [r.geonameId, r.continentCode, r.countryIso, r.countryName, r.subdivisionIso, r.subdivisionName, r.cityName, r.timeZone],\n\t).then(async (c) => {\n\t\t// geo_names is a PK table; a re-run would conflict. Caller truncates first\n\t\t// for a full reload; this keeps ingest itself simple and idempotent-free.\n\t\treturn c;\n\t});\n}\n\nexport async function ingestBlocks(store: Queryable, rows: BlockRow[]): Promise<number> {\n\treturn insertChunked(\n\t\tstore,\n\t\trows,\n\t\t5,\n\t\t`INSERT INTO geo_blocks (network, geoname_id, latitude, longitude, accuracy_radius)`,\n\t\t(r) => [r.network, r.geonameId, r.latitude, r.longitude, r.accuracyRadius],\n\t);\n}\n\n/** Full load from MaxMind City CSV files. Truncates first so a reload is a\n * clean replace (the dataset is a full snapshot, not a delta). */\nexport async function loadMaxMindCity(\n\tstore: Queryable,\n\tfiles: { locationsPath: string; blocksV4Path?: string; blocksV6Path?: string },\n): Promise<{ names: number; blocks: number }> {\n\tawait store.query('TRUNCATE geo_blocks');\n\tawait store.query('TRUNCATE geo_names');\n\tconst names = await ingestNames(store, parseLocationsCsv(readFileSync(files.locationsPath, 'utf8')));\n\tlet blocks = 0;\n\tfor (const p of [files.blocksV4Path, files.blocksV6Path]) {\n\t\tif (p) blocks += await ingestBlocks(store, parseBlocksCsv(readFileSync(p, 'utf8')));\n\t}\n\treturn { names, blocks };\n}\n"],"mappings":"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","names":["_"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/index.ts"],"sourcesContent":["import { createMigrationsPath } from '@fonderie/store';\n\nexport const getMigrationsPath = (): string => createMigrationsPath(import.meta.url);\n"],"mappings":";AAAA,SAAS,4BAA4B;AAE9B,IAAM,oBAAoB,MAAc,qBAAqB,YAAY,GAAG;","names":[]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
-- ----------------------------------------------------------------------------
|
|
2
|
+
-- 001_geo
|
|
3
|
+
-- ----------------------------------------------------------------------------
|
|
4
|
+
-- Self-hosted IP → location. Two tables, loaded from MaxMind GeoLite2 (or
|
|
5
|
+
-- Hurricane Electric) City CSVs by the ingest command:
|
|
6
|
+
--
|
|
7
|
+
-- geo_blocks — one row per CIDR block (`network`), pointing at a geoname_id,
|
|
8
|
+
-- with the block's own lat/lng/accuracy.
|
|
9
|
+
-- geo_names — the human-readable location for a geoname_id (country,
|
|
10
|
+
-- subdivision, city), denormalized for a single-join lookup.
|
|
11
|
+
--
|
|
12
|
+
-- Stored as native `cidr`, so ONE table holds IPv4 AND IPv6 and the lookup is
|
|
13
|
+
-- a range-contains — `WHERE network >>= $ip::inet ORDER BY masklen(network)
|
|
14
|
+
-- DESC LIMIT 1` (the most-specific block wins). The GiST `inet_ops` index makes
|
|
15
|
+
-- that containment fast; it ships in core Postgres (>= 9.4), no extension.
|
|
16
|
+
-- ----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS geo_names (
|
|
19
|
+
geoname_id BIGINT PRIMARY KEY,
|
|
20
|
+
continent_code TEXT,
|
|
21
|
+
country_iso TEXT, -- ISO-3166-1 alpha-2
|
|
22
|
+
country_name TEXT,
|
|
23
|
+
subdivision_iso TEXT, -- ISO-3166-2 (state/region), if any
|
|
24
|
+
subdivision_name TEXT,
|
|
25
|
+
city_name TEXT,
|
|
26
|
+
time_zone TEXT
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS geo_blocks (
|
|
30
|
+
network CIDR NOT NULL, -- IPv4 or IPv6 block
|
|
31
|
+
geoname_id BIGINT, -- FK-by-value into geo_names (nullable: MaxMind has blocks with no city)
|
|
32
|
+
latitude DOUBLE PRECISION,
|
|
33
|
+
longitude DOUBLE PRECISION,
|
|
34
|
+
accuracy_radius INTEGER
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
-- Containment lookup: which block(s) contain this address? inet_ops GiST
|
|
38
|
+
-- supports the >>= operator; masklen() then picks the most specific.
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_geo_blocks_network ON geo_blocks USING gist (network inet_ops);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_geo_blocks_geoname ON geo_blocks (geoname_id);
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fonderie/geo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"fonderie": { "stability": "experimental" },
|
|
5
|
+
"description": "Self-hosted IP → location. Provider-abstracted (IGeoProvider); the default resolves against a Postgres table of MaxMind/Hurricane-Electric CIDR blocks using native inet/GiST — IPv4 and IPv6, no external API, no key shipped. A signal source for @fonderie/risk and a day-one 'where did this request come from' for any Fonderie app.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"fonderiejs",
|
|
8
|
+
"geoip",
|
|
9
|
+
"geolocation",
|
|
10
|
+
"ip",
|
|
11
|
+
"maxmind",
|
|
12
|
+
"risk",
|
|
13
|
+
"saas",
|
|
14
|
+
"typescript"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"require": "./dist/index.cjs"
|
|
26
|
+
},
|
|
27
|
+
"./migrations": {
|
|
28
|
+
"types": "./dist/migrations/index.d.ts",
|
|
29
|
+
"import": "./dist/migrations/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/index.cjs",
|
|
33
|
+
"module": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup && tsup --config tsup.migrations.ts",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "tsx --test src/__tests__/*.test.ts",
|
|
40
|
+
"lint": "biome lint src",
|
|
41
|
+
"format": "biome format --write src",
|
|
42
|
+
"check": "biome check --write src"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@fonderie/core": "^0.10.0",
|
|
46
|
+
"@fonderie/store": "^0.3.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@fonderie/core": "../core",
|
|
50
|
+
"@fonderie/store": "../store",
|
|
51
|
+
"@types/node": "^26.4.1",
|
|
52
|
+
"tsup": "^8.5.1",
|
|
53
|
+
"tsx": "^4.23.13",
|
|
54
|
+
"typescript": "^6.0.3"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"dist",
|
|
61
|
+
"brain",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"README.md"
|
|
64
|
+
],
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/fonderiejs/fonderie.git",
|
|
68
|
+
"directory": "packages/geo"
|
|
69
|
+
},
|
|
70
|
+
"homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/geo#readme",
|
|
71
|
+
"bugs": {
|
|
72
|
+
"url": "https://github.com/fonderiejs/fonderie/issues"
|
|
73
|
+
}
|
|
74
|
+
}
|