maps/docs/ARCHITECTURE.md
2026-03-30 09:22:16 +02:00

791 lines
38 KiB
Markdown

# Architecture Document: Privacy-First Maps Application
**Version:** 1.0.0
**Date:** 2026-03-29
**Status:** Ready for development review
---
## 1. High-Level Architecture
```
+-----------------------+
| Mobile App |
| (Flutter / Dart) |
| |
| +-------+ +--------+ |
| |MapLibre| |Drift DB| |
| |GL Native| (SQLite)| |
| +-------+ +--------+ |
+-----------+-----------+
|
TLS 1.2+ only
|
+-----------v-----------+
| Rust API Gateway |
| (Actix-web) |
| |
| /tiles/* /api/search |
| /api/route /api/pois |
| /api/offline /api/health
+-+------+------+------+-+
| | | |
+------------+ +---+--+ +-+----+ +----------+
| | | | | |
+------v------+ +-----v----+ +v------v-+ +--------v--------+
| Martin | | Photon | | OSRM | | PostgreSQL |
| Tile Server | | Geocoder | | x3 | | + PostGIS |
| (Rust) | | (Java) | | profiles | | |
+------+------+ +----------+ +----------+ +-----------------+
| ^
+-------------------------------------------+
(reads tile data from PostGIS)
+-------------------+
| Redis |
| (response cache) |
+-------------------+
+-----------------------------+
| Caddy / nginx |
| (TLS termination, |
| reverse proxy) |
+-----------------------------+
```
**Data flow summary:**
1. The mobile app connects exclusively to the Rust API Gateway over TLS.
2. The gateway routes requests to the appropriate upstream service.
3. Martin reads vector tile data from PostGIS.
4. Photon provides geocoding from its own Elasticsearch-backed index (built from OSM/Nominatim).
5. OSRM provides routing from preprocessed OSM graph data (3 separate instances).
6. The gateway directly queries PostGIS for POI data.
7. Redis caches frequently accessed responses (tiles, POI queries, region metadata).
---
## 2. Component Responsibilities
### 2.1 Mobile App (Flutter)
- Renders vector map tiles via MapLibre GL Native.
- Manages on-device state: search history, favorites, offline regions.
- Handles all user interactions: gestures, search, routing, POI browsing.
- Implements offline mode using locally stored MBTiles, OSRM data, and POI SQLite databases.
- Enforces TLS-only communication; no third-party network calls.
### 2.2 Rust API Gateway (Actix-web)
- Single entry point for all mobile app requests.
- Proxies tile requests to Martin.
- Proxies search/reverse-geocoding requests to Photon.
- Proxies routing requests to the correct OSRM instance based on profile.
- Serves POI data directly by querying PostGIS.
- Serves offline data packages (tiles, routing data, POI extracts).
- Performs input validation, rate limiting, CORS, and health checks.
- Implements Redis caching layer.
- Logs requests without PII (no IPs, no query strings, no coordinates).
### 2.3 Martin (Tile Server)
- Serves Mapbox Vector Tiles (MVT) from PostGIS.
- Serves the `style.json` used by MapLibre GL to configure rendering.
- Connected directly to PostGIS; reads from `openmaptiles`-schema tables.
### 2.4 Photon (Geocoder)
- Forward geocoding: text query to coordinates + metadata.
- Reverse geocoding: coordinates to address/place.
- Stateless: does not log queries.
- Backed by Elasticsearch index built from Nominatim/OSM data.
### 2.5 OSRM (Routing Engine)
- Three separate instances, one per profile: `driving`, `walking`, `cycling`.
- Each uses a profile-specific graph preprocessed from OSM PBF data.
- Returns routes with geometry, distance, duration, and turn-by-turn steps.
- Supports alternative routes (up to 3).
### 2.6 PostgreSQL + PostGIS
- Stores OSM-imported geographic data used by Martin for tile generation.
- Stores POI data queried directly by the Rust gateway.
- Spatial indexes enable efficient bounding-box and proximity queries.
### 2.7 Redis
- Caches tile responses, POI bounding-box query results, and region metadata.
- Reduces load on PostGIS and upstream services.
---
## 3. Tech Stack Rationale
| Choice | Rationale |
|---|---|
| **Flutter** | Single codebase for Android and iOS. Strong ecosystem for maps (`maplibre_gl`). Dart compiles to native ARM, achieving 60fps rendering. Large community reduces hiring risk. |
| **MapLibre GL Native** | Open-source fork of Mapbox GL Native. No telemetry, no API key required. Supports MVT vector tiles, client-side styling, and offline MBTiles. The privacy constraint eliminates Mapbox SDK. |
| **Riverpod** | Compile-safe, testable state management. Better than `bloc` for this use case because map state is inherently reactive (viewport changes, location updates, search results). Riverpod's provider model fits naturally with dependency injection. |
| **Drift** | Type-safe SQLite wrapper for Dart. Supports migrations, DAOs, and complex queries. Better than raw `sqflite` for maintainability. Compiles queries at build time. |
| **Dio** | Full-featured HTTP client with interceptor support (for TLS enforcement, logging, caching headers). No third-party interceptors are used. |
| **Rust + Actix-web** | Memory-safe, high-performance API gateway. No garbage collection pauses. Actix-web is the fastest Rust web framework by most benchmarks. Async runtime (Tokio) handles thousands of concurrent connections efficiently. Binary deployment (no JVM, no runtime). |
| **Martin** | Written in Rust (same ecosystem as the gateway). Serves tiles directly from PostGIS with minimal configuration. Supports MBTiles and PMTiles. Actively maintained. |
| **Photon** | Purpose-built for OSM geocoding. Better OSM coverage than Nominatim's API layer. Supports proximity-biased results. Self-hostable with no external dependencies beyond its Elasticsearch index. |
| **OSRM** | Industry-standard open-source routing. Sub-second query times for continental distances. Well-documented API. Multiple profile support. Used by many OSM-based apps (known quantity). |
| **PostGIS** | The standard for geospatial databases. Mature, well-indexed spatial queries. Native support in Martin. Ecosystem of import tools (`osm2pgsql`, `imposm3`). |
| **Redis** | In-memory cache with TTL support. Simple, fast, well-understood. Reduces repeated expensive PostGIS queries. |
| **Docker Compose** | Appropriate for self-hosted single-server deployment. All services defined declaratively. Easy for the target audience (self-hosters) to deploy and manage. Kubernetes would be overkill for the typical deployment scenario. |
---
## 4. Mobile App Architecture
### 4.1 Folder Structure
```
lib/
├── main.dart # App entry point, ProviderScope
├── app.dart # MaterialApp, router config, theme
├── core/
│ ├── config/
│ │ ├── app_config.dart # Backend URL, defaults
│ │ └── env.dart # Environment-specific config
│ ├── constants/
│ │ ├── map_constants.dart # Zoom levels, default location
│ │ └── api_constants.dart # Endpoint paths, timeouts
│ ├── error/
│ │ ├── app_exception.dart # Base exception hierarchy
│ │ ├── error_handler.dart # Global error handling
│ │ └── failure.dart # Failure types for Either pattern
│ ├── network/
│ │ ├── dio_client.dart # Dio instance, TLS config
│ │ ├── api_interceptor.dart # Request/response logging (no PII)
│ │ └── connectivity.dart # Online/offline detection
│ ├── theme/
│ │ ├── app_theme.dart # Day/night Material themes
│ │ ├── map_styles.dart # MapLibre style JSON references
│ │ └── colors.dart
│ └── utils/
│ ├── debouncer.dart
│ ├── coordinate_utils.dart
│ └── opening_hours_parser.dart
├── features/
│ ├── map/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── tile_repository.dart
│ │ │ └── datasources/
│ │ │ ├── tile_remote_source.dart
│ │ │ └── tile_cache_source.dart # MBTiles SQLite
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── map_position.dart
│ │ │ │ └── map_marker.dart
│ │ │ └── repositories/
│ │ │ └── tile_repository.dart # Abstract interface
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ ├── map_controller_provider.dart
│ │ │ │ ├── location_provider.dart
│ │ │ │ └── theme_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── map_view.dart
│ │ │ │ ├── compass_button.dart
│ │ │ │ ├── zoom_controls.dart
│ │ │ │ ├── attribution_widget.dart
│ │ │ │ └── location_button.dart
│ │ │ └── screens/
│ │ │ └── map_screen.dart
│ │ └── map_providers.dart # Feature-level provider definitions
│ ├── search/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── search_repository.dart
│ │ │ ├── datasources/
│ │ │ │ ├── photon_remote_source.dart
│ │ │ │ ├── search_history_local_source.dart
│ │ │ │ └── offline_search_source.dart
│ │ │ └── models/
│ │ │ └── search_result_dto.dart
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── search_result.dart
│ │ │ │ └── search_history_item.dart
│ │ │ └── repositories/
│ │ │ └── search_repository.dart
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ ├── search_provider.dart
│ │ │ │ └── search_history_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── search_bar.dart
│ │ │ │ ├── search_result_tile.dart
│ │ │ │ └── recent_searches_list.dart
│ │ │ └── screens/
│ │ │ └── search_screen.dart
│ │ └── search_providers.dart
│ ├── routing/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── routing_repository.dart
│ │ │ ├── datasources/
│ │ │ │ ├── osrm_remote_source.dart
│ │ │ │ └── offline_routing_source.dart
│ │ │ └── models/
│ │ │ └── route_dto.dart
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── route.dart
│ │ │ │ ├── route_step.dart
│ │ │ │ ├── maneuver.dart
│ │ │ │ └── route_profile.dart
│ │ │ └── repositories/
│ │ │ └── routing_repository.dart
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ ├── routing_provider.dart
│ │ │ │ ├── navigation_provider.dart
│ │ │ │ └── reroute_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── route_summary_card.dart
│ │ │ │ ├── turn_instruction.dart
│ │ │ │ ├── profile_selector.dart
│ │ │ │ └── route_line_layer.dart
│ │ │ └── screens/
│ │ │ ├── directions_screen.dart
│ │ │ └── navigation_screen.dart
│ │ └── routing_providers.dart
│ ├── pois/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── poi_repository.dart
│ │ │ ├── datasources/
│ │ │ │ ├── poi_remote_source.dart
│ │ │ │ └── poi_local_source.dart
│ │ │ └── models/
│ │ │ └── poi_dto.dart
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── poi.dart
│ │ │ │ └── poi_category.dart
│ │ │ └── repositories/
│ │ │ └── poi_repository.dart
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ └── poi_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── poi_marker_layer.dart
│ │ │ │ ├── place_card.dart
│ │ │ │ └── opening_hours_display.dart
│ │ │ └── screens/
│ │ │ └── poi_detail_screen.dart
│ │ └── poi_providers.dart
│ ├── favorites/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── favorites_repository.dart
│ │ │ └── datasources/
│ │ │ └── favorites_local_source.dart
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── favorite.dart
│ │ │ │ └── favorite_group.dart
│ │ │ └── repositories/
│ │ │ └── favorites_repository.dart
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ └── favorites_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── favorite_list_tile.dart
│ │ │ │ └── save_favorite_dialog.dart
│ │ │ └── screens/
│ │ │ └── favorites_screen.dart
│ │ └── favorites_providers.dart
│ ├── offline/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ └── offline_repository.dart
│ │ │ └── datasources/
│ │ │ ├── offline_remote_source.dart
│ │ │ └── offline_local_source.dart
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ │ ├── offline_region.dart
│ │ │ │ └── download_progress.dart
│ │ │ └── repositories/
│ │ │ └── offline_repository.dart
│ │ ├── presentation/
│ │ │ ├── providers/
│ │ │ │ ├── offline_regions_provider.dart
│ │ │ │ └── download_manager_provider.dart
│ │ │ ├── widgets/
│ │ │ │ ├── region_list_tile.dart
│ │ │ │ ├── download_progress_bar.dart
│ │ │ │ └── region_selector_map.dart
│ │ │ └── screens/
│ │ │ └── offline_maps_screen.dart
│ │ └── offline_providers.dart
│ ├── sharing/
│ │ └── presentation/
│ │ ├── providers/
│ │ │ └── share_provider.dart
│ │ └── widgets/
│ │ └── share_sheet.dart
│ └── settings/
│ ├── presentation/
│ │ ├── providers/
│ │ │ └── settings_provider.dart
│ │ └── screens/
│ │ ├── settings_screen.dart
│ │ └── about_screen.dart
│ └── settings_providers.dart
├── database/
│ ├── app_database.dart # Drift database definition
│ ├── tables/
│ │ ├── search_history_table.dart
│ │ ├── favorites_table.dart
│ │ ├── favorite_groups_table.dart
│ │ └── offline_regions_table.dart
│ └── daos/
│ ├── search_history_dao.dart
│ ├── favorites_dao.dart
│ └── offline_regions_dao.dart
└── router/
└── app_router.dart # GoRouter or declarative routing
```
### 4.2 State Management (Riverpod)
The app uses Riverpod with the following provider hierarchy:
```
Core Providers (app-wide):
├── dioClientProvider → Dio instance (singleton)
├── databaseProvider → Drift AppDatabase (singleton)
├── connectivityProvider → Stream<bool> online/offline
├── locationProvider → Stream<Position> from platform
└── settingsProvider → User preferences (theme, units, cache size)
Feature Providers (scoped per feature):
├── Map:
│ ├── mapControllerProvider → MapLibre controller
│ ├── mapPositionProvider → Current viewport (center, zoom, bearing)
│ └── themeProvider → Active map style (day/night/terrain)
├── Search:
│ ├── searchQueryProvider → StateProvider<String>
│ ├── searchResultsProvider → FutureProvider (debounced, calls repository)
│ └── searchHistoryProvider → StreamProvider (from Drift DAO)
├── Routing:
│ ├── routeRequestProvider → StateProvider<RouteRequest?>
│ ├── routeResultsProvider → FutureProvider (calls OSRM)
│ ├── selectedRouteProvider → StateProvider<int> (selected alternative index)
│ └── navigationStateProvider → StateNotifierProvider (active navigation)
├── POIs:
│ ├── visiblePoisProvider → FutureProvider (bbox-based, auto-refreshes on viewport change)
│ └── selectedPoiProvider → StateProvider<Poi?>
├── Favorites:
│ ├── favoritesListProvider → StreamProvider (from Drift DAO)
│ └── favoriteGroupsProvider → StreamProvider
└── Offline:
├── availableRegionsProvider → FutureProvider (from backend)
├── downloadedRegionsProvider → StreamProvider (from Drift DAO)
└── activeDownloadsProvider → StateNotifierProvider (download manager)
```
**Key patterns:**
- `FutureProvider` for one-shot async data fetches.
- `StreamProvider` for reactive local data (Drift streams).
- `StateNotifierProvider` for complex mutable state (navigation, downloads).
- `Provider` for computed/derived values.
- Feature providers use `ref.watch` to react to upstream changes (e.g., `visiblePoisProvider` watches `mapPositionProvider`).
### 4.3 Navigation
Use `go_router` for declarative navigation:
```
/ → MapScreen (root)
/search → SearchScreen (overlay on map)
/directions → DirectionsScreen (origin/destination input)
/directions/navigate → NavigationScreen (active turn-by-turn)
/poi/:osmType/:osmId → POI detail (bottom sheet on map)
/favorites → FavoritesScreen
/settings → SettingsScreen
/settings/offline → OfflineMapsScreen
/settings/about → AboutScreen
```
The map remains persistent underneath all routes using a `ShellRoute` with the map as the shell. Search, POI details, and directions overlay the map as bottom sheets or panels.
### 4.4 Dependency Injection
Riverpod serves as the DI container. All dependencies are defined as providers:
```dart
// Core
final dioClientProvider = Provider<Dio>((ref) => createDioClient(ref));
final databaseProvider = Provider<AppDatabase>((ref) => AppDatabase());
// Repositories (depend on data sources)
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
return SearchRepositoryImpl(
remote: ref.watch(photonRemoteSourceProvider),
local: ref.watch(searchHistoryLocalSourceProvider),
offline: ref.watch(offlineSearchSourceProvider),
connectivity: ref.watch(connectivityProvider),
);
});
```
This makes testing straightforward: override any provider in tests with a mock.
---
## 5. Backend Architecture (Rust / Actix-web)
### 5.1 Project Structure
```
backend/
├── Cargo.toml
├── src/
│ ├── main.rs # Server bootstrap, service wiring
│ ├── config.rs # Configuration from env vars
│ ├── routes/
│ │ ├── mod.rs
│ │ ├── tiles.rs # GET /tiles/{layer}/{z}/{x}/{y}.pbf
│ │ ├── search.rs # GET /api/search, GET /api/reverse
│ │ ├── routing.rs # GET /api/route/{profile}/{coordinates}
│ │ ├── pois.rs # GET /api/pois, GET /api/pois/{type}/{id}
│ │ ├── offline.rs # GET /api/offline/regions, downloads
│ │ └── health.rs # GET /api/health
│ ├── services/
│ │ ├── mod.rs
│ │ ├── martin_proxy.rs # HTTP proxy to Martin
│ │ ├── photon_proxy.rs # HTTP proxy to Photon
│ │ ├── osrm_proxy.rs # HTTP proxy to OSRM instances
│ │ ├── poi_service.rs # PostGIS POI queries
│ │ ├── offline_service.rs # Region package management
│ │ └── health_service.rs # Upstream health checks
│ ├── middleware/
│ │ ├── mod.rs
│ │ ├── cors.rs # CORS configuration
│ │ ├── request_logger.rs # Structured logging (no PII)
│ │ ├── rate_limiter.rs # Token bucket per IP (IP not logged)
│ │ └── input_validator.rs # Coordinate range checks, string sanitization
│ ├── cache/
│ │ ├── mod.rs
│ │ └── redis_cache.rs # Redis get/set with TTL
│ ├── models/
│ │ ├── mod.rs
│ │ ├── poi.rs # POI structs, GeoJSON serialization
│ │ ├── region.rs # Offline region metadata
│ │ └── error.rs # Error types and API error response
│ └── db/
│ ├── mod.rs
│ └── postgres.rs # Connection pool (deadpool-postgres), spatial queries
├── migrations/
│ └── 001_create_pois.sql
└── tests/
├── integration/
└── common/
```
### 5.2 Route Configuration
```rust
// Simplified Actix-web route configuration
App::new()
.wrap(middleware::cors())
.wrap(middleware::request_logger())
.wrap(middleware::rate_limiter())
// Tiles (proxied to Martin)
.route("/tiles/{layer}/{z}/{x}/{y}.pbf", web::get().to(tiles::get_tile))
.route("/tiles/style.json", web::get().to(tiles::get_style))
// Search (proxied to Photon)
.route("/api/search", web::get().to(search::search))
.route("/api/reverse", web::get().to(search::reverse))
// Routing (proxied to OSRM)
.route("/api/route/{profile}/{coordinates}", web::get().to(routing::route))
// POIs (direct PostGIS queries)
.route("/api/pois", web::get().to(pois::list_pois))
.route("/api/pois/{osm_type}/{osm_id}", web::get().to(pois::get_poi))
// Offline
.route("/api/offline/regions", web::get().to(offline::list_regions))
.route("/api/offline/regions/{id}/{component}", web::get().to(offline::download_component))
// Health
.route("/api/health", web::get().to(health::check))
```
### 5.3 Middleware
**CORS:**
- Allow all origins (the mobile app's origin varies by deployment).
- Allow methods: `GET`, `HEAD`, `OPTIONS`.
- Allow headers: `Content-Type`, `Accept`, `Accept-Encoding`, `Range`.
- `Access-Control-Max-Age: 86400`.
**Request Logger:**
- Logs: HTTP method, path (without query string), response status, response time in ms.
- Does NOT log: query parameters, client IP, request body, `User-Agent`, cookies.
- Uses `tracing` crate with structured JSON output.
- Log format: `{"method":"GET","path":"/api/pois","status":200,"duration_ms":42}`
**Rate Limiter:**
- Token bucket algorithm, keyed by client IP (IP used only for bucket lookup, never logged or stored).
- Default: 100 requests/second per IP, burst of 200.
- Tile requests: 500 requests/second (tiles are cheap to serve from cache).
- Returns `429 Too Many Requests` with `Retry-After` header when exceeded.
**Input Validator:**
- Validates coordinate ranges: latitude [-90, 90], longitude [-180, 180].
- Validates zoom levels: [0, 18].
- Validates `limit` parameters: enforces maximum values.
- Sanitizes string inputs: max length 500 characters for search queries, strip control characters.
- Returns `400 Bad Request` with error details on validation failure.
### 5.4 Health Checks
The `/api/health` endpoint actively probes each upstream service:
| Service | Check | Timeout |
|---|---|---|
| Martin | `GET /health` on Martin's internal port | 2s |
| Photon | `GET /api/search?q=test&limit=1` | 3s |
| OSRM (driving) | `GET /route/v1/driving/0,0;0.001,0.001?overview=false` | 3s |
| OSRM (walking) | Same pattern | 3s |
| OSRM (cycling) | Same pattern | 3s |
| PostgreSQL | `SELECT 1` | 2s |
If any service is down, the endpoint still returns `200 OK` but with that service's status as `"degraded"` or `"down"`. This allows the mobile app to show degraded-mode indicators.
---
## 6. Deployment Architecture (Docker Compose)
### 6.1 Topology
```yaml
# docker-compose.yml (structural overview)
services:
gateway: # Rust API gateway port 8080 (internal)
martin: # Tile server port 3000 (internal)
photon: # Geocoder port 2322 (internal)
osrm-driving: # OSRM driving profile port 5001 (internal)
osrm-walking: # OSRM walking profile port 5002 (internal)
osrm-cycling: # OSRM cycling profile port 5003 (internal)
postgres: # PostgreSQL + PostGIS port 5432 (internal)
redis: # Cache port 6379 (internal)
caddy: # Reverse proxy + TLS ports 80, 443 (external)
```
### 6.2 Networking
- All services are on a single Docker bridge network (`maps_net`).
- Only `caddy` exposes ports to the host (80 for HTTPS redirect, 443 for TLS).
- The gateway communicates with upstream services by Docker DNS names (`martin:3000`, `photon:2322`, etc.).
- No service except `caddy` is reachable from outside the Docker network.
### 6.3 Volumes
| Volume | Service | Purpose |
|---|---|---|
| `pg_data` | postgres | PostgreSQL data directory (persistent) |
| `martin_config` | martin | Martin configuration file |
| `photon_data` | photon | Photon/Elasticsearch index (persistent, ~2-20 GB depending on coverage) |
| `osrm_data` | osrm-* | Preprocessed OSRM graph files (persistent, per profile) |
| `redis_data` | redis | Redis AOF/RDB persistence (optional, cache can be cold-started) |
| `offline_packages` | gateway | Pre-built offline region packages served to mobile clients |
| `caddy_data` | caddy | TLS certificates (Let's Encrypt auto-provisioned) |
| `caddy_config` | caddy | Caddy configuration |
### 6.4 Resource Allocation (for Netherlands-scale deployment)
| Service | CPU Limit | Memory Limit |
|---|---|---|
| gateway | 1 core | 256 MB |
| martin | 1 core | 512 MB |
| photon | 2 cores | 2 GB |
| osrm-driving | 1 core | 1.5 GB |
| osrm-walking | 0.5 core | 1 GB |
| osrm-cycling | 0.5 core | 1 GB |
| postgres | 2 cores | 2 GB |
| redis | 0.5 core | 256 MB |
| caddy | 0.5 core | 128 MB |
| **Total** | **~9 cores** | **~8.5 GB** |
---
## 7. Offline Architecture
### 7.1 Overview
Offline support has three pillars, each with its own storage format and data pipeline:
```
+-------------------------------------------+
| Mobile Device Storage |
| |
| +-------------+ +-----------+ +-------+ |
| | MBTiles | | OSRM | | POI | |
| | (tiles.db) | | .osrm.* | | .db | |
| | SQLite | | files | | SQLite| |
| +-------------+ +-----------+ +-------+ |
+-------------------------------------------+
```
### 7.2 Offline Tiles
- Format: MBTiles (SQLite database with tile data as blobs).
- The backend pre-generates MBTiles files per region using Martin's `mbtiles` tool or `tilelive`.
- Zoom levels 0-16 are included (level 17-18 excluded to reduce size).
- On the device, MapLibre GL Native is configured with a composite tile source:
1. First, check the offline MBTiles database for the requested tile.
2. If not found, check the LRU tile cache (also MBTiles format).
3. If not found and online, fetch from the backend and store in cache.
### 7.3 Offline Routing
- OSRM data files (`.osrm`, `.osrm.cell_metrics`, `.osrm.partition`, etc.) are downloaded per profile per region.
- On the device, routing is performed using a compiled OSRM library accessed via Dart FFI (Foreign Function Interface) bindings to the OSRM C++ library.
- The FFI binding exposes a minimal interface: `route(profile_data_path, coordinates) -> RouteResult`.
- Fallback: if FFI integration proves too complex for v1.0, the app can use pre-calculated route graphs in a simplified Dart-native implementation (Dijkstra on a simplified road graph stored in SQLite). This is a degraded experience but ensures offline routing works.
### 7.4 Offline Search
- POI data is downloaded as a SQLite database per region, containing the same schema as the PostGIS `pois` table.
- Offline search uses SQLite FTS5 (full-text search) on the `name` and `address` columns.
- Results are ranked by FTS5 relevance score, with proximity to the viewport center as a tiebreaker (using the Haversine formula in SQL).
### 7.5 Download Manager
- Downloads are managed by a background isolate (Dart isolate) to prevent blocking the UI.
- Each region download consists of multiple sequential component downloads (tiles, routing x3, POIs).
- Supports HTTP `Range` headers for pause/resume.
- Progress is tracked per component and aggregated per region.
- Downloads persist across app restarts by storing state in Drift (download URL, bytes received, total bytes).
---
## 8. Privacy Architecture
Each privacy commitment from the spec is enforced as follows:
| Commitment | Technical Enforcement |
|---|---|
| **No accounts** | No authentication middleware on backend. No session tokens, cookies, or `Authorization` headers. No user table in the database. |
| **No telemetry** | CI pipeline runs `dart pub deps --json` and rejects any dependency matching a deny-list (Firebase, Sentry, Amplitude, etc.). Static analysis scans compiled binary for known analytics domain strings. |
| **No third-party network calls** | Dio base URL is the single configured backend URL. Dio interceptor rejects any request not matching the base URL prefix. CI step: `strings` on the compiled APK/IPA, grep for known third-party domains — fail if any found. |
| **On-device history** | Search history, favorites, and offline region metadata are stored in Drift (SQLite). No provider or repository ever sends this data over the network. Code review enforced. |
| **Self-hosted backend** | Docker Compose includes all services. No SaaS API keys in configuration. Backend makes zero outbound network calls (all data is local). |
| **Auditable** | Open-source. CI publishes dependency tree. Network monitor-friendly (single backend domain). |
| **No PII logging (backend)** | `request_logger` middleware logs only: method, path (without query params), status, duration. The `tracing` subscriber is configured to redact any field named `ip`, `query`, `user_agent`, `coordinates`. |
| **On-device encryption** | Android: database files stored in app-internal storage (encrypted by default on Android 10+; on Android 8-9, use `EncryptedSharedPreferences` for the SQLite encryption key with `SQLCipher`). iOS: files stored with `NSFileProtectionComplete` attribute (encrypted until first unlock). |
| **TLS only** | Dio configured with `baseUrl` starting with `https://`. A custom `SecurityContext` rejects plaintext. On the backend, Caddy enforces HTTPS and redirects HTTP to HTTPS. HSTS header set. |
| **No device fingerprinting** | No code reads IMEI, advertising ID, MAC address, or serial number. CI lint rule: any import of `device_info_plus`, `android_id`, or similar packages fails the build. |
---
## 9. Error Handling Strategy
### 9.1 Mobile App
**Network errors:**
- Connection timeout / no connectivity: switch to offline mode automatically. Show "Offline mode" banner. Serve tiles from cache, search from local DB, routing from local OSRM data (if available).
- HTTP 429 (rate limited): retry after `Retry-After` duration with exponential backoff (max 3 retries).
- HTTP 5xx: show a non-intrusive snackbar ("Service temporarily unavailable"). Retry with exponential backoff.
- HTTP 404 (tile): render blank tile area, do not retry.
**Service-specific degradation:**
| Service Down | User Experience |
|---|---|
| Martin (tiles) | Map shows cached tiles only. Uncached areas are blank. Banner: "Some map areas unavailable." |
| Photon (search) | Search falls back to offline POI database (if downloaded). Otherwise: "Search unavailable. Try offline maps." |
| OSRM (routing) | Routing falls back to offline OSRM data (if downloaded). Otherwise: "Routing unavailable." |
| PostGIS (POIs) | POI markers not shown. POI detail returns "Details unavailable." |
| Entire backend | Full offline mode. All features work if region is downloaded. Otherwise: cached tiles only, no search, no routing. |
**Local errors:**
- SQLite corruption: detect on open, offer to clear cache (favorites are backed up separately).
- Disk full: warn user before download starts (check 80% threshold). If cache is full, LRU eviction runs automatically.
### 9.2 Backend API
All error responses use a standard format:
```json
{
"error": {
"code": "INVALID_BBOX",
"message": "Bounding box coordinates are out of valid range."
}
}
```
Error code mapping:
| HTTP Status | Code | When |
|---|---|---|
| 400 | `INVALID_PARAMETER` | Query parameter fails validation |
| 400 | `INVALID_BBOX` | Bounding box coordinates out of range |
| 400 | `INVALID_COORDINATES` | Route coordinates out of range |
| 400 | `MISSING_PARAMETER` | Required parameter missing |
| 404 | `NOT_FOUND` | POI, tile, or region not found |
| 429 | `RATE_LIMITED` | Client exceeded rate limit |
| 502 | `UPSTREAM_ERROR` | Martin/Photon/OSRM returned an error |
| 503 | `SERVICE_UNAVAILABLE` | Upstream service is down |
| 500 | `INTERNAL_ERROR` | Unexpected server error |
The gateway never exposes internal error details (stack traces, database errors) to the client.
---
## 10. Security
### 10.1 TLS
- Caddy auto-provisions TLS certificates via Let's Encrypt (ACME).
- Minimum TLS version: 1.2. Preferred: 1.3.
- HSTS header: `Strict-Transport-Security: max-age=63072000; includeSubDomains`.
- HTTP requests to port 80 are 301-redirected to HTTPS.
- Internal Docker network communication is plaintext (acceptable: all services are on the same host, in the same Docker network, not exposed externally).
### 10.2 No Authentication (by design)
- The backend has no authentication mechanism. This is intentional.
- The backend is meant to be deployed on a private network or behind a VPN/firewall.
- If public exposure is needed, the deployer can add HTTP Basic Auth or client certificates at the Caddy layer. This is documented but not built into the application.
### 10.3 Input Validation
All user-supplied input is validated at the gateway before proxying:
| Input | Validation |
|---|---|
| Zoom level (`z`) | Integer, [0, 18] |
| Tile coordinates (`x`, `y`) | Integer, >= 0, <= 2^z - 1 |
| Latitude | Float, [-90.0, 90.0] |
| Longitude | Float, [-180.0, 180.0] |
| Bounding box | 4 floats, valid lat/lon, min < max |
| Search query (`q`) | String, max 500 chars, control chars stripped |
| `limit` | Integer, [1, max_for_endpoint] |
| Routing profile | Enum: `driving`, `walking`, `cycling` |
| Coordinates (routing) | 2-7 coordinate pairs (origin + up to 5 waypoints + destination) |
| OSM type | Enum: `N`, `W`, `R` |
| OSM ID | Positive integer |
| `lang` | 2-char ISO 639-1, validated against allow-list |
### 10.4 Rate Limiting
- Applied per source IP using an in-memory token bucket (not stored in Redis to avoid logging IPs).
- Default limits (configurable via environment variables):
- Tile requests: 500/s per IP, burst 1000.
- API requests: 100/s per IP, burst 200.
- Returns `429` with `Retry-After` header.
- The rate limiter state is ephemeral (lost on restart), which is acceptable.
### 10.5 Dependency Security
- Backend: `cargo audit` runs in CI to detect known vulnerabilities in Rust dependencies.
- Mobile: `dart pub outdated` and manual review of transitive dependencies.
- No native dependencies beyond MapLibre GL Native and (optionally) OSRM FFI.
- Docker images use minimal base images (`rust:slim` for build, `debian:bookworm-slim` for runtime).
### 10.6 Content Security
- Martin tiles are binary protobuf; no injection risk.
- Photon and OSRM responses are validated JSON. The gateway re-serializes POI responses from PostGIS to prevent SQL injection artifacts from reaching the client.
- The gateway uses parameterized queries (via `tokio-postgres` / `sqlx`) for all PostGIS access. No string interpolation in SQL.