diff --git a/apps/web/src/lib/search/server/geocoder.test.ts b/apps/web/src/lib/search/server/geocoder.test.ts index 6163fd4..aab712b 100644 --- a/apps/web/src/lib/search/server/geocoder.test.ts +++ b/apps/web/src/lib/search/server/geocoder.test.ts @@ -73,6 +73,36 @@ describe('createGeocoder', () => { expect(calls[0].url).toContain('key=tok'); }); + it('requests addressdetails so callers get structured address parts', async () => { + const { fn, calls } = fakeFetch([{ lat: '50.84', lon: '4.36', addresstype: 'city' }]); + await createGeocoder({}, fn).geocode('Bruxelles, BE'); + expect(calls[0].url).toContain('addressdetails=1'); + }); + + it('carries label + addressdetails + osm refs so no caller needs a second client', async () => { + const { fn } = fakeFetch([ + { + lat: '38.2542', + lon: '-85.7594', + display_name: 'Louisville, Jefferson County, Kentucky, United States', + addresstype: 'city', + osm_type: 'relation', + osm_id: 207611, + address: { city: 'Louisville', state: 'Kentucky', country: 'United States' } + } + ]); + const point = await createGeocoder({}, fn).geocode('Louisville, KY'); + expect(point).toEqual({ + lat: 38.2542, + lng: -85.7594, + precision: 'locality', + label: 'Louisville, Jefferson County, Kentucky, United States', + address: { city: 'Louisville', state: 'Kentucky', country: 'United States' }, + osmType: 'relation', + osmId: 207611 + }); + }); + it('returns null on an empty result set (no-match)', async () => { const { fn } = fakeFetch([]); expect(await createGeocoder({}, fn).geocode('nowhere')).toBeNull(); diff --git a/apps/web/src/lib/search/server/geocoder.ts b/apps/web/src/lib/search/server/geocoder.ts index 6e6cf54..a01070c 100644 --- a/apps/web/src/lib/search/server/geocoder.ts +++ b/apps/web/src/lib/search/server/geocoder.ts @@ -13,6 +13,15 @@ export interface GeoPoint { lng: number; /** Provider-reported granularity tier (rooftop/street/locality/...). */ precision?: string; + /** Top match's display name — shown so a user can spot a wrong match. Present + * for interactive callers (near-me search box, address-entry form). */ + label?: string; + /** Structured address parts (addressdetails=1) — the address-entry form maps + * these back onto its street/locality/region/country fields. */ + address?: Record; + /** OSM object refs for a canonical openstreetmap.org permalink. */ + osmType?: string; + osmId?: number; } export interface Geocoder { @@ -118,6 +127,9 @@ type NominatimHit = { class?: string; place_rank?: number; addresstype?: string; + address?: Record; + osm_type?: string; + osm_id?: number; }; const ROOFTOP = new Set(['house', 'building', 'address', 'house_number']); @@ -161,6 +173,10 @@ export function createGeocoder(env: GeocoderEnv = {}, fetchImpl: typeof fetch = url.searchParams.set('q', q); url.searchParams.set('format', 'json'); url.searchParams.set('limit', '1'); + // Structured address parts so the interactive address-entry form can map + // them onto its fields without a second client. Both Nominatim and + // LocationIQ honor this; the bulk drip simply ignores the extra fields. + url.searchParams.set('addressdetails', '1'); if (key) url.searchParams.set('key', key); const res = await fetchImpl(url, { @@ -189,7 +205,15 @@ export function createGeocoder(env: GeocoderEnv = {}, fetchImpl: typeof fetch = // inGeoRange returns false for NaN, so it subsumes the finite check. if (!inGeoRange(lat, lng)) return null; - return { lat, lng, precision: derivePrecision(top) }; + return { + lat, + lng, + precision: derivePrecision(top), + label: top.display_name, + address: top.address, + osmType: top.osm_type, + osmId: top.osm_id + }; } }; } -- 2.51.2 From d8a0114ce63ec91d5e8e9ce52665beefb92fe634 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:52:40 -0400 Subject: [PATCH 2/3] refactor(near-me): route geocodeLocation through shared createGeocoder Near-me forward geocode now delegates to the one shared client, so it honors a configured GEOCODER_URL/GEOCODER_KEY (key appended server-side) instead of a hardcoded public-Nominatim URL. Drops the duplicate fetch/parse and the stale 'does not honor GEOCODER_URL' comment (om-qp3y). --- .../web/src/lib/search/server/geocode.test.ts | 19 +++++++-- apps/web/src/lib/search/server/geocode.ts | 41 +++++-------------- 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/apps/web/src/lib/search/server/geocode.test.ts b/apps/web/src/lib/search/server/geocode.test.ts index e9d15ea..d07a2a2 100644 --- a/apps/web/src/lib/search/server/geocode.test.ts +++ b/apps/web/src/lib/search/server/geocode.test.ts @@ -13,7 +13,7 @@ const louisville = [ ]; describe('geocodeLocation', () => { - it('queries Nominatim and returns the top result as numeric coords', async () => { + it('geocodes through the shared client and returns the top result as numeric coords', async () => { const fetchImpl = fakeFetch(200, louisville); const result = await geocodeLocation('Louisville, KY', fetchImpl); @@ -21,13 +21,12 @@ describe('geocodeLocation', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); const [input, init] = fetchImpl.mock.calls[0] as unknown as [URL | string, RequestInit]; const url = new URL(String(input)); - // URL is sourced from the shared geocoder constant, not a local literal. + // URL is sourced from the shared geocoder client, not a local literal. const shared = new URL(DEFAULT_GEOCODER_URL); expect(url.hostname).toBe(shared.hostname); expect(url.pathname).toBe(shared.pathname); expect(url.hostname).toBe('nominatim.openstreetmap.org'); expect(url.searchParams.get('q')).toBe('Louisville, KY'); - expect(url.searchParams.get('format')).toBe('jsonv2'); expect(url.searchParams.get('limit')).toBe('1'); // Nominatim's usage policy requires an identifying User-Agent; default // comes from the shared env-driven helper. @@ -52,6 +51,20 @@ describe('geocodeLocation', () => { expect(new Headers(init.headers).get('user-agent')).toBe('custom-agent/9.9 (https://example.test)'); }); + it('honors a configured GEOCODER_URL/KEY (the shared client appends the key server-side)', async () => { + const fetchImpl = fakeFetch(200, louisville); + + await geocodeLocation('Louisville, KY', fetchImpl, { + GEOCODER_URL: 'https://us1.locationiq.com/v1/search', + GEOCODER_KEY: 'tok' + }); + + const [input] = fetchImpl.mock.calls[0] as unknown as [URL | string, RequestInit]; + const url = new URL(String(input)); + expect(url.hostname).toBe('us1.locationiq.com'); + expect(url.searchParams.get('key')).toBe('tok'); + }); + it('returns null when nothing matches', async () => { expect(await geocodeLocation('zzzz no such place', fakeFetch(200, []))).toBeNull(); }); diff --git a/apps/web/src/lib/search/server/geocode.ts b/apps/web/src/lib/search/server/geocode.ts index f2599f2..e3b5e5b 100644 --- a/apps/web/src/lib/search/server/geocode.ts +++ b/apps/web/src/lib/search/server/geocode.ts @@ -7,11 +7,14 @@ // page. Heavier-traffic compliance (app-wide rate limiting, caching) is still // a follow-up before high-volume exposure. // -// Base URL and User-Agent come from the shared geocoder module (one canonical -// source, no duplicate literals). This path stays on public Nominatim — it does -// NOT honor a configured GEOCODER_URL, because that endpoint may be a keyed -// LocationIQ URL and this hot-path forward search sends no ?key= (it would 401). -import { DEFAULT_GEOCODER_URL, resolveGeocoderUserAgent, type GeocoderEnv } from './geocoder'; +// This is a thin adapter over the shared createGeocoder client — the single +// forward-geocode client for every server-side path (near-me, the address-entry +// form's /api/geocoding endpoint, and the bulk drip). It therefore honors a +// configured GEOCODER_URL/GEOCODER_KEY (keyed LocationIQ) like the drip does: +// the key is appended server-side by the shared client and never reaches the +// browser, so the earlier "don't honor GEOCODER_URL, we send no ?key=" rationale +// no longer applies. Default remains public Nominatim when no key/URL is set. +import { createGeocoder, type GeocoderEnv } from './geocoder'; export type GeocodeResult = { lat: number; @@ -25,29 +28,7 @@ export async function geocodeLocation( fetchImpl: typeof fetch = fetch, env: GeocoderEnv = {} ): Promise { - const url = new URL(DEFAULT_GEOCODER_URL); - url.searchParams.set('q', q); - url.searchParams.set('format', 'jsonv2'); - url.searchParams.set('limit', '1'); - - const response = await fetchImpl(url, { - headers: { accept: 'application/json', 'user-agent': resolveGeocoderUserAgent(env) } - }); - if (!response.ok) { - throw new Error(`geocode request failed: ${response.status}`); - } - - const results = (await response.json()) as { - lat?: string; - lon?: string; - display_name?: string; - }[]; - const top = results[0]; - if (!top) return null; - - const lat = Number(top.lat); - const lng = Number(top.lon); - if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; - - return { lat, lng, label: top.display_name ?? q }; + const point = await createGeocoder(env, fetchImpl).geocode(q); + if (!point) return null; + return { lat: point.lat, lng: point.lng, label: point.label ?? q }; } -- 2.51.2 From 9604c82d318234be038b4b5c47ba9342acb851a5 Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Thu, 2 Jul 2026 15:52:40 -0400 Subject: [PATCH 3/3] feat(api/geocoding): converge endpoint onto shared client, normalized shape /api/geocoding now calls createGeocoder (one URL + User-Agent + key handling) and returns a normalized {lat,lng,label,address,...} shape instead of the raw Nominatim object; drops the stray console.error, keeps the auth gate. Updates the two consumers (resolveGeoLocation, LocationSection) to the normalized keys (om-qp3y). --- .../src/routes/(app)/api/geocoding/+server.ts | 36 +++--- .../routes/(app)/api/geocoding/server.test.ts | 118 ++++++++++++++++++ packages/ui/src/editor/LocationSection.svelte | 2 +- packages/ui/src/event-view/format.ts | 17 +-- 4 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/routes/(app)/api/geocoding/server.test.ts diff --git a/apps/web/src/routes/(app)/api/geocoding/+server.ts b/apps/web/src/routes/(app)/api/geocoding/+server.ts index a3c56c8..4b3ac8a 100644 --- a/apps/web/src/routes/(app)/api/geocoding/+server.ts +++ b/apps/web/src/routes/(app)/api/geocoding/+server.ts @@ -1,6 +1,14 @@ import { json } from '@sveltejs/kit'; +import { createGeocoder } from '$lib/search/server/geocoder'; -export async function GET({ url, locals }) { +// Forward-geocode the address-entry form's free-text query. Server-only (auth- +// gated on the signed-in DID) and routed through the ONE shared createGeocoder +// client, so it shares the single Nominatim/LocationIQ URL, User-Agent and key +// handling with the near-me search box and the bulk drip — no local endpoint or +// UA literal here. Returns a normalized { lat, lng, label, address, ... } shape +// (never the raw upstream object): the key, when configured, is appended inside +// the Worker and never reaches the browser. +export async function GET({ url, locals, platform, fetch }) { if (!locals.did) { return json({ error: 'You must be signed in.' }, { status: 401 }); } @@ -10,23 +18,21 @@ export async function GET({ url, locals }) { return json({ error: 'No search provided' }, { status: 400 }); } - const nomUrl = - 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' + - encodeURIComponent(q); - try { - const data = await fetch(nomUrl, { - headers: { - 'User-Agent': 'atmo.rsvp/0.1 (contact: flobit.dev@gmail.com)', - Referer: 'https://atmo.rsvp' - } + const point = await createGeocoder(platform?.env ?? {}, fetch).geocode(q); + if (!point) { + return json({ error: 'No results' }, { status: 404 }); + } + return json({ + lat: point.lat, + lng: point.lng, + label: point.label ?? q, + address: point.address ?? {}, + osmType: point.osmType, + osmId: point.osmId }); - console.error(data.status, data.statusText); - const location = (await data.json()) as Array>; - - return json(location[0]); } catch (error) { - console.error('Error fetching location:', nomUrl, error); + console.error('Error fetching location:', q, error); return json({ error: 'Failed to fetch location' }, { status: 500 }); } } diff --git a/apps/web/src/routes/(app)/api/geocoding/server.test.ts b/apps/web/src/routes/(app)/api/geocoding/server.test.ts new file mode 100644 index 0000000..27c49a9 --- /dev/null +++ b/apps/web/src/routes/(app)/api/geocoding/server.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GET } from './+server'; +import { DEFAULT_GEOCODER_URL, DEFAULT_GEOCODER_USER_AGENT } from '$lib/search/server/geocoder'; + +const louisville = [ + { + lat: '38.2542', + lon: '-85.7594', + display_name: 'Louisville, Jefferson County, Kentucky, United States', + addresstype: 'city', + osm_type: 'relation', + osm_id: 207611, + address: { city: 'Louisville', state: 'Kentucky', country: 'United States' } + } +]; + +function fakeFetch(status: number, body: unknown) { + const calls: { url: string; headers: Record }[] = []; + const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + headers: Object.fromEntries(new Headers(init?.headers).entries()) + }); + return new Response(JSON.stringify(body), { status }); + }); + return { fn: fn as unknown as typeof fetch, calls }; +} + +// Minimal RequestEvent shape the handler actually reads. +function event(opts: { + q?: string | null; + did?: string | null; + fetch: typeof fetch; + env?: Record; +}) { + const url = new URL('https://atmo.rsvp/api/geocoding'); + if (opts.q != null) url.searchParams.set('q', opts.q); + return { + url, + locals: { did: opts.did ?? null }, + platform: { env: opts.env ?? {} }, + fetch: opts.fetch + } as unknown as Parameters[0]; +} + +describe('GET /api/geocoding', () => { + it('rejects an unauthenticated request (auth gate kept)', async () => { + const { fn } = fakeFetch(200, louisville); + const res = await GET(event({ q: 'Louisville', did: null, fetch: fn })); + expect(res.status).toBe(401); + expect(fn).not.toHaveBeenCalled(); + }); + + it('rejects a missing query', async () => { + const { fn } = fakeFetch(200, louisville); + const res = await GET(event({ q: null, did: 'did:plc:abc', fetch: fn })); + expect(res.status).toBe(400); + }); + + it('returns a normalized {lat,lng,label,...} shape, not the raw upstream object', async () => { + const { fn, calls } = fakeFetch(200, louisville); + const res = await GET(event({ q: 'Louisville, KY', did: 'did:plc:abc', fetch: fn })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + lat: number; + lng: number; + label: string; + address: Record; + lon?: unknown; + display_name?: unknown; + }; + + // Normalized numeric coords + label; no raw upstream `lon`/`display_name` leak. + expect(body.lat).toBe(38.2542); + expect(body.lng).toBe(-85.7594); + expect(body.label).toBe('Louisville, Jefferson County, Kentucky, United States'); + expect(body.address).toEqual({ + city: 'Louisville', + state: 'Kentucky', + country: 'United States' + }); + expect(body.lon).toBeUndefined(); + expect(body.display_name).toBeUndefined(); + + // Goes through the one shared client: shared URL + shared User-Agent. + expect(calls[0].url).toContain(DEFAULT_GEOCODER_URL); + expect(calls[0].headers['user-agent']).toBe(DEFAULT_GEOCODER_USER_AGENT); + }); + + it('appends the key server-side when GEOCODER_URL/KEY are configured', async () => { + const { fn, calls } = fakeFetch(200, louisville); + const res = await GET( + event({ + q: 'Berlin', + did: 'did:plc:abc', + fetch: fn, + env: { GEOCODER_URL: 'https://us1.locationiq.com/v1/search', GEOCODER_KEY: 'tok' } + }) + ); + expect(res.status).toBe(200); + expect(calls[0].url).toContain('https://us1.locationiq.com/v1/search'); + expect(calls[0].url).toContain('key=tok'); + }); + + it('returns 404 when nothing matches', async () => { + const { fn } = fakeFetch(200, []); + const res = await GET(event({ q: 'nowhere', did: 'did:plc:abc', fetch: fn })); + expect(res.status).toBe(404); + }); + + it('does not log to console.error on the happy path', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { fn } = fakeFetch(200, louisville); + await GET(event({ q: 'Louisville', did: 'did:plc:abc', fetch: fn })); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/packages/ui/src/editor/LocationSection.svelte b/packages/ui/src/editor/LocationSection.svelte index 005987a..7553005 100644 --- a/packages/ui/src/editor/LocationSection.svelte +++ b/packages/ui/src/editor/LocationSection.svelte @@ -39,7 +39,7 @@ const country = addr.country || ''; result = { - displayName: (data.display_name as string) || q, + displayName: (data.label as string) || q, location: { ...(street && { street }), ...(locality && { locality }), diff --git a/packages/ui/src/event-view/format.ts b/packages/ui/src/event-view/format.ts index f7aa444..75bc74d 100644 --- a/packages/ui/src/event-view/format.ts +++ b/packages/ui/src/event-view/format.ts @@ -109,17 +109,18 @@ export async function resolveGeoLocation( try { const r = await fetch(`/api/geocoding?q=${encodeURIComponent(locationData.fullAddress)}`); if (!r.ok) return null; + // /api/geocoding returns a normalized { lat, lng, label, ... } shape. const data = (await r.json()) as { - lat?: string; - lon?: string; - osm_type?: string; - osm_id?: number; + lat?: number; + lng?: number; + osmType?: string; + osmId?: number; } | null; - if (!data?.lat || !data?.lon) return null; - const lat = parseFloat(data.lat); - const lng = parseFloat(data.lon); + if (typeof data?.lat !== 'number' || typeof data?.lng !== 'number') return null; + const lat = data.lat; + const lng = data.lng; if (isNaN(lat) || isNaN(lng)) return null; - return { lat, lng, ...geoUrls(lat, lng, data.osm_type, data.osm_id) }; + return { lat, lng, ...geoUrls(lat, lng, data.osmType, data.osmId) }; } catch { return null; }