chore(geocoding): remove Pelias + close 3 bypass paths to public Nominatim

Pelias was retired from the Mac mini on 2026-04-28; photon-self
(self-hosted Photon on mana-gpu) has been the live primary since then.
This removes the now-dead Pelias adapter, config, tests, and the
services/mana-geocoding/pelias/ stack — the entire compose file, the
geojsonify_place_details.js patch, the setup.sh import script.

Provider chain is now `photon-self → photon → nominatim`. The chain
keeps its `privacy: 'local' | 'public'` split, sensitive-query
blocking, coord quantization, and aggressive caching unchanged.

Three direct calls to nominatim.openstreetmap.org that bypassed
mana-geocoding now route through the wrapper:

- citycorners/add-city + citycorners/cities/[slug]/add use the shared
  searchAddress() client (browser → same-origin proxy → mana-geocoding
  → photon-self).
- memoro mobile drops its OSM reverse-geocoding fallback entirely;
  Expo's on-device reverse-geocoding stays as the sole path. Routing
  through the wrapper would require a memoro-server proxy endpoint —
  a follow-up if Expo's quality proves insufficient.

Other behavioral changes:

- CACHE_PUBLIC_TTL_MS dropped from 7d → 1h. The long TTL was a
  privacy-amplification trick from the Pelias era; with photon-self
  serving the bulk of traffic, a transient cross-LAN blip was pinning
  cached fallback answers for days. 1h gives quick recovery.
- /health/pelias renamed to /health/photon-self; prometheus blackbox
  config + status-page generator updated.
- mana-geocoding container no longer needs `extra_hosts:
  host.docker.internal:host-gateway` (was only there for the
  Pelias-on-host-network era).

113 tests passing. CLAUDE.md rewritten to reflect the post-Pelias
architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 22:12:26 +02:00
parent 7bca16dfa7
commit 2bbcf14aba
35 changed files with 330 additions and 1262 deletions

View file

