feat(geo): support overriding Nominatim endpoint via env

This commit is contained in:
Luffy
2026-09-16 14:30:31 +08:00
parent 06610bd97e
commit c0195557d4
7 changed files with 30 additions and 3 deletions
+2
View File
@@ -49,6 +49,8 @@ DEMO_MODE=false # Demo mode - resets data hourly
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) # MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
# NOMINATIM_URL=https://nominatim.openstreetmap.org # Custom HTTP(S) Nominatim base URL for maps and Atlas geocoding. Unset/blank uses the public default.
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less. # OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000) # OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
+1 -1
View File
@@ -82,7 +82,7 @@ TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS, OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL, ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check), ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
LLM_TIMEOUT_MS. LLM_TIMEOUT_MS, NOMINATIM_URL.
## Exemptions — raw `process.env` stays ## Exemptions — raw `process.env` stays
+1
View File
@@ -265,6 +265,7 @@ export function deriveIntegrations(raw: RawEnv) {
return { return {
unsplashAccessKey: raw.UNSPLASH_ACCESS_KEY?.trim(), unsplashAccessKey: raw.UNSPLASH_ACCESS_KEY?.trim(),
transitApiBase: stripTrailingSlashes(raw.TRANSIT_API_URL || 'https://api.transitous.org'), transitApiBase: stripTrailingSlashes(raw.TRANSIT_API_URL || 'https://api.transitous.org'),
nominatimUrl: stripTrailingSlashes(raw.NOMINATIM_URL || 'https://nominatim.openstreetmap.org'),
overpassUrl: raw.OVERPASS_URL, overpassUrl: raw.OVERPASS_URL,
// Longer than the `[timeout:20]` the query itself carries, or we abort an answer the // Longer than the `[timeout:20]` the query itself carries, or we abort an answer the
// mirror was still allowed to be working on. See OVERPASS_QUERY_TIMEOUT_S. // mirror was still allowed to be working on. See OVERPASS_QUERY_TIMEOUT_S.
+1
View File
@@ -108,6 +108,7 @@ export const envSchema = z.object({
// Integrations // Integrations
UNSPLASH_ACCESS_KEY: anyString, UNSPLASH_ACCESS_KEY: anyString,
TRANSIT_API_URL: url, TRANSIT_API_URL: url,
NOMINATIM_URL: url,
// OVERPASS_URL accepts a comma-separated endpoint list and silently drops // OVERPASS_URL accepts a comma-separated endpoint list and silently drops
// non-http(s) entries today — left unvalidated to keep that behavior. // non-http(s) entries today — left unvalidated to keep that behavior.
OVERPASS_URL: anyString, OVERPASS_URL: anyString,
+2 -2
View File
@@ -1,4 +1,5 @@
import { UA } from '../maps/maps.helpers'; import { UA } from '../maps/maps.helpers';
import { readEnv } from '../../app-config';
/** /**
* The one Nominatim client. * The one Nominatim client.
@@ -21,7 +22,6 @@ import { UA } from '../maps/maps.helpers';
* header, not the weaker one. * header, not the weaker one.
*/ */
const BASE = 'https://nominatim.openstreetmap.org';
const MIN_INTERVAL_MS = 1100; const MIN_INTERVAL_MS = 1100;
let lastCall = 0; let lastCall = 0;
@@ -171,7 +171,7 @@ export async function nominatimFetch(
): Promise<Response> { ): Promise<Response> {
await throttle(opts.lane ?? 'interactive'); await throttle(opts.lane ?? 'interactive');
const signal = opts.signal ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined); const signal = opts.signal ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined);
return fetch(`${BASE}/${path}?${params.toString()}`, { return fetch(`${readEnv().integrations.nominatimUrl}/${path}?${params.toString()}`, {
headers: { 'User-Agent': UA }, headers: { 'User-Agent': UA },
...(signal ? { signal } : {}), ...(signal ? { signal } : {}),
}); });
@@ -28,6 +28,7 @@ import { UA } from '../../../src/nest/maps/maps.helpers';
const fetchMock = vi.fn(); const fetchMock = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.stubEnv('NOMINATIM_URL', undefined);
vi.useFakeTimers(); vi.useFakeTimers();
// tests/setup.ts zeroes this for every other suite. This one is about the // tests/setup.ts zeroes this for every other suite. This one is about the
// throttle, so it runs against the interval the real service publishes. // throttle, so it runs against the interval the real service publishes.
@@ -43,6 +44,7 @@ afterEach(() => {
setGeoThrottleInterval(0); setGeoThrottleInterval(0);
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.unstubAllEnvs();
}); });
/** Runs `fn`, draining fake timers until it settles. */ /** Runs `fn`, draining fake timers until it settles. */
@@ -53,6 +55,26 @@ async function drain<T>(fn: () => Promise<T>): Promise<T> {
} }
describe('nominatimFetch', () => { describe('nominatimFetch', () => {
it('uses the public endpoint for an empty override', async () => {
vi.stubEnv('NOMINATIM_URL', '');
await drain(() => nominatimFetch('search', new URLSearchParams({ q: 'Berlin' })));
expect(fetchMock.mock.calls[0][0]).toBe('https://nominatim.openstreetmap.org/search?q=Berlin');
});
it.each(['search', 'reverse', 'lookup'] as const)('routes %s to the configured path prefix', async (path) => {
vi.stubEnv('NOMINATIM_URL', 'http://nominatim:8080/geocoding///');
const params = new URLSearchParams({ q: 'New York & Berlin' });
await drain(() => nominatimFetch(path, params));
expect(fetchMock.mock.calls[0][0]).toBe(`http://nominatim:8080/geocoding/${path}?q=New+York+%26+Berlin`);
expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toBe(UA);
});
it('supports an HTTPS override without a trailing slash', async () => {
vi.stubEnv('NOMINATIM_URL', 'https://geo.example.com');
await drain(() => nominatimFetch('lookup', new URLSearchParams({ osm_ids: 'N1' })));
expect(fetchMock.mock.calls[0][0]).toBe('https://geo.example.com/lookup?osm_ids=N1');
});
it('GEO-001: identifies the instance on every request', async () => { it('GEO-001: identifies the instance on every request', async () => {
await drain(() => nominatimFetch('search', new URLSearchParams({ q: 'Berlin' }))); await drain(() => nominatimFetch('search', new URLSearchParams({ q: 'Berlin' })));
+1
View File
@@ -396,6 +396,7 @@ next key rotation or admin reset would quietly switch the file back to WAL.
| Variable | Description | Default | | Variable | Description | Default |
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------| |---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|
| `IDEMPOTENCY_TTL_SECONDS` | How long (in seconds) stored idempotency keys are kept before garbage collection. The offline client replays queued mutations with their `X-Idempotency-Key` on reconnect, so this must exceed the longest expected offline window or a replay could create a duplicate. Invalid values abort startup. | `2592000` (30 days) | | `IDEMPOTENCY_TTL_SECONDS` | How long (in seconds) stored idempotency keys are kept before garbage collection. The offline client replays queued mutations with their `X-Idempotency-Key` on reconnect, so this must exceed the longest expected offline window or a replay could create a duplicate. Invalid values abort startup. | `2592000` (30 days) |
| `NOMINATIM_URL` | Custom HTTP(S) Nominatim base URL for maps and Atlas geocoding. Unset or blank uses the default. | `https://nominatim.openstreetmap.org` |
| `OVERPASS_URL` | Custom [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) endpoint(s) used by the map's POI "explore" search, comma-separated. When set it **replaces** the bundled public mirrors — point it at an internal or self-hosted Overpass instance when the public mirrors are unreachable from your network (e.g. firewalled/locked-down egress in a Kubernetes cluster). Entries that aren't valid `http(s)` URLs are ignored. If you don't run your own Overpass but the public mirrors throttle TREK, first make sure `APP_URL` (or `ALLOWED_ORIGINS`) is set: that alone gives outbound Overpass/Nominatim requests a unique User-Agent, which the public mirrors rate-limit far less. | bundled public mirrors | | `OVERPASS_URL` | Custom [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) endpoint(s) used by the map's POI "explore" search, comma-separated. When set it **replaces** the bundled public mirrors — point it at an internal or self-hosted Overpass instance when the public mirrors are unreachable from your network (e.g. firewalled/locked-down egress in a Kubernetes cluster). Entries that aren't valid `http(s)` URLs are ignored. If you don't run your own Overpass but the public mirrors throttle TREK, first make sure `APP_URL` (or `ALLOWED_ORIGINS`) is set: that alone gives outbound Overpass/Nominatim requests a unique User-Agent, which the public mirrors rate-limit far less. | bundled public mirrors |
| `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. Raise it if you run a slow self-hosted Overpass instance. Invalid values abort startup. | `12000` | | `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. Raise it if you run a slow self-hosted Overpass instance. Invalid values abort startup. | `12000` |
| `LLM_TIMEOUT_MS` | How long (in milliseconds) one AI-parsing call may take before it is abandoned. One ceiling for every provider, applied to the abort signal and to the underlying HTTP client alike. The default is generous so heavier parsing work fits without a code change; lower it if you use a cloud provider and would rather fail fast. Invalid values abort startup. | `900000` (15 min) | | `LLM_TIMEOUT_MS` | How long (in milliseconds) one AI-parsing call may take before it is abandoned. One ceiling for every provider, applied to the abort signal and to the underlying HTTP client alike. The default is generous so heavier parsing work fits without a code change; lower it if you use a cloud provider and would rather fail fast. Invalid values abort startup. | `900000` (15 min) |