const DEFAULT_NOMINATIM_BASE = 'https://nominatim.openstreetmap.org'; const MIN_REQUEST_INTERVAL_MS = 1000; const ALLOWED_HOSTS = new Set(['nominatim.openstreetmap.org']); let lastRequestAt = 0; function assertAllowedHost(url) { const host = new URL(url).hostname.toLowerCase(); if (!ALLOWED_HOSTS.has(host)) { throw new Error(`[nominatim] Host not allowlisted: ${host}`); } } function getUserAgent() { return process.env.NOMINATIM_USER_AGENT ?? 'DeckHearth/1.0'; } async function throttle() { const now = Date.now(); const elapsed = now - lastRequestAt; if (elapsed < MIN_REQUEST_INTERVAL_MS) { await new Promise((resolve) => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed)); } lastRequestAt = Date.now(); } /** * @param {string} zip Five-digit US zip code. * @param {{ fetchImpl?: typeof fetch }} [options] * @returns {Promise<{ lat: number, lng: number, displayName: string } | null>} */ export async function geocodeUsZip(zip, { fetchImpl = fetch } = {}) { const base = process.env.NOMINATIM_BASE_URL ?? DEFAULT_NOMINATIM_BASE; const url = new URL('/search', base); url.searchParams.set('postalcode', zip); url.searchParams.set('country', 'US'); url.searchParams.set('format', 'json'); url.searchParams.set('limit', '1'); assertAllowedHost(url.toString()); await throttle(); const response = await fetchImpl(url.toString(), { headers: { 'User-Agent': getUserAgent() }, }); if (!response.ok) { throw new Error(`[nominatim] HTTP ${response.status}`); } const results = await response.json(); if (!Array.isArray(results) || results.length === 0) { return null; } const hit = results[0]; const lat = Number(hit.lat); const lng = Number(hit.lon); if (!Number.isFinite(lat) || !Number.isFinite(lng)) { return null; } return { lat, lng, displayName: hit.display_name ?? `${zip}, US`, }; }