@ -1,9 +1,11 @@
/**
* Shared geocoding client for all modules in the unified Mana app.
*
* Talks to our self-hosted mana-geocoding service (Pelias-backed, port 3018).
* All queries stay within our infrastructure no user location data leaves
* the network.
* Talks to mana-geocoding (port 3018), which fronts a provider chain
* (photon-self public photon public nominatim) with sensitive-query
* blocking and coord quantization. Sensitive + happy-path queries stay
* on our infrastructure via photon-self; only last-resort fallbacks
* leave the network.
*
* Used by: places, events, contacts, photos,
*
@ -66,26 +68,24 @@ export interface GeocodingResult {
longitude: number;
address: GeocodingAddress;
category: PlaceCategory;
/** Raw Pelias categories (food, retail, transport, ) only present
* when the result came from Pelias. */
peliasCategories?: string[];
confidence: number;
/** Which backend served this result. `pelias` is local; `photon` and
* `nominatim` are public APIs (the wrapper applies sensitive-query
* blocking + coord quantization before forwarding to those). */
provider?: 'pelias' | 'photon' | 'nominatim';
/** Which backend served this result. `photon-self` is our self-hosted
* Photon (privacy: 'local'); `photon` and `nominatim` are public APIs
* (the wrapper applies sensitive-query blocking + coord quantization
* before forwarding to those). */
provider?: 'photon-self' | 'photon' | 'nominatim';
}
/**
* Out-of-band information returned alongside results the wrapper uses
* this to signal *why* a query had unusual behavior:
*
* - `'fallback_used'`: Pelias was unreachable, so a public-API provider
* served the request. Results are still valid but may be less precise.
* UI should show a subtle "approximate" badge.
* - `'fallback_used'`: photon-self was unreachable, so a public-API
* provider served the request. Results are still valid but may be
* less precise. UI should show a subtle "approximate" badge.
* - `'sensitive_local_unavailable'`: the query matched the wrapper's
* sensitive-keyword list (medical / mental-health / crisis service)
* AND the local Pelias was unreachable. The wrapper deliberately did
* AND no local provider was reachable. The wrapper deliberately did
* NOT forward the query to public APIs. Results are empty by design.
* UI should explain this to the user.
*/
@ -95,7 +95,7 @@ interface GeocodingResponse {
results: GeocodingResult[];
cached?: boolean;
error?: string;
provider?: 'pelias' | 'photon' | 'nominatim';
provider?: 'photon-self' | 'photon' | 'nominatim';
notice?: GeocodingNotice;
}
@ -109,7 +109,7 @@ interface GeocodingResponse {
*/
export interface SearchOutcome {
results: GeocodingResult[];
provider?: 'pelias' | 'photon' | 'nominatim';
provider?: 'photon-self' | 'photon' | 'nominatim';
notice?: GeocodingNotice;
}

View file

@ -6,6 +6,7 @@
import { cityTable, useAllCities } from '$lib/modules/citycorners';
import type { LocalCity } from '$lib/modules/citycorners/types';
import { RoutePage } from '$lib/components/shell';
import { searchAddress } from '$lib/geocoding';
const allCities = useAllCities();
@ -44,14 +45,10 @@
geocoding = true;
try {
const searchQ = country.trim() ? `${q}, ${country.trim()}` : q;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQ)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
const results = await searchAddress(searchQ, { limit: 1 });
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
latitude = results[0].latitude;
longitude = results[0].longitude;
}
} catch {
// best-effort

View file

@ -8,6 +8,7 @@
import { ccLocationTable, CATEGORY_KEYS } from '$lib/modules/citycorners';
import type { LocalCity, LocalLocation } from '$lib/modules/citycorners/types';
import { RoutePage } from '$lib/components/shell';
import { searchAddress } from '$lib/geocoding';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
@ -58,14 +59,10 @@
cityName && !addr.toLowerCase().includes(cityName.toLowerCase())
? `${addr}, ${cityName}`
: addr;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
const results = await searchAddress(q, { limit: 1 });
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
latitude = results[0].latitude;
longitude = results[0].longitude;
}
} catch {
// Geocoding is best-effort

View file

@ -25,8 +25,8 @@
* If we ever want to rate-limit by user we can add JWT verification here
* without touching the upstream service.
*
* Also proxies /health and /health/pelias so the SvelteKit status page
* (/status) can check the service from its server side.
* Also proxies /health and /health/photon-self so the SvelteKit status
* page (/status) can check the service from its server side.
*/
import { error } from '@sveltejs/kit';

View file

@ -155,71 +155,26 @@ export const reverseGeocodeWithExpo = async (
};
/**
* Führt ein Reverse Geocoding mit OpenStreetMap/Nominatim durch
* @param latitude Breitengrad
* @param longitude Längengrad
* @returns Adressinformationen oder null bei Fehler
*/
export const reverseGeocodeWithOSM = async (
latitude: number,
longitude: number
): Promise<AddressInfo | null> => {
try {
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&addressdetails=1`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Memoro App', // OSM erfordert einen User-Agent
},
});
if (!response.ok) {
throw new Error(`OSM API responded with status: ${response.status}`);
}
const data = await response.json();
if (data && data.address) {
return {
street: data.address.road || data.address.pedestrian || data.address.street,
streetNumber: data.address.house_number,
postalCode: data.address.postcode,
city: data.address.city || data.address.town || data.address.village,
district: data.address.suburb || data.address.neighbourhood,
region: data.address.state,
country: data.address.country,
name: data.name,
formattedAddress: data.display_name,
};
}
return null;
} catch (error) {
console.debug('Fehler beim Reverse Geocoding mit OSM:', error);
return null;
}
};
/**
* Führt ein Reverse Geocoding durch und versucht, die beste verfügbare Adresse zu ermitteln
* @param latitude Breitengrad
* @param longitude Längengrad
* @returns Adressinformationen oder null bei Fehler
* Führt ein Reverse Geocoding durch. Nutzt ausschließlich Expo's
* On-Device Reverse-Geocoding keine direkten Calls an
* nominatim.openstreetmap.org, weil das die User-IP + Coords ungeschützt
* an einen Public-Service leakt. Wenn Expo keine Adresse liefert,
* geben wir null zurück.
*
* Falls Expo's Qualität auf Dauer nicht reicht, ist der richtige Fix
* ein Proxy-Endpoint im memoro-server, der intern an mana-geocoding
* weiterreicht (Privacy-Hardening + Photon-Self).
*/
export const getAddressFromCoordinates = async (
latitude: number,
longitude: number
): Promise<AddressInfo | null> => {
try {
// Zuerst mit Expo versuchen
const expoResult = await reverseGeocodeWithExpo(latitude, longitude);
// Wenn Expo ein gutes Ergebnis liefert, dieses verwenden
if (expoResult && expoResult.street && expoResult.city) {
return expoResult;
}
// Ansonsten mit OSM versuchen
return await reverseGeocodeWithOSM(latitude, longitude);
return expoResult;
} catch (error) {
console.debug('Fehler beim Reverse Geocoding:', error);
return null;