ambek .planning

This commit is contained in:
araharah-executor
2026-10-23 22:37:55 +08:00
parent e512104d5e
commit 275879a18b
54 changed files with 9921 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
# Architecture Research
**Domain:** Telegram-first GPS navigation (bot + Mini App) on OpenStreetMap data
**Researched:** 2026-10-22
**Confidence:** HIGH (core architecture verified against official Telegram Bot API / Mini App docs and grammY hosting docs; two platform-behavior gaps flagged LOW)
---
## TL;DR Verdict on the IDEAS.md Stack
IDEAS.md's stack (Neon Postgres + PostGIS, RabbitMQ, Prometheus/Grafana) is an **enterprise batch-processing stack, not a v1 navigation stack**. Challenge accepted and rejected for v1:
| IDEAS.md Assumption | Verdict for v1 | Replacement |
|---------------------|----------------|-------------|
| Neon (Postgres + PostGIS) | **Not needed** | Cloudflare KV with <24h TTL for guidance sessions; on-device Telegram `DeviceStorage` for user history (opt-in, stored on *their* phone, not ours) |
| RabbitMQ | **Not needed at any scale below 100k DAU** | Workers request/response is already async-from-Telegram's-view; queue becomes relevant only for report fan-out (v2) |
| OSRM "with dynamic traffic weighting" | **Keep OSRM, drop traffic weighting** | Plain OSRM/Valhalla routing; traffic weighting requires probe data you don't have and won't have under opt-in-only privacy |
| Prometheus + Grafana | **Not needed** | Cloudflare's built-in Worker analytics + structured logs |
| Maptiler tiles | Keep (cheap, good neutral style) or swap to self-hosted style over OSM raster/vector | — |
The resulting v1 backend is **one Cloudflare Worker + KV + zero databases**. The guidance state machine lives in the client. This is not a compromise — it is the correct shape for a privacy-first product: the least-persisted-data architecture is also the cheapest and simplest one.
---
## Standard Architecture
### System Overview
```
┌──────────────────────────── TELEGRAM CLIENT (user's phone) ────────────────────────────┐
│ Telegram app: bot chat (commands, buttons, turn-cards) ⟷ Mini App WebView │
└───────────────┬──────────────────────────────────────────────┬────────────────────────┘
│ Bot API webhooks (updates) │ HTTPS (initData, location, tile/render data)
┌───────────────▼──────────────────────────┐ ┌───────────────▼────────────────────────┐
│ BOT/API WORKER (Cloudflare Workers) │ │ MINI APP (Cloudflare Pages, static │
│ single Worker, two route trees: │ │ SPA, no runtime backend) │
│ │ │ │
│ /telegram/webhook ← Telegram POST │ │ Plane 1: map + route render (MapLibre │
│ ├─ command router (/start /go ...) │ │ or Mapbox GL-style SDK, Rams-styled) │
│ ├─ callback_query handler (results) │ │ Plane 2: guidance HUD (state machine │
│ └─ message handler (free-text search) │ │ runs HERE in the client) │
│ │ │ │
│ /api/* ← Mini App fetches (Hono-style) │ │ Auth: initData → session token │
│ ├─ POST /api/session (init) │ │ State: route object held in client │
│ ├─ POST /api/search (geocode) │ │ memory + DeviceStorage (opt-in) │
│ ├─ POST /api/route (route + │ │ │
│ │ re-route) │ └────────────────────────────────────────┘
│ └─ POST /api/guidance/heartbeat │
│ (TTL touch + chat turn-cards) │
│ │
│ KV namespaces: │
│ - chat_sessions (search flow state) │
│ - guidance_sessions (short TTL) │
└──────┬───────────────┬───────────────────┘
│ outbound │ outbound
┌──────▼──────┐ ┌─────▼─────────────┐ ┌──────────────────────────┐
│ GEOCODE │ │ ROUTE SERVICE │ │ TILER (Maptiler / │
│ Photon │ │ OSRM (self-hosted │ │ neutral OSM style) │
│ (komoot.io) │ │ or hosted) — │ │ fetched via Worker │
│ public API │ │ /route interface │ │ proxy or direct w/ │
│ behind │ │ swappable │ │ public token │
│ Worker proxy│ └───────────────────┘ └──────────────────────────┘
└─────────────┘
All API keys stay server-side. Zero databases, zero queues in v1.
```
### Component Responsibilities
| Component | Responsibility | Typical Implementation |
|-----------|----------------|------------------------|
| **Bot/API Worker** | Everything server-side: Telegram webhook intake, command router, search orchestration, route/departure API for the Mini App, chat turn-card pushing, secret-key custody | One Cloudflare Worker, grammY webserver mode + Hono-style framework for `/api/*` on the same `fetch` handler. Secrets via Worker bindings |
| **chat_sessions (KV)** | Multi-turn bot conversation state (pending search results for callback buttons) | grammY sessions plugin with Cloudflare KV storage adapter; minutes-scale TTL |
| **guidance_sessions (KV)** | Ephemeral record of an active drive: user id, chat id, destination label, ETA snapshot. Auto-expires (~2h TTL). **The only "database" in v1** | Verbatim KV JSON writes, from heartbeat endpoint |
| **Mini App (Pages)** | All rendering, all guidance state: route progress, off-route detection, recalculation triggers, HUD | Static SPA (Vite + TS + Tailwind v4 real build), deployed to Cloudflare Pages; map SDK with custom Rams style layer |
| **Telegram Mini App runtime** | Platform glue: auth payload, native geolocation, theme, back button, fullscreen | Official `telegram-web-app.js` script; `WebApp.initData`, `LocationManager` (Bot API 8.0+), `DeviceStorage` (9.0+) |
| **Geocoder boundary** | "Turn 'KLCC' into a lat/lng + display name in KL metro" | `Geocoder.search(query)` / `.reverse()`. Start: Photon public API (OSM-based, typo-tolerant, free, no key) behind the Worker. Interface lets you bolt on a curated landmark index later |
| **Router boundary** | "Turn (origin, destination) into geometry + steps + duration" | `Router.route(points)` → GeoJSON + steps list. Start: OSRM (self-hosted on a small VPS, Malaysia+SG extract; demo server is explicitly non-production). Valhalla is the drop-in alternative |
| **Tiles** | Map raster/vector rendering | Maptiler neutral/mono style via CDN key, or self-hosted style. Fetched from the Mini App (browser) directly or via Worker proxy for the key |
### Why One Worker, Not a Separate Bot Backend + API Backend
Two forces push the same way:
1. **Telegram webhooks must hit a public HTTPS endpoint.** Cloudflare Workers is the canonical, zero-cost host for this (grammY has first-class, version-managed docs for Workers hosting). Long-running server VMs add ops burden for zero benefit — webhook handlers are sub-second request/response.
2. **The Mini App needs a server for exactly four things** (session init, geocode proxy, route proxy, heartbeat). Those are the *same* functions the bot flow uses (search, route). Splitting bots and API into two services duplicates the geocoder/router boundaries and splits secret custody.
One Worker serves `/telegram/webhook` and `/api/*` from the same `fetch()`. Total backend surface: ~1 Worker + 2 KV namespaces.
> **Noted but not recommended:** Telegram's own new **Bot API Serverless** (June 2026) lets Mini Apps call bot endpoints with platform-validated initData — it eliminates the session-init step entirely. It is very new (LOW confidence, ecosystem maturity unknown) and locks you to Telegram's hosting. Worth watching; Workers is the proven path today.
---
## Recommended Project Structure
Monorepo, two deployables, one shared token package:
```
araharah/
├── apps/
│ ├── worker/ # Cloudflare Worker: bot + API
│ │ ├── src/
│ │ │ ├── index.ts # fetch handler: route /telegram/* and /api/* trees
│ │ │ ├── bot/
│ │ │ │ ├── commands.ts # /start /go /cancel command grammar
│ │ │ │ ├── search.ts # free-text search → inline keyboard results
│ │ │ │ └── cards.ts # turn-card message composition (chat side)
│ │ │ ├── api/
│ │ │ │ ├── session.ts # POST /api/session — initData validation → token
│ │ │ │ ├── search.ts # POST /api/search — geocoder proxy (KL-biased)
│ │ │ │ ├── route.ts # POST /api/route — router proxy + re-route
│ │ │ │ └── heartbeat.ts# POST /api/guidance/heartbeat — TTL + cards
│ │ │ └── geo/ # Geocoder / Router client implementations
│ │ ├── wrangler.toml
│ │ └── bindings (secrets): TELEGRAM_BOT_TOKEN, GEOCODER_KEY?, ROUTER_URL...
│ └── web/ # Mini App (Vite SPA → Cloudflare Pages)
│ ├── src/
│ │ ├── main.ts
│ │ ├── telegram.ts # telegram-web-app.js wrapper: init, theme, geolocation
│ │ ├── map/ # map SDK init, Rams style layer, route layer
│ │ ├── guidance/ # state machine: idle → routing → guiding → reroute
│ │ ├── ui/ # real components (see design-system section)
│ │ └── api.ts # typed client for /api/*, session token attach
│ └── dist/ → Cloudflare Pages
├── packages/
│ └── design-tokens/ # Telemetry Rams tokens as source of truth
│ ├── src/tokens.js # generated from telemetry_rams/DESIGN.md YAML
│ ├── tailwind.preset.js # shared Tailwind v4 preset
│ └── fonts/ # self-hosted Space Grotesk + Inter/Geist subsets
└── stitch_minimalist_route_navigator/ # mockups stay as spec/reference, never imported
```
**Structure rationale:**
- **Single token package** is the design-fidelity insurance the project flagged as its top risk. Both the map style and the UI consume the same generated values; the mockup HTML files are demoted to *visual acceptance criteria*, not code to copy.
- **Worker as one deployable** keeps secret custody trivial and lets the bot command `/go` and the Mini App's `/api/search` call the identical `geo/` module.
- **Mini App has no build-time dependency on Telegram SDK semantics** — it's a plain SPA whose `telegram.ts` is the only file that touches `window.Telegram`, so the app is testable in a plain browser (critical for fast iteration on a desktop without Telegram).
---
## Architectural Patterns
### Pattern 1: Webhook + Secret Path (bot transport on Workers)
**What:** Telegram POSTs every update to `https://<worker>/telegram/webhook/<SECRET_PATH>`. The secret-path segment doubles as webhook authentication (Telegram validates possession of the URL; we validate the path before parsing).
**When to use:** Always for Workers-hosted bots; long-polling is only for local dev.
**Trade-off:** Requests must be idempotent-ish and fast (Workers CPU limits are per-request; geocoding fan-out is fine — it's queued fetches, not blocking work).
```typescript
// apps/worker/src/index.ts (shape)
export default {
async fetch(req, env, ctx): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === `/telegram/webhook/${env.WEBHOOK_SECRET}`) {
const update = await req.json();
await handleBotUpdate(update, env); // grammY webhookCallback handles this
return new Response("ok");
}
if (url.pathname.startsWith("/api/")) return handleApi(req, env);
return new Response("not found", { status: 404 });
},
};
```
Register once at deploy: `setWebhook?url=...&secret_token=<same secret>` — grammY's official Workers hosting tutorial covers this verbatim (HIGH confidence, grammy.dev/hosting/cloudflare-workers).
### Pattern 2: initData → Session Token (auth boundary)
**What:** The Mini App never sends raw `initData` with every request. On open it POSTs `initData` once to `/api/session`; the Worker validates the HMAC signature, then mints a random opaque token stored in KV with the user's identity and a short TTL. Subsequent `/api/*` calls carry the token header.
**When to use:** Always — this is the standard hardening pattern for Mini App backends.
**Why not raw initData every request:** Validation is cheap but replayable; a session token (timer, short TTL, random) gives you revocation and keeps hot-path requests stateless to verify (KV get).
**Verified mechanics (official docs, HIGH confidence):**
```typescript
async function validateInitData(initData: string, botToken: string): Promise<WebAppUser> {
const params = new URLSearchParams(initData);
const hash = params.get("hash")!;
params.delete("hash");
const dataCheckString = [...params.entries()]
.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join("\n");
const secret = await hmac("WebAppData", botToken); // HMAC_SHA256(key, msg)
const calc = await hmacHex(secret, dataCheckString);
if (calc !== hash) throw new Error("bad initData signature");
// also enforce auth_date freshness (e.g. < 24h) here
return JSON.parse(params.get("user")!);
}
```
### Pattern 3: Destination Context via `start_param` (bot → Mini App handoff)
**What:** The bot resolves a destination (search flow → inline keyboard → user picks), then launches the Mini App with a **direct link carrying the destination bundle**:
`https://t.me/<botusername>/<appname>?startapp=<payload>&mode=compact…`
The Mini App receives it in field `start_param` and GET param `tgWebAppStartParam` (official docs, HIGH confidence). Payload = compact, URL-safe, signed blob or a KV id, e.g. `d=kv:dest_ab12` where KV holds `{name:"KLCC", lat, lng}` under minutes-TTL.
**Why KV-id over inline coords:** IDs keep the URL short (start_param length practical limits) and let the bot compose richer payloads (route options, language) later without format churn. Inline base64 coords also work for v1 if you want fewer moving parts — acceptable sub-choice.
```
Bot: /go klcc
→ Worker geocodes (Photon, KL-bias)
→ Worker sends message with inline keyboard [KLCC ▸ Open in araharah]
→ tapping opens startapp=d:dest_ab12
Mini App: reads start_param → POST /api/session (initData) → GET dest from KV
→ user's position via LocationManager → POST /api/route → render → drive
```
**Chat id extraction:** For private chats the Mini App's `initData.user.id` *is* the chat id (private chat id == user id). This is what lets the API Worker push turn-cards to the same chat with plain `sendMessage`, no stored relationship table needed.
### Pattern 4: Client-Authoritative Guidance (stateless server, stateful client)
**What:** During a drive, the *Mini App owns the guidance state machine*:
```
idle → route_computed → guiding ⇄ rerouting → guiding … → arrived
│ │
(off-route detect) └← POST /api/route (fresh route from current pos)
```
- The server never tracks progress. The **heartbeat endpoint exists only to (a) keep the guidance KV session alive and (b) send chat turn-cards while the app is foregrounded-but-minimized** — it receives `{token, pos?: {lat,lng,speed,course}, stepIndex, eta}` and does not respond with guidance state.
- **Off-route detection is local:** distance-to-polyline check against the cached route geometry (e.g., >50 m off-route for >10 s). On deviation → re-route request with current position. **Recalculation on missed turn is therefore a client decision**, not server-enforced — no session registration protocol, no server push needed to drive the UI.
- **Why this beats server-driven guidance** (server tracks user position and pushes each turn like Google does): with the Mini App foregrounded (the primary UX), the client has fresher position, lower latency, and zero coupling to webhook/route latency. And it means **nothing position-sensitive needs server persistence** — the privacy stance falls out of the architecture for free.
**Storage split (privacy by construction):**
| Data | Where | Lifetime |
|------|-------|----------|
| Route geometry + steps | Client memory (+ `sessionStorage` tab) | Until arrival/close |
| Destination history | Client `Telegram.WebApp.DeviceStorage` (Bot API 9.0+, per-user, 5 MB cap) | User-controlled, "recent destinations" — opt-in |
| Guidance session (who's driving where) | KV, `guidance_sessions`, TTL ~2h | Auto-purged |
| Chat search state | KV, `chat_sessions`, minutes TTL | Auto-purged |
| Anything longer | Does not exist in v1 | — |
### Pattern 5: Tailwind Preset + Component Extraction (design-system implementation plan)
**What:** Convert `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md` (the YAML token front matter + component rules) into a *build-time* design system, and treat the four `code.html` mockups as visual regression targets rather than code.
Concrete steps:
1. **Tokens first.** Script-convert the YAML front matter into `packages/design-tokens/src/tokens.js` (single source). Write a Tailwind v4 preset that maps: five brand colors + surface ramp + chassis-subtle, spacing scale, 8px grid, `0px` radius globally (`border-radius: 0` as a base layer), typography classes (`display-lg`…`telemetry-num`), uppercase label utilities (`label-*` + `letter-spacing` rules).
2. **Fonts self-hosted.** Space Grotesk + Inter (or Geist) as woff2 subsets in the token package — the mockups load fonts/Tailwind from CDN, which is fine for mockups but a real app must not (offline-driving use case makes runtime CDN dependency a real failure mode, not just a best-practice nit).
3. **Component set (small on purpose).** From the mockups: `ManeuverStrip` (inverted HUD chassis), `TelemetryCard` (bordered panel + header plate + `SEC-XX` badge), `PrimaryButton`/`SecondaryButton` (2px solid border, invert-on-press — spec says *instant inversion*, not transitions), `StatusChip` (orange/red/olive variants), `SearchField` (2px border, non-blinking block caret), `ToggleSwitch` (dual-box `[ OFF | ON ]`).
4. **Acceptance = screenshot diff vs mockups.** For each screen (`active_route_guidance`, `navigate_map_overview`), render the real component code side-by-side with `screen.png`/`code.html` in a preview page; eyeball register-band structure (top/center/lower) and token fidelity. Automate only lightly (e.g., Storybook-like preview + screenshot checklist) — the mockups are the contract.
5. **Map chrome is styled, the basemap is calmed.** The spec's "grayscale map grounding" is achieved with the map SDK's style layer: desaturate/gray the OSM basemap tiles so route orange and hazard red pop. This lives in `apps/web/src/map/style.ts`, consuming the same token package.
**Note:** The mockups' `tailwind.config` inline CDN setup already encodes the token palette — port it, don't import it. Tailwind CDN in production is explicitly not supported and breaks the offline/perf goals.
---
## Data Flow
### Flow 1: Search (bot plane)
```
User: "/go klcc"
→ Telegram webhook → Worker.bot
→ Geocoder.search("klcc", bias=KL) → 3 results
→ KV: chat_sessions[user] = {results, stage:"pick"} (minutes TTL)
→ sendMessage with inline keyboard
User taps result
→ callback_query → Worker reads stored results
→ sendMessage: [OPEN IN ARAHAHARAH] → startapp=d:dest_id
```
### Flow 2: Mini App boot → route display
```
Mini App loads (Pages) → telegram-web-app.js ready()
→ reads start_param + initData
→ POST /api/session {initData} → validated → {token}
→ fetch dest from KV (via /api/route or dedicated handoff)
→ LocationManager.init() → getLocation() (Bot API 8.0+ native:
gives course & speed — nav-critical fields navigator.geolocation lacks)
→ POST /api/route {origin, dest} → geometry + maneuvers
→ MapLibre renders route in signal-orange over grayed basemap
→ confirm → guidance state machine starts ticking on client
```
### Flow 3: Drive (client plane + thin server plane)
```
Every ~3s (foreground):
client advances progress using local geometry (map-match by closest step)
→ off-route? → POST /api/route reroute → swap route, keep dest
→ POST /api/guidance/heartbeat {token, pos?, stepIndex, eta}
→ Worker: touch TTL, and IF user minimized the app, push
next-maneuver turn-card to chat via sendMessage (projection
from last known pos + speed — see Pitfall 3, LOW confidence UX)
User closes/minimizes:
WebApp.deactivated event → client stops streaming
→ server continues best-effort turn-cards from last known state
→ user re-opens → client resumes local state from memory, reconciles
```
### Flow 4: Turn-cards (bot plane, output-only)
```
Worker.heartbeat → compose "NEXT: Jalan Ampang ▸ 400 m" card
→ Bot API sendMessage(chat_id = initData.user.id)
Frontend never blocks on a response for rendering; cards are a Telegram
chat mirror of what the HUD already knows.
```
---
## The Last Major Question: Why Not a Database at All
Explicit position: **v1 storage = KV only.** Reasoning per data class:
1. **Navigation is ephemeral.** A drive session lives ~30 minutes. KV with TTL is *more* correct than Postgres (auto-expiry is the privacy feature; no cron jobs deleting "old users").
2. **No relational joins exist in v1.** The only "queries" are get-by-id (session token, callback result dest). Postgres solves problems you don't have; KV is the native id-keyed store of the platform you've already committed to.
3. **PostGIS exists for spatial queries** — nearest-report-radius, incident clustering, heatmap zones. All of those are **report-phase (v2) features that are explicitly out of scope.** Add Neon + PostGIS when the first spatial query actually gets written, not before.
4. **RabbitMQ has no producer.** Queues shine when work outlives the request (fan-out, retries, long jobs). Every v1 operation completes within the request. Telegram's own retry-on-webhook-failure is the only queue behavior needed.
5. **When to add what (honest triggers):** first durable user setting → keep KV but raise TTL; first report → Postgres (Neon) with PostGIS; report fan-out to groups → Queues/RabbitMQ. None of these fire in v1.
User-visible history ("recent destinations") is deliberately **on-device** via `DeviceStorage` — server never sees it. This is stronger privacy than storing it anonymized in our DB, per the project's core promise.
---
## Scaling Considerations
| Scale | Architecture Adjustments |
|-------|--------------------------|
| 0–1k users (v1 target) | As drawn. One Worker, KV, external Photon/OSRM. Cost ≈ $0–20/mo |
| 1k–100k | First bottleneck is **the self-hosted OSRM box** (CPU-bound routing) — scale it vertically or add a second instance + Worker round-robin. Photon public API needs replacing with your own Photon instance (their public server has no SLA). Everything else stays |
| 100k+ | KV fine (add D1/Postgres if report features land). Consider Durable Objects only if you add *server-authoritative* live features (group tracking, live traffic fusion). Regions via Telegram groups (v2 idea) would need this |
### Scaling Priorities
1. **First bottleneck: routing engine hardware** (and geocoder SLA), not Cloudflare. Cloudflare Workers + KV scale essentially free of your attention.
2. **Second: map tile spend** — Maptiler per-load pricing vs self-hosted tiles/style. Decide at the tile-billing e-mail, not before.
---
## Anti-Patterns
### Anti-Pattern 1: Server-driven turn-by-turn
**What people do:** Model the system like Google/OSAnd: server ingests position stream, tracks progress server-side, pushes every maneuver.
**Why it's wrong here:** Mini App JS reliably pauses when minimized (platform truth: `isActive` false, WebView throttled/suspended on iOS). A server-driven loop then has no eyes, and you'd build session-restore complexity for a client that already has the freshest state. Guidance while backgrounded is best done as *projection* (below), not as ingestion.
**Do this instead:** Client-authoritative guidance (Pattern 4); server handles only chat-card pushes and TTL.
### Anti-Pattern 2: Trusting the Mini App client
**What people do:** Read `initDataUnsafe` or accept `/api/*` calls with a `user_id` body parameter and store a session based on it.
**Why it's wrong:** It's attacker-controlled data. The entire auth model of Mini Apps is HMAC-validated `initData` (official docs warn twice, verbatim).
**Do this instead:** Pattern 2, validate on server, never trust client payloads for identity.
### Anti-Pattern 3: Promising real-time turn-cards while backgrounded
**What people do:** Commit to "Telegram push notifications of the exact next turn when the app is closed."
**Why it's wrong:** Once the Mini App is minimized, the client stops sending positions (JS paused). The server can only *project* from the last known position+speed+route — accurate enough for a nudge ("you're nearing the turn"), wrong as an agreement with the user.
**Do this instead:** Frame chat cards as best-effort reminders; the guidance contract lives in the foreground HUD. This is a product-scope framing, not just an implementation choice — worth an explicit PROJECT.md note so the phase doesn't overreach.
### Anti-Pattern 4: Building the persistence layer "just in case"
**What people do:** Stand up Neon+PostGIS on day one "because IDEAS.md says so," then round-trip every session init through it.
**Why it's wrong:** Misaligns the architecture with the privacy brand (most-persisted-data by default), adds a paid dependency and a schema-lifecycle burden for data with 30-minute lifetimes.
**Do this instead:** KV + TTL. Postgres enters with reports (v2), where PostGIS earns its keep.
### Anti-Pattern 5: Using free public geocoding/routing directly from the browser
**What people do:** Mini App calls `photon.komoot.io` and the OSRM *demo* server directly.
**Why it's wrong:** (a) No key custody, no rate-limit management, no failover; (b) the OSRM project's public demo server is explicitly for demonstration/testing and forbids production use (MEDIUM confidence: long-standing published stance — verify wording at setup); (c) you can't bias results consistently toward KL metro from the client.
**Do this instead:** Both behind `Geocoder`/`Router` boundaries in the Worker; demo server allowed *only* in local dev with a config flag.
---
## Integration Points
### External Services
| Service | Integration Pattern | Notes / Gotchas |
|---------|---------------------|-----------------|
| **Telegram Bot API** | grammY (Node) in webserver mode inside the Worker; webhook with secret path + secret_token | `sendMessage` from /api/heartbeat needs chat_id from initData; `answerWebAppQuery`'s `query_id` is single-use — useless for repeated turn-cards, don't design around it |
| **Telegram WebApp JS API** | `telegram-web-app.js` in `<head>` before everything | `LocationManager` requires `init()` before use and Bot API 8.0+ clients (version-gate with `isVersionAtLeast`, fall back to `navigator.geolocation`); `start_param` may be empty when app opened from menu button — code the "no destination" boot state; call `ready()` early; use `expand()`, `requestFullscreen()`, safe-area insets for the HUD |
| **Photon (initial) / Nominatim** | HTTP JSON via Worker proxy | Photon public API is free & typo-tolerant (good for "klcc"-style POI input, MEDIUM-HIGH); add `osm_tag`/bbox limits to bias to Malaysia; expect to self-host Photon or add a curated KL landmark table when quality is insufficient — Nominatim public instance: ≤1 req/s, strict policy |
| **OSRM (initial) / Valhalla (alt)** | HTTP via Worker proxy | Self-host on a small VPS (Malaysia-Singapore extract from Geofabrik is modest); `/route/v1/driving/...?steps=true&geometries=polyline`; `alternatives=true` for the route-choice screen. Demo server dev-only |
| **Maptiler (or OSM raster)** | Map SDK on Mini App loads tiles | Neutral/mono style customization is the job here — match spec's grayscale grounding; tile key can stay client-side (it's read-only) but route overlay styling lives in map/style.ts |
| **Cloudflare KV** | 2 namespaces as bindings | Minutes-hour TTLs only; never store position history |
### Internal Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| Mini App ↔ Worker | HTTPS REST, JSON, session-token header | The *only* app-side API contract; version it (`/api/v1/`) because Mini App updates roll out asynchronously |
| Bot module ↔ geo module | Direct TS function calls, same Worker process | No service split; the boundary is a TypeScript interface, refactoring later is free |
| Worker ↔ KV | Bindings, id-keyed get/put | Two namespaces by TTL class, not by domain |
| Map SDK ↔ design tokens | Style layer generated from token package | Keeps "grayscale grounding" consistent with UI palette |
| Mockups ↔ real UI | **Reference only** — screenshot comparison, never code import | Guard against `code.html` copy-paste drift; mockups stay frozen as artifacts |
---
## Build Order (Suggested Phase Skeleton)
Dependencies flow strictly downward; each phase produces something destable/end-to-end verifiable.
```
P0 Design tokens + component library (no external deps; unblocks everything visual)
└→ P1 Bot skeleton on Workers (webhook + /start + KV session + /go stub)
└→ P2 Mini App shell (initData session flow + LocationManager +
│ map render of self; works with no destination)
└→ P3 Geocoding service + search (bot search flow AND /api/search share module;
│ KL quality verdict lands here)
└→ P4 Routing service + route display (full search→route→render loop;
│ the "product exists" milestone)
└→ P5 Guidance (client state machine, off-route reroute,
│ heartbeat + chat turn-cards)
└→ P6 Hardening (re-route edge cases, TT exit, empty-boot
states, version gating, paste-up verification)
```
**Order rationale:**
- **P0 before everything:** design fidelity is the flagged top risk; tokens are zero-risk work that every later phase consumes. Building the UI first and tokens later is the single most likely way to lose the Rams look.
- **P1 before P2:** the bot *is* how users reach the Mini App; the `startapp` handshake needs a real bot to test against.
- **P2 before P3:** session/auth + geolocation plumbing is prerequisite to any geo service consumption; also the riskiest *platform-integration* phase (WebView quirks deserve procurement early, not after routing works).
- **P3 P4 order matters:** you cannot show a screen users care about until search resolves something; search quality (KL landmarks) is the data-correctness experiment with the most unknowns — surface it before the routing plumbing.
- **P5 last:** guidance builds on every prior piece, and it's the phase with the most *behavioral* unknowns (reroute triggers, background-card semantics) — schedule the slack there.
- **P6 exists as a phase, not as "later":** the empty/error/permission states (`start_param` missing, geolocation denied, OSRM timeout, off the road with no geometry) are the actual polish surface of a navigation tool.
---
## Sources
- **Telegram Mini Apps — official docs** (core.telegram.org/bots/webapps): launch modes (`startapp`/`start_param`/`tgWebAppStartParam`), `initData`/`initDataUnsafe` trust warnings, HMAC validation recipe, `LocationManager` + `LocationData` (course/speed), `DeviceStorage`/`SecureStorage`, fullscreen + safe-area APIs, `isActive` semantics — read 2026-10-22 (HIGH confidence, page current to Bot API 10.1, June 2026)
- **grammY official hosting docs** — Cloudflare Workers (Node & Deno variants) webhook setup (HIGH)
- **grammY + Cloudflare Workers + Durable Objects community walkthroughs** (flashblaze.xyz, Feb 2026; Hono+grammY patterns) — supporting evidence that KV/DO state plugins are mature (MEDIUM)
- **Photon** — komoot/photon GitHub, OSM wiki, chringel.dev notes on free public API (MEDIUM-HIGH); Photon 1.0 release note (nominatim.org, Feb 2026) for direction of travel
- **Nominatim public-instance usage policy** (≤1 req/s, attribution, no bulk) — long-standing published policy (MEDIUM, re-verify at setup)
- **OSRM demo server non-production stance** — widely documented community stance + project docs; verify exact wording when configuring (MEDIUM)
- **Training-data architecture patterns for Workers-hosted bots** — supporting, flagged LOW where not doc-verified (notably: Bot API "Serverless" object, new June 2026 — LOW confidence, watch-only)
**Two flagged gaps for phase-specific research:**
1. **Background turn-card acceptability** — what "good enough" means for minimized-app hints (Pitfall 3). Needs a product decision + small live experiment in the guidance phase.
2. **KL geocoding quality of Photon vs alternatives** (Nominatim self-host, Maptiler geocoding, Foursquare/OSM blends) — this is a *data* experiment, not an architecture question; P3 must budget a bake-off.
---
*Architecture research for: Telegram-first GPS navigation (araharah) — KL metro v1*
*Researched: 2026-10-22*
+349
View File
@@ -0,0 +1,349 @@
# Pitfalls Research
**Domain:** Telegram-first GPS navigation (bot + Mini App + OSM routing), KL metro v1
**Researched:** 2026-10-22
**Confidence:** HIGH for security/platform pitfalls (official Telegram/Cloudflare/OSMF sources), MEDIUM for navigation-engine UX behavior (community + known engineering practice), MEDIUM for Malaysia-specific OSM data quality.
> Phase names used below assume a v1 roadmap roughly shaped like:
> P1 Design-system foundation → P2 Bot + destination search → P3 Mini App shell + location → P4 Route display → P5 Live turn-by-turn guidance → P6 Hybrid chat guidance → P7 Hardening/private launch. If the actual phase list differs, the "phase to address" still reads naturally by role.
---
## Critical Pitfalls
### Pitfall 1: Trusting initDataUnsafe or validating initData in the wrong place
**What goes wrong:**
The Mini App receives `initData` (a Telegram-signed payload containing user id, auth_date, etc.). The most common and most fatal Mini App security error is treating anything arriving from the client as proof of identity: using `initDataUnsafe`, accepting a plain `user_id` request parameter, or "validating" the hash client-side. Result: anyone can POST `{"user_id": <anyone>}` and navigate-as-another-user, and a captured `initData` string works forever because `auth_date` freshness was never checked.
**Why it happens:**
`initDataUnsafe` is named for convenience, not as a warning label. Telegram's docs state plainly that validation must happen on the bot's server, but client-side dev flows make the unsafe path feel like it works.
**How to avoid:**
- Send the **raw** `initData` string from the Mini App to the backend (a Cloudflare Worker/Pages Function); never parse it client-side for auth.
- On the server: build the data-check string from all key–value pairs except `hash`, sorted alphabetically, joined with `\n` (URL-decode values; watch for escape characters inside values like `photo_url` — a classic implementation bug). Derive the secret key as `HMAC-SHA256(bot_token, "WebAppData")` — the key is **not** the raw bot token (a recurring mistake). Compute `HMAC-SHA256(data_check_string, secret_key)` and compare to `hash` in constant time.
- Reject payloads older than a short window (e.g. 5–10 min) by `auth_date`; exchange the validated payload **once** for your own session token and authenticate all later Mini App↔backend calls with that token.
- Never send the bot token to the client. If third-party verification is ever needed, the newer Ed25519 signature path exists (signatures are base64url **without padding** — many languages need `=` padding restored before decoding).
- Unit-test the validator with vectors from Telegram's docs plus tampered/stale payloads before anything else ships.
**Warning signs:**
Any auth code path reachable via curl with a hand-rolled JSON body; a "TODO validate" comment; an endpoint that accepts user_id as a plain parameter.
**Phase to address:** P1–P2 (build the validation utility as the first backend artifact, before any Mini App page consumes identity).
---
### Pitfall 2: Geolocation that dies silently inside the Telegram WebView
**What goes wrong:**
`navigator.geolocation` inside a third-party WebView can fail **without ever showing a permission prompt** — the error callback fires with code 1 ("User denied Geolocation") even though the user denied nothing. This is exactly what happens when the host app's WebView does not implement the geolocation permission bridge (`onGeolocationPermissionsShowPrompt` on Android). Telegram ships several clients (iOS, Android, Telegram Desktop) that differ in how — or whether — they pass the prompt and position through. The worst outcome is silent: no prompt, no position, no useful message. A navigation app built against one client's behavior can be non-functional on another.
**Why it happens:**
Mini Apps run inside Telegram's own WebView, not Chrome; the developer controls nothing about the permission bridge. Developers test in Chrome/Safari first — where the prompt works — and build the entire guidance loop on a primitive that may be dead in-device.
**How to avoid:**
- Prefer the **WebApp LocationManager** API (Bot API 8.0+: `LocationManager.init()`, access-state check, `requestLocation`) as the primary path — it exposes a location-access state and uses Telegram's own permission plumbing. Keep `navigator.geolocation` as per-client fallback.
- Build a **client test matrix from day one**: Android Telegram, iOS Telegram, Desktop, plain browser. Test permission *grant*, *deny*, *re-ask after deny* (denials may persist with no retry path), and *no-fix scenarios* (no signal, stale fix, accuracy 100m+ — normal in Malaysian high-rise canyons).
- The very first P3 vertical slice is "read position inside the Mini App on a real phone in Telegram" — nothing else is built until that works across the matrix, with a graceful denied-state screen (re-enable instructions + manual "set origin on map" fallback).
**Warning signs:**
"Works in browser dev; fires PERMISSION_DENIED on device with no prompt ever shown." Devs testing only in Chrome. No denied-permission screen in the design.
**Phase to address:** P3 — multi-client location permission is the exit criterion of the location slice.
---
### Pitfall 3: Turn-by-turn complexity underestimated: snapping jitter, recalculation storms, ETA whiplash, battery drain
**What goes wrong (four linked failure modes naive implementations all hit):**
1. **Snapping/jitter:** raw GPS fixes jitter ±5–30m (worse in urban canyons and multi-level roads common in KL — parallel frontage/service roads, flyovers). The puck teleports between the real road and adjacent/parallel/underneath roads; happens *because position is displayed raw*.
2. **Recalculation storms:** recalculating the full OSRM route on every off-route ping (a jitter burst easily reads as off-route) creates request storms, flickering routes — and route *oscillation*: the recalculated route confuses position matching, which recalculates again.
3. **ETA whiplash:** every recalc rewrites remaining time, so the ETA visibly jumps around — the canonical "half-built navigator" smell.
4. **Battery drain:** a continuous high-accuracy `watchPosition` at tight intervals keeps the radio awake and burns battery within minutes; combined with no screen-wake control in a webview, users get "drains my phone" reviews.
**Why it happens:**
"Position → route progress → next turn" looks trivial in demos; real navigators have a dedicated location/snapping pipeline and an explicit navigation state machine that tutorial projects never show. Web examples (Leaflet + routing plugin) draw routes, not vehicle guidance.
**How to avoid:**
- Treat guidance as an explicit **state machine**: `en-route → approaching-maneuver → maneuvering → off-route → rerouting → arrived`, with hysteresis: minimum move distance (or ≥20–30m) and heading correlation with the route before "off-route" fires, plus N consecutive bad fixes.
- **Snap the position to the route polyline** (project onto active route geometry), not to the nearest road-graph edge — enough for v1, no map-matching infra needed. Maneuver distance/time always computed from snapped progress, not raw fix distance.
- **Debounce rerouting:** max one OSRM call per off-route event, cool-down (e.g. ≥5–8s), collapsing pending re-requests. Also protects the Cloudflare subrequest/CPU budgets (Pitfall 8).
- **Smooth user-visible ETA:** only surface a recalc if the change is meaningful; average speed over a window, not instantaneous GPS speed.
- Throttle position updates adaptively (≈1s while moving, 3–5s standstill); Wake Lock only during active navigation, released otherwise.
- Test with **recorded drive replays** (JSON position logs played through the state machine in CI) + a real phone mounted in a car. Golden-route replays double as the Malaysia-data regression net (Pitfall 7).
**Warning signs:**
Route fetch inside a `watchPosition` callback with no debounce/cooldown; ETA updates every tick; distance-to-turn from raw fix distance; >10% battery/10min in internal QA.
**Phase to address:** P5 (live guidance) builds it correctly; flag in P4 so the route model already carries step geometry + snapped-progress support from day one.
---
### Pitfall 4: The Telegram platform bridges are just… different: localStorage, 100vh viewport, back button
**What goes wrong (three recurring traps):**
1. **Storage:** `localStorage` inside Telegram's WebView **may be wiped** on iOS and some desktop builds; the official Telegram client storage (`CloudStorage`) exists precisely because in-webview persistence is unreliable. Session tokens cached in `localStorage` vanish → spontaneous logouts after restart.
2. **Viewport / `100vh`:** CSS `100vh` ≠ the Telegram viewport (compact/expanded states, header overlay, keyboard), and the keyboard changes `viewportHeight` while `viewportStableHeight` exists exactly for that. Layouts sized off `window.innerHeight` or `100vh` jump when the keyboard opens and slide under the Telegram header.
3. **Back button:** a custom in-page back arrow competes with Telegram's **BackButton** (Bot API 6.1+) and Android hardware back; a BackButton click with no handler closes the whole Mini App back to the chat; iOS vertical-swipe can close the app unless `disableVerticalSwipes` is called; `enableClosingConfirmation` can prevent accidental closure during navigation (but is annoying while browsing).
**Why it happens:**
Developers build a normal web app, add the `telegram-web-app.js` script tag, and meet the platform afterward.
**How to avoid:**
- Session token → `WebApp.CloudStorage` (or an HTTP-only cookie on a same-site backend); `localStorage` only for throwaway UI state.
- Shell tracks Telegram viewport: `WebApp.ready()` + `WebApp.expand()` on mount; use the viewport/`viewportStableHeight` values (or bind the SDK's CSS variables); never `100vh` for the app shell; test with the search keyboard open.
- Bind Telegram's BackButton to the router (show/hide on route change, `onClick` → router back), keep **one** navigation stack, call `disableVerticalSwipes` (Bot API 7.7+) so map interactions don't close the app, and enable closing confirmation only during active navigation.
- `WebApp.isVersionAtLeast()` / feature detection before using any advanced API; graceful fallbacks for old clients.
- Test in-device (all four clients from Pitfall 2), not just the browser.
**Warning signs:**
Content sliding under the Telegram header; keyboard shoving the route list; BackButton click dumps users to the chat mid-navigation; sessions vanishing on iOS restarts.
**Phase to address:** P3 (Mini App shell) — the shell must be Telegram-aware from the first commit or every screen inherits the debt.
---
### Pitfall 5: Nominatim as the production geocoder — rate limits and local-landmark weakness
**What goes wrong:**
Two-part failure. (1) Nominatim's official usage policy allows **at most 1 request/second** with "no heavy use" — a per-user search into the public instance violates the policy and gets 429/blocked (and the block looks like an app bug). (2) The queries users actually type are the weakest cases: "KLCC" (abbreviation for a **complex**: Kuala Lumpur City Centre *and* Suria KLCC), mall names ("Pavilion", "Sunway Pyramid"), subdistrict/kampung names, Malay road abbreviations ("Jln", highway codes like "DUKE", "SKVE", "Sprint"). Public-Nominatim ranking for local landmark phrasing is mediocre without a deliberate alias/index layer. Result: the flagship "search a destination" requirement — a validated core promise — fails on the marquee KL queries it was chosen for.
**Why it happens:**
Nominatim is the default "free OSM geocoder" and its local-phrasing weakness isn't obvious until someone types "KLCC" and gets something wrong — no feedback loop yet.
**How to avoid:**
- Build a small **curated landmark/alias index** for KL first: top ~200 destinations (malls, landmarks, transit hubs, attractions) with OSM ids + abbreviation/alias expansion ("KLCC" → Suria KLCC / KLCC Park / KLCC LRT station — user picks). Tiny static dataset (JSON in the bundle or KV) — makes flagship queries natively excellent regardless of geocoder choice, with zero privacy cost.
- Route fallback searches to an **OSM geocoder you can scale**: Photon (komoot) is the standard Nominatim successor (better autocomplete; self-host on a Malaysia extract) or a managed OSM provider (Geoapify, MapTiler geocoding — license-compliant). Nominatim public = dev/prototype only.
- If any public geocoding is used at all: cache aggressively (results are near-immutable per query), rate-limit client-side (debounce ≥300ms, min 3 chars), honor policy identification (User-Agent/email metadata).
- Reverse geocoding (origin label) is low-frequency but cache by geohash/rounded coords.
**Warning signs:**
"KLCC"-style queries failing or passing only via hardcoded synonyms; any per-keystroke synchronous Nominatim call path; 429s in logs; search latency >1s in-device.
**Phase to address:** P2 (destination search) — the alias index and geocoder choice are that phase's substance, not polish.
---
### Pitfall 6: OSRM demo-server dependency + traffic weighting assumed free
**What goes wrong:**
Developing against `router.project-osrm.org` and never fixing the dependency: the OSRM project itself says the demo server is **NOT SUITABLE FOR PRODUCTION USE** (e.g. leaflet-routing-machine's error text relayed from the demo policy). It also runs only the car profile on a periodically-updated planet extract: no uptime/existence guarantee, arbitrary policy throttling, and **no traffic weighting** — the traffic-update feature needs nightly upstream traffic files and specific ingestion infrastructure that no public server offers. So "we'll add live-ish ETA later" silently implies standing up a data pipeline. Another classic: not switching to a private instance *before* the launch — being the app where "routing is down" on demo-day.
**Why it happens:**
The demo API is identical to a self-hosted one, so the swap feels like "just change the URL" — until the routing requirements (profile tuning, extrusion from a Malaysia extract, traffic files) reveal a real deployment/task chain.
**How to avoid:**
- Stand up **your own tiny OSRM** when route display first ships: Geofabrik Malaysia-Singapore-Brunei .pbf extract (~50–100MB) → `osrm-extract` → `osrm-contract` → `osrm-routed`, comfortably within a small VPS/Fly/GCP VM (few GB RAM). The Cloudflare Worker proxies it (respecting Pitfall 8 budgets — this is actually a clean way to stay within subrequest/CPU limits).
- Design a **route-provider interface** in code (getRoute(from,to,profile) → geometry+steps+duration) so demo-OSRM vs self-hosted is a config swap, never a rewrite; traffic weighting later slots in without touching the client.
- Don't promise traffic-aware ETA in v1: default OSM speeds via OSRM duration-prediction is what you get; ship honest "typical, indicative" ETAs and defer the traffic feature entirely (trigger condition: enough opt-in telemetry, or third-party traffic data chosen — both later milestones with their own research).
- Cache route responses keyed on (from, to, profile) briefly (best-effort freshness) both to spare the OSRM instance and to keep the Cloudflare invocation fast.
**Warning signs:**
`router.project-osrm.org` in any production configuration; no route-timeouts/error handling; no test for the case "demo server slow/absent"; no deployment plan for the OSRM instance anywhere in the roadmap.
**Phase to address:** P4 (route display) — *before* guidance makes route calls latency-sensitive and chatty (P5).
---
### Pitfall 7: Malaysia/OSM data quality gaps — the app's is only as good as its map data
**What goes wrong:**
OSM Malaysia data has known weak zones relative to commercial maps: sparse lane-level geometry (`turn:lanes` tags largely absent on Malaysian roads), inconsistent multi-level/flyover modeling (interchanges where ground and elevated links appear to intersect), inconsistent Malay road naming/aliases (Jalan vs Jln; Lebuhraya vs federal expressway codes like DUKE/SPRINT/SKVE/SILK/KESAS), sparser building/POI coverage in fast-growing outer corridors, and incomplete address/portal/POI detail outside central KL. Each ingredient failure surfaces as a navigation bug: wrong-side/off-by-exit maneuvers, puck positioned on the wrong parallel link, destination snapping to a block centroid instead of the entrance, "turn left in 20m then left in 10m" instruction fragments from data artifacts, and search results not finding POIs that a local knows exist.
**Why it happens:**
The correctness contract of a navigator ("gets me there, tells me the right turn") depends entirely on the least-good map ingredient; contributors map what they see, not necessarily what a driver needs (turn-lane modeling is obscure and tedious; OSM tooling makes it optional).
**How to avoid:**
- Set the expectation explicitly in the roadmap: v1 quality bar = **"correct on KL main corridors, known limitations in lane-level and interchange micro-detail"** — not Google-equivalent. Manage this in copy design and docs, not by lying to users.
- Define a small **golden-route test set** (10–20 real KL driving pairs: KL Sentral → Mid Valley, Duta–Sungai Buloh, LDP/Penchala interchange, one Ampang KLC-style labyrinth, one Bangsar tangle) and replay them as regression tests through the guidance engine (doubles as Pitfall 3's replay corpus).
- Heuristics to normalize Malay road naming and highway-code abbreviations in search input (part of the alias index of Pitfall 5 — make this explicit work in the search phase, not an "if time permits").
- For routing quality bugs, capture **data-vs-code bug categories** (bad one-way? missing lane info? geometry wrong? OSM data missed a flyover/underpass?) — file to OSM-community (added-value via our app; the eventual reports feature becomes this pipeline, but is out of v1 scope per PROJECT.md). Route-level UX behaviors should not depend on lane geometry in v1 (no lane-decision colors); stick to maneuver-level guidance.
- Don't promise street/lane-level precision externally in v1; in fact omit any claim of "lane guidance" from copy — managing user expectation honestly is its own v1 feature ("less, but better").
**Warning signs:**
Golden-route tests failing on route *shape* only (data, not code); maneuver list contains nonsense micro-steps; no navigation QA on real KL corridors; product copy claims precision unmet by data.
**Phase to address:** P4 (golden routes) and P5 (guidance restraint on data-poor routes); bug-category capture from first QA session; OSM report loop explicitly excluded from v1 (and planned in the reports milestone).
---
### Pitfall 8: Cloudflare Pages/Workers limits mistaken for "we don't need websockets so we're fine"
**What goes wrong:**
Pages Functions are Workers, and Workers have quotas per invocation and per plan: **free plan = 10ms CPU per invocation, 50 subrequests per invocation, 100k requests/day** (paid: 10k subrequests, same daily CPU shape; Pages static assets serve unlimited requests). Per-user backend calls are small (binary initData→session, geocode-proxy, route-proxy, chat turn-cards). But it's easy to silently blow a single invocation's 50-subrequest ceiling by a fan-out handler (e.g. one "plan navigation" endpoint doing: alias lookup → geocoder → OSRM → map static asset) or by calling routing/geocoding per keystroke/pan, or by firing subrequest-heavy jobs post-hoc (during dev only — production plan's 10ms CPU simply isn't enough for heavy parsing/matching/blocking compute, causing silent dropped requests). The common mistake is not realizing Cloudflare *does* provide almost-free hosting — the mistake is architecting phases to require patterns (per-frame subrequests, in-Worker geo computation) that Workers is not built for.
**Why it happens:**
Pages is described as "free hosting"; the per-invocation subrequest/CPU model applies to Functions and is documented separately. With no background workers ("websockets aren't needed"), the plan model is very easy to satisfy accidentally — hard limits only surface when the architecture is wrong.
**How to avoid:**
- Architect backend so **one user action = one small invocation chain**: initData authenticate (no sub fetch), geocode (cache + 1 upstream call), route (cache + 1 OSRM call), turn-cards (1 bot API call). Never fan-out into per-item loops in a single invocation.
- Use **Cloudflare KV/Cache API** to serve the KL landmark alias index and cached routes — cache-first is the most important budget-free win (a small hotspot region + modest query diversity means routes/alias hits are extremely friendly to caching).
- Client-side debounce: search debounce ≥300ms, no per-pan route re-fetch (viewport is device-local; no route call per map move).
- Instrument during dev (`wrangler tail` shows subrequest/CPU usage and duration per invocation) and set an explicit, phase-reviewed budget ("route call ≤2 subrequests; session init ≤1; geocoder ≤1").
- Plan-time acceptance: free-plan viable at launch only if **cache-hit-rate > ~85% and ≤6 subrequests worst-case per invocation**; paid plan ($5/mo) is the documented fallback, not a panic.
- CPU: keep Worker logic light (validate, proxy, cache lookup); offload any heavy geo computation to the OSRM instance itself, not the Worker.
**Warning signs:**
`wrangler tail` showing >10 subrequests per invocation on one endpoint; any OSRM-call-per-map-pan logic; no debounce before geocoder; cache hit-rate never measured; first "slow" fan-out endpoint compiles without any cache.
**Phase to address:** P4 (route provider + caching), and keep as a recurring review item in every backend-touching phase; a "budgets audit" entry in the pre-launch hardening phase (P7).
---
### Pitfall 9: Design-fidelity death by translation — Tailwind-CDN mockups rebuilt into generic component code
**What goes wrong:**
The mockups in `stitch_minimalist_route_navigator/` are single-file HTML built with **Tailwind via the Play CDN** — a JIT-in-browser dev tool that encourages arbitrary values (`bg-[#1A1A1A]`, `text-[13.5px]`, one-off letter-spacing tweaks, hand-tuned asymmetrical spacing/micro-offsets) that give the Telemetry Rams look its life. The standard failure mode: the real app is rebuilt from a component library or normalized spacing scale and every one-off arbitrary value gets "cleaned up" into the nearest 4px bucket — cumulatively flattening the hand-tuned, optical, asymmetrical charm into yet another Tailwind dashboard. This never looks like one mistake; it's dozens of small normalization decisions, each individually defensible, that quietly destroy the project's most important intangible: the "less, but better" evidence of taste. The user has flagged this as the **#1 v1 risk**, and the trap is that the *natural act of "doing it properly" (tokens, libraries, scales)* is precisely what erases the fidelity.
**Why it happens:**
Tailwind-CDN mockups carry no tokens layer; components imported from libraries come with default spacing/rounded/metaphors that fight the design; "refactor" PRs silently restructure spacing/typography; Helvetica Neue's availability differs by platform (system font on some, fetchable elsewhere) while webfonts change optical weights and rhythm.
**How to avoid:**
- **Extract design tokens first, before any app screen**: write `design-tokens.css`/`tokens.ts` literally *from the mockup files* (colors incl. exact hexes — Signal Orange #EB8008, Functional Red #ED3F1C, Olive Green #736B1E, Soft Beige #D9D2C6, Matte Black #1A1A1A; type scale/weights; 8px grid plus documented *intentional exceptions*; radius set; icon line weights). Anything not in tokens requires a sunset/review flag, not silent deletion. The mockup HTML is the source of truth, not the Tailwind docs.
- **Decide the component approach deliberately**: no default-styled component library as the visual base in v1 (not shadcn/MUI-look as base); hand-rolled Rams components for the ~15 pieces v1 actually needs, built on tokens. Map is a container whose overlays use the same tokens (route line color = Signal Orange, etc.).
- Wire **font reality** into the design-system phase: expect Helvetica-system-font fallback vs Inter; verify optical weights survive; measure line-height rhythm in real webview render, not in Figma/license.
- **Keep fidelity verification as a named acceptance artifact** per UI-bearing phase: side-by-side gallery-vs-mockup review (rotated-mock vs real screenshot at matching viewport), approved by the user *before* screens are declared done. Pass/fail judged against *the intent* — rhythm, optical relationships, asymmetry — not pixel-diff, because wrapped-in-Telegram-chrome and font substitution guarantee pixel drift.
- If any "cleanup/refactor" PR touches spacing/color/typography without a mockup diff, reject it at review.
**Warning signs:**
Component library import in package.json (radix/shadcn MUI defaults); a diff that touches spacing/color/typography without mockup reference; screens built from memory of mockups instead of side-by-side against the live HTML; any "we'll polish fidelity at the end" plan.
**Phase to address:** P1 (tokens + component gallery, gated by user approval against mockups); re-verified at the end of every UI-bearing phase (P3, P4, P5, P6).
---
### Pitfall 10: Ops-stack scope creep — RabbitMQ/PostGIS/Grafana before any user
**What goes wrong:**
IDEAS.md lists RabbitMQ, Prometheus/Grafana, Neon (Postgres), Resend etc. — aspirational services from the vision doc. Classic greenfield-scope mistake: v1 ships nothing navigable because weeks go to infrastructure for scale that doesn't exist and for features (`incident reports`, telemetry pipelines) explicitly **out of scope** this milestone (PROJECT.md). PostGIS has no obvious v1 consumer: one region, a tiny curatable POI index, no user sessions to persist (privacy posture *helps* here — almost nothing must be stored). RabbitMQ serves incident broadcasts — deferred. This pitfall never looks like a mistake in the moment because each piece is "obviously needed eventually".
**Why it happens:**
Infrastructure feels like progress and produces no design-fidelity anxiety that blocks work; primitives (queues, dashboards) absorb low-confidence energy better than shipping does.
**How to avoid:**
- Roadmap rule stated up front: **every v1 service exists to serve a shipping requirement or it doesn't ship.** The legitimate v1 backend isolates to: one Worker (initData → session, geocoder + route proxy via cache, turn-card bot replies) + KV/Cache; at most one Postgres (Neon free) when opted-in telemetry or reports ship, **not before**.
- Replace each aspirational service in the roadmap with a **trigger condition**: "PostGIS: when incident reports start producing route-impact data" / "Grafana: when load patterns deny CF built-in logs" — this keeps ambition visible without consuming phase capacity.
- Grafana/Prometheus = nothing in v1; use Cloudflare's own invocation logs + a Telegram-bot error webhook (the bot is a free ops-alerting channel).
- Treat any mid-plan replacement of UI work with infra work as a re-scoping alarm in phase-gate review.
**Warning signs:**
"Plumbing" tickets with no user-visible change; imports of queue libraries; schema design for tables whose features are out-of-scope; dashboards with no metric consumers.
**Phase to address:** Milestone-planning discipline and every phase gate, not a specific phase; IDEAS.md aspiration list should be re-read (and sorted into "now / trigger-conditions") at milestone close.
---
## Technical Debt Patterns
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Route calls direct-to-OSRM from client code (no provider interface) | Zero setup, fastest possible route on screen | Coupled rewrite when self-hosted OSRM + caching + traffic arrives; leaks infra into UI code | Spike/demo phases only; never in the phase that ships route display |
| `localStorage` for session token | One line of code | iOS/client-dependent wipe → spontaneous logouts | Never — CloudStorage/cookie is the same one line |
| `100vh` / fixed pixel layout | Looks right immediately in dev | Keyboard/header shifts break it on every client | Never — viewport bindings exist |
| Trust `initDataUnsafe` in dev, "validation later" | Auth works instantly | Security hole ships if "later" slips; auth-shape rewrite | Never — the validator is one file; build it in the first backend commit |
| Demo OSRM + public Nominatim without a provider abstraction | Day-1 route+search | Policy violation/rate walls at launch scale; forced rewrite at both call sites | Spike/prototype only |
| Full route recalc on each off-route ping | Simple code path | Request storms, flicker, ETA whiplash | Never — debounce is ~10 lines |
| Skipping golden-route test corpus | Faster start | Data/code bugs invisible until real drivers complain; no regression net for guidance changes | Only if human-corridor QA is explicitly scheduled within days of the guidance phase |
| Tailwind-Play-CDN styles carried into production app code | Fastest build path | No tokens layer → design fidelity decays in every subsequent PR | Never — extract tokens in P1 instead |
| Hardcoding "websockets not needed" as "no server push" | Simplifies P3 planning | Hybrid guidance (P6) needs message timing; re-planning when chat turn-cards arrive | OK for UI-only phases; revisit when P6 design starts |
## Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| Telegram initData | HMAC with raw bot token as key; unsorted data-check-string; skipped URL decoding; no `auth_date` freshness check | Key = HMAC(bot_token, "WebAppData"); sorted `\n`-joined string; constant-time compare; short TTL + session-token exchange; test with official vectors + tampered payloads |
| Telegram WebApp JS API | Calling features without `WebApp.isVersionAtLeast`; skipping `ready()`; assuming BackButton exists on every client | Feature-detect with version gates; graceful fallback screens; SDK init before any API call |
| Telegram LocationManager | Assuming `navigator.geolocation` behaves identically in every client; skipping the access pre-check | Check LocationManager access state first; fall back to `navigator.geolocation`; always weapon the denied screen + manual-origin fallback |
| Nominatim | Per-keystroke calls; no cache; exceeding 1 rps by design | Debounce ≥300ms; KV/Cache; own POI alias index; Photon/managed geocoder as the primary |
| OSRM | Demo server in production; ignoring mismatch 1-overview vs steps geometry scale; polyline decoded at wrong precision (5 vs 6); forgetting `steps=true` for guidance | Own instance; unit-tested response parser; polyline6 decode; always request `steps` for guidance |
| Map tiles | Hotlinking free tiers (style and quota burn); tile style visually indifferent/blank clash shapes with Rams basics | Pay/hosr tiles; apply muted Rams-compatible basemap styling (vector styles or CSS filtered raster); client-side tile caching |
| Cloudflare Workers/Pages | Fan-out subrequests in one invocation; no caching; treating .dev vars as secrets | One function per user intent; KV/Cache-first; secrets in dashboard bindings; `wrangler tail` during dev |
## Performance Traps
| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| Per-keystroke geocoding without debounce/cache | Request count ≈ keystrokes × users; 429s; battery | Debounce ≥300ms, min 3 chars, KV cache, local alias index first | Immediately at search ship; hundreds of QPS with a handful of users |
| `watchPosition` at max frequency for whole session | Battery death; UI churn; health-app mistrust | Speed-adaptive sampling; stop when app idle/suspended; Wake Lock only during active guidance | Noticeable in QA drain test; ship-blocker above ~8–10%/hour |
| Re-fetching full route geometry on map pan/zoom | Workers subrequest storm; janky map | Geometry fetched once per navigation session; viewport decisions are device-local | With the first handful of concurrent navigators |
| Route fetch + geocode + POI enrichment in one invocation > free-plan subrequest ceiling | Invocation aborts with opaque limit errors | Cap fan-out, cache, split into separate invocations | The day "plan navigation" composes three things — usually at pre-launch testing |
| Bundling all KL POIs into the client bundle | MB-scale JS; slow first open on 4G | POI search stays server-side/aliased; client ships route + narrow nearby-detail JSON on demand | First field test in-device |
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| Client-side initData validation or trusting `initDataUnsafe` | Full identity spoofing — navigate as any user, spam any scoped API | Server-side HMAC validation (Pitfall 1); session-token exchange |
| Bot token shipped in client bundle or exposed in Pages build env | Bot hijack, spam, forged initData | Token only in Worker secret bindings; never client `"NEXT_PUBLIC_"`-style exposure |
| Unvalidated `start_param` deep-links | Injection into Mini App navigation logic; stats/games abuse | Treat start_param as untrusted; whitelists only |
| Turn-cards or route sharing w/o rate limits | Spam vector; report-feature abuse when it later ships | v1: private-chat flows only; per-(user, minute) rate limits; reports feature gets identity + rate-limit design before it ships |
| Server-side location/track persistence "for debugging" | Direct contradiction of the privacy promise; irrecoverable trust hit | No location persistence in v1; ops logs redact coordinates (round to ~100m if ever needed) |
## UX Pitfalls
| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| Search that doesn't know local landmark language ("KLCC", mall names, subdistrict names) | Faith in the marquee feature breaks first | Alias/POI layer (Pitfall 5); tune from real tester queries |
| Guidance UI demanding driver attention (dense lists, small taps while moving) | Unsafe + app abandonment | Rams restraint as an advantage: next-maneuver only, large type, distance-first |
| No screen-awake handling — display sleeps mid-navigation (or GPS runs forever after idle) | Anger in both directions | Wake Lock only during active-navigation state (test per-client support); degrade to Telegram chat turn-cards (P6 hybrid) |
| Permission prompts at the worst moment (launch straight into guidance with no location) | Denial reflex | Prompt only at the "Start from here?" moment with one-tap confirm; map-origin fallback always available |
| Raw-fix puck floating off-road | Distrust ("is this app even right?") | Snap to route; honest "recalculating…" state (Rams microcopy) |
| ETA flipping as the vehicle moves | "Numbers wiggling, tired of this" | Smoothing; deltas shown only when meaningful (Pitfall 3) |
| Guidance UI lingers forever past arrival (no arrival detection) | Feels unfinished, buggy | Arrival detection on snapped progress → brief completion card → done state |
## "Looks Done But Isn't" Checklist
- [ ] **Auth:** tampered hash rejected in CI; stale `auth_date` (e.g. 10-min old) rejected; raw-curl calls rejected; token rotation procedure documented
- [ ] **Location:** Android/iOS/Desktop/browser matrix tested including POST-deny retry + indoor no-fix behavior; denied screen and manual-origin fallback shipped
- [ ] **Shell:** keyboard-open doesn't shove content; Telegram header never overlaps; BackButton works and never double-pops during active navigation; vertical swipe doesn't close app mid-guidance; sessions survive restart on iOS
- [ ] **Guidance:** recorded-drive replay passes; one reroute per off-route event (not a storm); route-ETA stability follows the smoothing rules
- [ ] **Golden routes:** 10–20 real KL pairs return sane routes and readable maneuvers (no 12-turn-in-30m sequences on ramp data artifacts)
- [ ] **Battery/screen:** hourly drain < ~10% in QA; wake-lock behaves exactly across active navigation vs browsing vs backgrounded
- [ ] **Cloudflare budget:** subrequest/CPU usage measured under replay load; cache hit-rate visible in logs; worst-case invocation within free-plan ceiling (or paid-plan budget accepted)
- [ ] **Design fidelity:** tokens + component gallery approved by the user side-by-side against mockups (named artifact in the design-system phase), re-verified at every UI phase close
- [ ] **Privacy:** no coordinates in any server log; opt-in telemetry only; opt-out exists and works; storage list ("what we store and why") present in the bot or in docs
## Recovery Strategies
| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| initData validation shipped broken | LOW (pre-launch) / HIGH (post-users) | Write validator + vectors; invalidate all sessions; rotate the bot token if a spoof already succeeded |
| Silent geolocation failure discovered late | MEDIUM | Swap to LocationManager primary path (abstraction makes this cheap); ship denied-help screen; manual-origin fallback unblocks users meanwhile |
| Recalc storm in production | LOW | Add debounce + cooldown (small fix); the real cost is data (possible upstream 429 bans) time to recover |
| Search fails on real KL queries post-launch | MEDIUM | Alias index is additive, not architectural; add incrementally from real query logs (privacy-respecting aggregate logging only) |
| Demo OSRM throttled/gone at launch | MEDIUM | Stand up self-hosted instance (a day, not a quarter — interface means a config swap) |
| Design fidelity lost in the app | HIGH — hardest to recover | Rebuild screen-by-screen from tokens with side-by-side mockup verification; every defer costs compounded fidelity decay; precisely why the user rated this #1 |
| Ops-stack scope creep mid-build | MEDIUM | Ruthlessly remove or defer; re-scope at true v1 verticals; sunk cost hurts once, ongoing maintenance hurts every week |
## Pitfall-to-Phase Mapping
| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| initData validation done right | P1/P2 (first backend artifact) | Tampered/stale payload tests in CI; curl-only requests rejected |
| Geolocation silent WebView failure | P3 (shell + location slice) | Multi-client device matrix demo includes denied + no-fix paths before phase closes |
| Snapping, recalc storms, ETA whiplash, battery | P5 (live guidance), building on route model in P4 | Recorded-drive replay passes: one reroute per off-route event; ETA stability; drain test within budget |
| localStorage / viewport / back-button traps | P3 (Telegram-aware shell) | Session survives iOS restart; keyboard test; BackButton never closes the Mini App mid-navigation |
| Nominatim limits + landmark weakness | P2 (search) | "KLCC" / "Sunway" / "Mid Valley" / alias queries pass; 1-rps budget enforced; zero 429s in logs |
| OSRM demo dependency + missing traffic infrastructure | P4 (route provider) | No demo-OSRM reference in production config; route-provider interface with two impls testable; traffic weighting deferred with explicit trigger condition |
| Malaysia OSM data quality | P4 (golden routes) × P5 (guidance restraint) | 10–20 KL golden routes pass with concise maneuvers; known-limitations doc committed |
| Cloudflare budgets | P4 onward + budgets audit in P7 | Subrequest/CPU counts instrumented in dev replay; cache hit-rate visible in logs |
| Design fidelity (Tailwind-CDN → real code) | P1 (tokens + gallery, user-approved) | Gallery-vs-mockup approval artifact per UI phase (P3–P6) |
| Ops-stack scope creep | Milestone planning + every phase gate | Every shipped service maps to a shipped requirement; no v1 infra beyond Worker/KV (+ optional Postgres only when telemetry/reports ship) |
## Sources
- Telegram official — Mini Apps / Web App platform docs (initData validation procedure, LocationManager Bot API 8.0+, BackButton Bot API 6.1+, `viewportStableHeight`, CloudStorage, disableVerticalSwipes/closing-confirmation): https://core.telegram.org/bots/webapps — HIGH
- "Telegram Mini App (TMA) Development Mistakes and How to Avoid Them" (AdsGram blog, 2025/2026) — initData traps, localStorage wipe on iOS/desktop builds, custom-back-button vs native, viewport symptom table — MEDIUM (corroborated by official docs)
- tma.js / @telegram-apps sustain issues and docs (location request patterns, viewport/css var binding, feature detection): https://github.com/Telegram-Mini-Apps/tma.js/issues/617 — MEDIUM
- WebView geolocation silent-denial references: even-realities/EvenDemoApp issue #50 (error code 1 without prompt when WebView lacks permission bridge); Stack Overflow "android webview geolocation" thread — MEDIUM (widely-reported failure mode, matches developer reports)
- Nominatim Usage Policy (official OSMF operations page; max 1 req/s, no heavy use): https://operations.osmfoundation.org/policies/nominatim/ — HIGH
- OSM wiki "Addresses" (interpolation, tagging gaps); community threads on address/POI geocoding quality and abbreviations in OSM search — MEDIUM
- OSRM official docs + demo-server policy references (leaflet-routing-machine docs' "**NOT SUITABLE FOR PRODUCTION USE**" OSRM-demo notice; traffic-update feature/datasource notes in OSRM API docs) — HIGH for policy, MEDIUM for profile nuances
- Cloudflare Workers limits docs — subrequests (50 free / 10k+ paid), CPU limits, Pages/Workers free-plan request quotas: https://developers.cloudflare.com/workers/platform/limits/ — HIGH
- Cloudflare Pages pricing docs — "static unmetered; functions metered" — HIGH
- Project-internal: PROJECT.md (Telemetry Rams token list, Tailwind-CDN mockups, privacy constraints, ops appetite, hybrid-guidance decision) and user's stated #1-risk flag on design fidelity — HIGH
- Turn-by-turn engineering traps (snapping, off-route hysteresis, ETA smoothing, adaptive sampling, drive-replay testing): synthesized from established navigation-engineering practice (routing engine vs navigation-layer separation), no single canonical source treat as MEDIUM/known-wisdom
---
*Pitfalls research for: Telegram-first GPS navigation (araharah)*
*Researched: 2026-10-22*
+262
View File
@@ -0,0 +1,262 @@
# Stack Research: araharah (Telegram-first GPS navigation)
**Domain:** Telegram bot + Mini App turn-by-turn navigation on OpenStreetMap data (KL metro first)
**Researched:** 2026-10-22 (all versions verified against registries/official docs on this date)
**Confidence:** HIGH (core stack) / MEDIUM (geolocation workarounds, snapshot path)
## Executive Verdict
The viable 2026 stack for this project is already fully assembled from cloud-native + OSM components:
**grammY (TypeScript) on a single Cloudflare Worker** for the bot + Mini App API, **Cloudflare Workers static
assets** (not legacy Pages) hosting the Mini App, **MapLibre GL JS v6** with **OpenFreeMap** vector tiles for the
webview map (custom style JSON = the Telemetry Rams look), **self-hosted Photon + OSRM on one cheap VPS**
for geocoding and routing (Malaysia/country extract only — no planet imports), and **Cloudflare KV** for
ephemeral session state. Total infra cost: ~€5–10/month VPS + $0 Cloudflare.
Critically: every public/community OSM service (OSRM demo, photon.komoot.io, nominatim.openstreetmap.org) is
explicitly **not production-grade** and every one of them forbids or throttles exactly the traffic a
navigation app generates. Self-hosting a Malaysia-area extract is small (tens of GB, not terabytes), cheap, and
is the only option consistent with the privacy stance (user origin/destination coordinates go to
infrastructure that stores nothing — not to ad-funded or quota-metered third parties). Also note: a
KL-country-scale deployment does not need a database for routing or geocoding state at all.
The aspirational stack in IDEAS.md must be cut hard for v1. Neon/PostGIS, RabbitMQ, Prometheus/Grafana,
Resend, and Maptiler are all **deferred or dropped** — none is needed for search → route → guidance. The
privacy stance actually *reinforces* this: if nothing is stored, there is almost nothing to database.
---
## Recommended Stack — Core Technologies
### 1. Telegram bot layer
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| **Telegram Bot API** | 10.3 (Aug 2026) | Bot transport, commands, Mini App launch, location messages | Current stable API. Bot API 8.0 (2024) added the Mini App `LocationManager` — the geolocation API this product needs — and Bot API 10.2 (Jul 2026) added Rich Messages including `InputRichBlockMap` (native map blocks in chat, a candidate for backgrounded guidance cards). |
| **grammY** | 1.46.0 (npm verified) | Bot framework | De-facto standard TypeScript bot framework. First-class webhook support (works natively on Cloudflare Workers — the official docs document exactly this deployment), `BotCommand`-scoped menus, update validation via `X-Telegram-Bot-Api-Secret-Token`, and the `conversations` plugin for multi-step search→confirm flows. Actively maintained at Bot API 10.x parity. |
| **@grammyjs/conversations** | latest 2.x | Multi-turn flows | `Query "KLCC" → result list → open Mini App` is a conversation; conversations plugin models it cleanly without hand-rolled state machines. |
**Webhook vs polling (decided): webhook.** Reasons: (a) the bot backend lives on Cloudflare Workers, which is
request-driven — polling via long-running processes is a poor fit for Workers (no persistent connections);
(b) webhook gives instant routing of the secret-token header for update validation; (c) scale-to-zero cost.
Polling (`grammy runner`) only if you later move the bot to a long-lived VPS — unnecessary duplication.
**Bot commands / menus:** configure via `setMyCommands` (a `/start`, `/navigate`, `/search` minimum) and set
the **default chat menu button to open the Mini App** (BotFather → Menu Button, or `setChatMenuButton` API).
No custom keyboard grids for v1 — inline keyboards on result messages suffice (Rams: less, but better).
### 2. Mini App front end (the webview map)
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| **telegram-web-app.js** (Telegram official script) | per core.telegram.org/bots/webapps | Mini App bindings, viewport, LocationManager, initData | The canonical WebApp JS API, loaded from `https://telegram.org/js/telegram-web-app.js`. Gives you: `initData` for identity, `LocationManager` (Bot API 8.0+) for persistent geolocation permission on mobile, `BackButton`, `Expand`, `MainButton`, viewportChanged events, and theme wiring. |
| **Vite + TypeScript** | Vite ^8 (npm verified 2026-10-22) | Build/dev for the Mini App | Cloudflare has an official Vite plugin; Mockups exist as HTML fragments — Vite multi-page or single-entry builds them cleanly. No framework needed for 4 screens. |
| **MapLibre GL JS** | ^6.15.0 (npm verified; v6 line started 2026-07-22) | Map rendering in the webview | Vector rendering means one tile schema + **custom style JSON** → the entire Telemetry Rams cartography (Matte Black land, Signal Orange route casing, Olive water) is editable CSS-like style JSON. Supports `bearing` (rotate map to course — essential for driving UX), smooth tile LOD, and is Mapbox GL fork lineage with permissive BSD. Note v6 requires explicit `setWorkerUrl` wiring in bundlers (Vite snippet exists in OpenFreeMap quick start). |
| **OpenFreeMap** | tiles.openfreemap.org | Free vector tile provider | Free, **unlimited map views, no API key, no registration, commercial use allowed**, privacy-positive (no cookies, no tracking, OpenMapTiles schema), weekly OSM updates — verified on openfreemap.org. Use the hosted `liberty`/`positron` styles as baseline, then fork the style JSON into the Rams look. Maximum data zoom z14 is fine inside KL at driving zooms. |
**Cloudflare hosting (decided): Workers with static assets — not Pages.** Cloudflare's own docs (updated
Oct 2026) direct **all new projects to Workers**: "all of our investment, optimizations, and feature work will
be dedicated to improving Workers" (Pages still works but is legacy — its own docs carry a
"migrate from Pages to Workers" guide). A single `wrangler.jsonc` project gives you both the Mini App static
files (`assets.directory`, `not_found_handling: "single-page-application"`) **and** the API/bot webhook routes
(`/api/geocode`, `/api/route`, `/telegram/webhook`) in one deployable unit. HTTPS is automatic — Telegram
Mini Apps require HTTPS and Worker URLs are TLS-terminated by Cloudflare out of the box.
### 3. Geocoding / POI search (must find "KLCC")
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| **Photon (self-hosted)** | latest (Java 21+; pin release at setup) | Forward/reverse geocode + **typeahead/autocomplete** | The only free OSM geocoder that natively does search-as-you-type, typo tolerance, and location bias. Runs prebuilt country dumps: GraphHopper hosts weekly **Malaysia extract** (`download1.graphhopper.com/public/extracts/by-country-code/my/`) — download two files, unzip, `java -jar photon-*.jar serve`. ~<5 GB disk for MY, no Nominatim/Postgres needed. Excellent POI recall for abbreviated landmark names ("KLCC", "KLIA", "Pavilion") because it indexes all OSM names incl. `name:en`. |
| **Cloudflare Worker proxy + cache** | — | Geocode API edge in front of Photon | Route all Mini App/bot geocode queries through your Worker → your VPS Photon. Adds HTTPS (Photon is HTTP), hides the origin, and lets you cache hot queries (KL top-POIs) in KV. Also the integration seam if you later swap Photon for a paid API. |
**Layer strategy for v1 quality:** prefer Photon results **filtered to MY bounding box + `layer=venue/poi` boost**;
fall back to Nominatim semantics only for address-mode queries. Rate-limit-aware caching in KV (TTL 24 h on
hot POI ids) keeps VPS load trivial at early-user scale.
### 4. Routing engine
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| **OSRM (self-hosted)** | v26.10.0 (CalVer, release verified 2026-10) | Car routing with turn-by-turn steps | Purpose-built for car navigation, `steps=true` returns per-maneuver instructions + geometry, `alternatives=2` returns a short route list. With a **Malaysia extract** (~100–200 MB pbf via Geofabrik extracts, or a KL-only custom extract via osmium), the preprocess (`osrm-extract` → `osrm-partition` → `osrm-contract`) runs in <4 GB RAM on a shared VPS. Query latency single-digit ms; no per-request cost. Supports custom profiles but the standard car profile suits Malaysian roads (incl. tolled highways flagged). |
**Why not the other engines first (Opinionated):**
- The **official OSRM demo server (`router.project-osrm.org` / `routing.openstreetmap.de`)** is a volunteer
demo — its own wiki and maintainers say *"NOT SUITABLE FOR PRODUCTION"*, throttle >1 req/s, no SLA, and
have had multi-day outages (verified: OSM community threads 2024–2025, GitHub issues #6647/#5997).
Testing-only.
- **Valhalla (3.9.1)** — richer feature set (elevation, isochrones) and better instructions, but noticeably
more complex to build/update and its C++ toolchain is less standardized in Docker images. Switch to it
later if you add cycling/walking modes, elevation cues, or turn-by-turn instruction localization grammars.
- **GraphHopper cloud** — solid but paid for anything beyond hobby volume.
- **OpenRouteService free cloud** — 2 000 directions/day, now at `api.heigit.org` (api.openrouteservice.org
endpoints were deprecated in 2026). Fine as dev fallback but a hard ceiling for a location-refreshing
navigation app, and each route request carries your users' GPS points to a third party.
### 5. Backend state & storage
| Technology | Version | Purpose | When to Use |
|------------|---------|---------|-------------|
| **Cloudflare KV** | free tier | Ephemeral session state: in-flight search results, pending route selection, route-exhaust windows | Drop-in KV adapter in grammY; TTL key expiry naturally enforces "no long-term location storage". |
| **Cloudflare D1 (SQLite)** | free tier | Later: opt-in anonymized telemetry, saved places | **Not v1.** Only when users consent to opt-in features. |
| **Cloudflare Queues** | free tier | Future: async report ingestion (v2) | Not v1 — direct synchronous API calls suffice for search/route. |
## Supporting Libraries
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| **@turf/turf** | 7.4.0 (npm verified) | Geo math client-side: `nearestPointOnLine` (off-route detection), `bearing` (map rotation), `distance` | Map/geometry utilities inside the Mini App for route-following without server round-trips. |
| **hono** | 4.13.13 (npm verified) | Typed HTTP routing inside the Worker (`/api/*`, `/telegram/*`) | With one Worker serving both static assets and API routes, Hono keeps the API routes readable and typed. Alternatives (raw `Request` handler) fine if you prefer zero deps. |
| **zod** | 4.6.7 (npm verified) | Schema validation for all request payloads | Validate `initData`-derived user IDs, geocode query strings, coordinates. |
| **@grammyjs/storage-free** | latest | grammY storage adapter for Cloudflare KV | Backs the conversations plugin with key/value storage — required once the bot runs serverless (no in-memory session). |
| **@telegram-apps/sdk** | 3.x | Optional richer TS SDK over telegram-web-app.js | Only if you want typed `initData` parsing helper + LocationManager wrappers; the raw script is enough if you keep front-end deps minimal. |
## Development Tools
| Tool | Purpose | Notes |
|------|---------|-------|
| wrangler ^4.151 (npm verified) | Workers deploy/local dev | `npm create cloudflare@latest` scaffolds static-assets worker in one step; use `assets.directory` + `not_found_handling: "single-page-application"`. |
| Docker Compose (dev VPS) | OSRM + Photon local orchestration | Official `osrm/osrm-backend` image preprocessed on a Malaysia extract; photon runs its country dump; both lightweight in Compose. Note: current OSRM images live on GHCR (`ghcr.io/project-osrm/osrm-backend`) — the Docker Hub mirror is stale at v5.25. |
| Geofabrik malaysia-singapore-brunei extract | Source OSM data for OSRM/Photon | Choose `malaysia-singapore-brunei` (whole-country) rather than planet — trivially small. |
| Bot webhook/API testing | wrangler local dev + HTTP client | `wrangler dev` exercises the Worker (assets + `/telegram/webhook`) locally; Bruno/Postman replays saved Telegram update payloads for fast iteration on update shape. |
## Installation
```bash
# Cloudflare side (bot API + Mini App statics)
npm create cloudflare@latest araharah # choose Worker + static assets template, TypeScript
npm install grammy @grammyjs/conversations hono zod
npm install -D wrangler @cloudflare/workers-types
# Mini App side (within same repo, e.g. apps/miniapp)
npm install maplibre-gl @turf/turf
npm install -D vite
# Style: fork https://tiles.openfreemap.org/styles/liberty into rams.style.json
# Routing/geocoding side (self-hosted VPS, Docker Compose)
# NOTE: the legacy Docker Hub image `osrm/osrm-backend` is stale (top tag v5.25, 2021).
# Current releases ship via GitHub Container Registry:
docker run -t -v "${PWD}/malaysia-latest.osm.pbf:/data/malaysia-latest.osm.pbf" \
ghcr.io/project-osrm/osrm-backend:v26.10.0 osrm-extract -p /opt/car.lua /data/malaysia-latest.osm.pbf
# ... osrm-partition / osrm-contract / osrm-routed
# Photon: download https://download1.graphhopper.com/public/extracts/by-country-code/my/... and
# java -jar photon-*.jar serve
```
## Alternatives Considered
| Slot | Recommended | Alternative | When to Use Alternative |
|------|-------------|-------------|-------------------------|
| Bot framework | grammY 1.46 | Telegraf 4.16.3 | Only if team already has large Telegraf codebase; Telegraf lags Bot API 10.x features and has weaker Workers ergonomics. |
| Tile provider | OpenFreeMap (free, no key, unlimited) | MapTiler (free: 100k req / 5k sessions per month, **non-commercial only**, forced MapTiler logo — verified pricing page) | If you need a *hosted stylable map* — MapTiler's `Session` model + required logo + non-commercial clause fights both the budget and the design system. Also skip MapTiler's *Rendered raster* tile flow (512px raster counts as 4 requests and prevents custom vector styling). |
| Tile provider (2) | OpenFreeMap | Protomaps self-hosted on Cloudflare R2 + PMTiles | Full control/per-region: a Malaysia-only PMTiles bucket costs ~pennies on R2 and no external calls. Only worth it if you later need a custom tile schema or worry about OpenFreeMap's lone-maintainer risk. |
| Map renderer | MapLibre GL JS v6 | Leaflet | Leaflet if you need tiny bundle + wide browser support and are happy with raster tiles + limited styling. In a navigation app you want the vector pipeline (style JSON, bearing, GPU cam) that Leaflet effectively lacks. |
| Geocoder | Photon self-host (MY dump) | Nominatim (`nominatim.openstreetmap.org`) | Nominatim public is capped at **1 req/s** and **forbids autocomplete** on the public instance (verified policy) — a navigation app with typeahead breaks both rules in one hour of usage. |
| Geocoder (2) | Photon self-host | Paid APIs (MapTiler geocode / HERE / Geocode Maps) | If you want zero-ops and the privacy stance tolerates a commercial geocoder's logs — MapTiler's new 2026 *session* model makes costs predictable. Only justified if/when Photon's MY quality falls short. |
| Routing | OSRM self-host v26.10 | Valhalla self-host 3.9.1 | More feature-parity for guided navigation with elevation, better instruction localization, and isochrones — but larger ops effort and image sizes. Use if/when you extend beyond driving. |
| Routing (alt) | OSRM self-host | OpenRouteService cloud (api.heigit.org, 2 000 directions/day standard) | Great docs + bundled geocoder/pelias; use only if you want zero VPS and accept quota ceilings. |
| Backend state | Cloudflare KV | Neon Postgres+PostGIS | **Defer to v2 for opt-in telemetry/reports.** PostGIS is genuinely useful for incident geo-queries and traffic heatmaps — but only once the privacy-consent feature actually exists. |
| Static hosting | Workers with static assets | Cloudflare Pages (classic) | Pages still fully works but Cloudflare's own docs route every new project to Workers; no reason to start a new project on the legacy path. |
## What NOT to Use
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `router.project-osrm.org` in production | Officially "NOT SUITABLE FOR PRODUCTION", >1 req/s forbidden, no SLA, has multi-day outages, CF/Worker-origin traffic will be flagged | Self-hosted OSRM on a small VPS (Malaysia extract) |
| Public `nominatim.openstreetmap.org` for typeahead | Usage policy caps 1 req/s and **explicitly forbids autocomplete**; withdrawing access without notice | Self-hosted Photon (typeahead-native) or a paid API |
| `photon.komoot.io` demo server in production | Komoot's own guidance: demo only, "if you have to ask about limits, run your own instance" | The same Photon software self-hosted with the GraphHopper MY dump |
| **Google / Mapbox / HERE / Apple Maps SDKs everywhere** | Forces proprietary data licensing, ad-tracking concerns, breaks the OSM/privacy brand, and adds token dance to a webview app you want strip-simple | OSM stack (Photon/OSRM/OpenFreeMap) |
| **Neon PostGIS in v1** | Nothing persistent survives the session; PostGIS adds an ops dependency before there is any incident/report data worth geo-querying | Cloudflare KV (ephemeral); add PostGIS only for v2 `reports/telemetry` phase |
| **RabbitMQ** | A route request + a search query are synchronous and millisecond-fast; nothing needs decoupled queues at v1 scale | Direct Hono route → OSRM/Photon HTTP calls |
| **Prometheus + Grafana** | You have single-digit services; Cloudflare Workers Observability (logs/tail) covers it; a full metrics stack here is toy-scope inflation | Cloudflare built-in Workers logs + a simple `/healthz` in the VPS Compose |
| **Resend (email)** | Telegram *is* the notification channel — IDEAS.md itself says so | Telegram bot messages |
| **MapTiler as primary paid dependency** | Free tier is non-commercial + forced logo, and their Rendered tiles count 512px raster as 4 requests (pricing-page verified) — bad economics for a driving app that repaints constantly | OpenFreeMap with a custom style JSON |
| **Heavy SPA framework (Next.js/Nuxt) for the Mini App** | Massive payload for a roads-and-buttons webview; Mini Apps live inside Telegram and boot on tap — bundle size is the boot-time tax | Vite + TypeScript, <200 KB gzip JS budget; add Preact only if state management demands it |
| **MapLibre GL JS ≤ v4 pinned** | v6 (2026) fixed the bundler community's worker-URL pitfalls and improves webview performance; pinning to v4 locks you out of current style-JSON tuning | `^6.15.0` with explicit `setWorkerUrl` |
---
## Geolocation in the Cloudflare-hosted Mini App (worked-through)
This was a specific concern; here is the concrete, verified picture:
1. **HTTPS:** Worker URLs (`*.workers.dev` or custom domain) are HTTPS by default → satisfies Telegram's
hard requirement, including the "Mini App methods from origins different from the original Mini App
domain" hardening that auto-enables for **all** Mini Apps on 2026-07-20 (Bot API 10.2 changelog). Deploy
the Mini App from one origin — do not route it through redirects to foreign domains.
2. **Secure-iframe constraint:** Telegram Desktop / Telegram Web run Mini Apps inside a browser context
where your page cannot pop cross-origin permission prompts the way a standalone tab would. Practically:
**`navigator.geolocation` works on mobile Telegram (iOS + Android webview), and on Desktop it either**
**does nothing or needs extra setup** (verified: Telegram-Mini-Apps issues #77, #964, tma.js discussion
#675 — Desktop is a known gap).
3. **`LocationManager` (Bot API 8.0+) is the primary path:** it goes through Telegram's native permission
bridge, so the grant **persists across sessions** on mobile — a big UX win vs. web geolocation, where
permission resets each session (verified: Telegram-Mini-Apps issue #56 comment by stefda, Jul 2025).
Call `WebApp.LocationManager.init()` → `getLocation(cb)`; the payload includes `speed` and `course`
(useful for driving UI).
4. **Android bug to code around:** `LocationManager.getLocation` on Android returns static/cached coords
when called on a timer (verified: Telegram-Mini-Apps #56, reported Jun 2025, still open). Workaround
shipped in the wild: also use `navigator.geolocation.watchPosition` inside the webview (permissions reset
per session, but works continuously once granted) — combine both sources, prefer freshest `speed/course`
flagged fix — and route cross-platform behavior through one small `getLiveData()` wrapper.
5. **Desktop fallback via the chat:** Telegram Desktop can't geolocate inside the Mini App → offer
`KeyboardButton(request_location=true)` (a chat-side native Telegram location) as the universal fallback;
the bot receives a `location` message and hands coordinates to the Mini App a deep link
(`WebApp.openTelegramLink("https://t.me/<bot>?startapp=<handoff>")` or a fresh `startapp` payload). This also serves
the "bot-first" UX (user shares location from the chat without ever opening the Mini App).
6. **What this means architecturally:** the origin-bias request always flows through `initData`-verified
account + at most one ephemeral KV entry (view-state only), so the flow stays inside the "no stored
location history" promise.
## Stack Patterns by Variant
**If the Mini App's map misses the Rams look at first release:**
- Fork the OpenFreeMap `liberty` style JSON and restyle: bg = Matte Black `#1A1A1A`, land = Soft Beige
`#D9D2C6`, water = deep slate-olive, route casing = Functional Red `#ED3F1C` (outer) over Signal Orange
`#EB8008` (inner). This is exactly the property vector tiles give you — raster tiles cannot deliver
it. Iterate on style JSON only, no code changes.
**If Photon MY quality ever disappoints on real KL queries:**
- Add a hand-seeded KL landmark alias file (top-50: KLCC, KLIA, LCCT, Mid Valley, Pavilion, KL Sentral, …)
checked before Photon. 5 minutes of data work that fixes 80% of short-name POI misses. Keep Photon as the
fallback — don't over-engineer synonyms early.
**If you ever outgrow the single VPS:**
- First leverage: move OSRM/Photon into Hetzner Cloud separate VMs (cheap) before any managed service.
Skip "managed OSM infrastructure" entirely — it is the most over-priced niche in the cloud market.
**If you lean on Rich Messages for the chat map later:**
- Bot API 10.2 (Jul 2026) added `InputRichBlockMap` (rich messages with map blocks). This may become a
native way to send backgrounded chat "turn cards" with a real map, replacing custom static tiles.
**Do not bank on it for v1** — filed as a phase-research flag, not a stack dependency.
## Version Compatibility
| Package A | Compatible With | Notes |
|-----------|-----------------|-------|
| `grammy@^1.46` | Bot API 10.3 | `@grammyjs/types@^5` ships Bot API 10.x types; check type parity on any API version bump (`@BotNews` for deprecations). |
| `maplibre-gl@^6.15` | Vite ^8 | v6 requires explicit worker wiring: `import { setWorkerUrl } from 'maplibre-gl'; import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; setWorkerUrl(workerUrl);` (documented in OpenFreeMap quick start). Without this the webview render may hang in production builds. |
| `wrangler@^4.151` | `compatibility_date >= 2026-10-01` | Use jsonc config; toml is being edged out. Static assets config: `assets.directory` + `not_found_handling`. |
| Bot API `LocationManager` | requires Telegram app **8.0+ (2024)** | Still guard `isSupported` for older clients, and always keep the chat-location fallback. |
| Bot API `RichMessage`/`InputRichBlockMap` (10.2) | Telegram app late-2026+ | Post-v1 feature; do not make backgrounded guidance depend on it until usage share confirms client support. |
| OSRM `v26.10` profile files | long-lived car.lua profile | Pin the Docker image tag; an OSRM profile regression silently changes recommended route quality. |
## Sources
- Telegram Bot API changelog (official, core.telegram.org/bots/api-changelog) — verified: current Bot API 10.3, Aug 2026; 10.2 RichMessage/InputRichBlockMap + origin hardening enabled 2026-07-20. **HIGH**
- Telegram Mini Apps docs (official, core.telegram.org/bots/webapps) — verified: `LocationManager` is Bot API 8.0+, `LocationData` has `speed`/`course`; bot-side chat geolocation is standard `KeyboardButton(request_location)`/message. **HIGH**
- npm registry (checked 2026-10-22) — `grammy@1.46.0`, `@grammyjs/types@5.0.0`, `maplibre-gl@6.15.0`, `@turf/turf@7.4.0`, `wrangler@4.151.3`, `hono@4.13.13`, `vite@8.3.5`, `zod@4.6.7`. **HIGH** (versions) / MEDIUM-HIGH (fitness for this domain, community consensus)
- OpenFreeMap official site + GitHub (openfreemap.org, hyperknot/openfreemap) — verified: free unlimited, no API key, commercial allowed, OpenMapTiles schema, privacy model, `setWorkerUrl` Vite guidance; also their own note that they can't offer PMTiles-on-Cloudflare due to latency (supports the R2→PMTiles "later, don't do it first" call). **HIGH**
- OpenFreeMap vs Maptoolkit vs Protomaps comparison (maptoolkit.org/compare, 2026) — cross-informs tile-provider landscape table; Maptoolkit itself is a 2025-launched alternative (10 req/s throttled unregistered, <€1M commercial cap). **MEDIUM**
- MapTiler Cloud pricing / terms (maptiler.com/cloud/pricing, /terms/cloud, doc "sessions vs requests") — verified: free tier is non-commercial, forced MapTiler logo, 100k req / 5k sessions per month, rendered 512px raster count as 4 requests. **HIGH**
- OSRM demo-server usage policy + `Project-OSRM/osrm-backend` wiki — verified: demo is best-effort, forbidden >1 req/s, "not suitable for production" warning carried in LRM and OSM forum threads. **HIGH** (policy) / MEDIUM (updated world-extract size claims)
- OSRM release feed — `Project-OSRM/osrm-backend` latest release `v26.10.0` (2026-10-01), CalVer. **HIGH** (version).
- Valhalla release feed — `valhalla/valhalla` latest `3.9.1` (2026-10-06). **HIGH** (version).
- Docker Hub `osrm/osrm-backend` tag listing (checked 2026-10-22) — image mirror stale (top tag v5.25, 2021); current OSRM releases must be pulled from GHCR. **HIGH** (staleness fact verified directly).
- grammY official docs + npm — Workers deployment + webhook secret-token validation + conversations plugin; concrete Cloudflare adapters. **HIGH** (fit) / MEDIUM-HIGH (exact 1.46 code-verified API shape — verify at phase start)
- Telegram-Mini-Apps issue tracker (#77, #964, #56, tma.js disc #675) — verified: Mini App geolocation is mobile-first, Desktop unsupported, Android stale-location bug open as of mid/late 2025. **MEDIUM** (community-confirmed bugs, official stance not yet changed)
- Cloudflare Workers docs: "Migrate from Pages to Workers" (developers.cloudflare.com, updated 2026-10-12) + Developer-Week guidance — verified: all-new-projects → Workers with static assets; feature matrix favors Workers (gradual deployments, observability, cron). **HIGH**
- Cloudflare Workers static-assets pricing note — assets requests are **free**; makes the whole cloud stack's marginal cost effectively zero at v1 scale. **HIGH**
---
*Stack research for: araharah — Telegram bot + Mini App OSM navigation (KL metro)*
*Researched: 2026-10-22*
+182
View File
@@ -0,0 +1,182 @@
# Project Research Summary
**Project:** araharah
**Domain:** Telegram-first privacy-respecting GPS navigation (bot + Mini App, OSM data, KL metro)
**Researched:** 2026-10-22
**Confidence:** HIGH (stack + architecture core, verified against official docs) / MEDIUM-HIGH (features) / MEDIUM (geolocation client quirks, KL data quality, backgrounded turn-card UX)
## Executive Summary
araharah is a turn-by-turn car navigation assistant delivered entirely inside Telegram: a bot handles search and launch, a Mini App (webview) renders the Rams-designed guidance HUD, and the map/routing data comes from the OSM ecosystem. The research converges on an unusually crisp shape for v1: **grammY on a single Cloudflare Worker** (webhook + `/api/*` + static Mini App assets via Workers static assets — not legacy Pages), **MapLibre GL JS v6 + OpenFreeMap** vector tiles styled from a forked `liberty` style JSON into the Telemetry Rams palette, and **self-hosted Photon + OSRM on one small VPS** fed by a Geofabrik Malaysia extract. Storage is **Cloudflare KV with TTLs only — zero databases, zero queues** in v1. Privacy is not a bolt-on; the client-authoritative guidance architecture (server never tracks position, everything ephemeral) makes the brand promise true by construction. Every public/community OSM service (OSRM demo, photon.komoot.io, public Nominatim) is explicitly non-production and throttles or forbids exactly this traffic pattern — self-hosting a country extract is cheap (tens of GB, ~€5–10/mo) and the only privacy-consistent option.
The recommended build order follows the strict dependency funnel: design tokens first (design fidelity is the user-flagged #1 risk), then bot skeleton, Mini App shell + geolocation, destination search (KL landmark quality is the biggest data experiment), routing, then live guidance, then hardening. Two features define "is this real navigation": **KL-accurate landmark search** ("KLCC" must resolve) and **missed-turn auto-recalculation** — both get dedicated research and test-corpus treatment.
Key risks, in priority order: (1) **design-fidelity death by translation** — the Tailwind-CDN mockups must be distilled into a tokens package with screenshot-diff acceptance against the mockups, never normalized into a generic component library; (2) **Telegram WebView geolocation quirks** — `LocationManager` (Bot API 8.0+) is the primary path, with Android stale-fix bugs coded around and a chat-side `request_location` fallback for Desktop; (3) **turn-by-turn engineering complexity** — snapping jitter, recalculation storms, ETA whiplash, and battery drain all need an explicit guidance state machine with hysteresis and debounce; (4) **KL OSM data quality** — patchy `maxspeed`, weak `turn:lanes`, inconsistent interchange modeling; scope the quality bar honestly and validate against a golden-route corpus; (5) **ops-stack scope creep** — RabbitMQ/PostGIS/Grafana/Prometheus from IDEAS.md are explicitly deferred with trigger conditions.
## Key Findings
### Recommended Stack
The viable 2026 stack is already assembled from cloud-native + OSM parts (full detail in STACK.md). Total infra cost ≈ €5–10/mo VPS + $0 Cloudflare.
**Core technologies:**
- **Telegram Bot API 10.3 + grammY 1.46** (`@grammyjs/conversations`) — webhook mode on Workers, secret-token validation, KV-backed sessions
- **Cloudflare Worker (single deployable, static assets)** — bot webhook + `/api/*` (Hono) + Mini App SPA in one `wrangler.jsonc` unit; Pages is legacy per Cloudflare's own docs (this resolves the Pages mention in earlier project docs)
- **telegram-web-app.js + LocationManager** — initData identity, persistent geolocation grant on mobile, BackButton/theme/haptics/fullscreen platform glue
- **Vite ^8 + TypeScript + MapLibre GL JS ^6.15 + OpenFreeMap** — no SPA framework; <200 KB gzip budget; custom style JSON = the Rams look; requires explicit `setWorkerUrl` wiring in bundlers
- **Self-hosted Photon** (GraphHopper Malaysia dump, ~<5 GB) — the only free geocoder with native typeahead + typo tolerance; behind a Worker proxy with KV caching
- **Self-hosted OSRM v26.10** (GHCR image, not stale Docker Hub) — car routing with `steps=true`, alternatives, single-digit-ms latency on a Malaysia extract
- **Cloudflare KV** (2 namespaces: `chat_sessions` minutes-TTL, `guidance_sessions` ~2h TTL) — the only storage in v1
**What NOT to use:** OSRM demo server, public Nominatim (1 req/s cap, forbids autocomplete), photon.komoot.io in production, Google/Mapbox/HERE, Neon/PostGIS/RabbitMQ/Prometheus/Grafana/Resend in v1, MapTiler as primary (non-commercial free tier + forced logo), heavy SPA frameworks, MapLibre ≤ v4.
### Expected Features
Full prioritization in FEATURES.md. The funnel has two layers: navigator table stakes + Telegram-native table stakes.
**Must have (v1 / table stakes):**
- `/start` onboarding + commands; free-text destination search → inline keyboard results → Mini App launch with destination via `startapp`
- KL-accurate search: curated landmark/alias gazetteer (~200 entries) + Malay abbreviation normalization ("Jln"↔"Jalan") layered over Photon, KL-bounded
- `sendVenue` destination card; geolocation via LocationManager + chat `request_location` fallback
- Route draw + 2–3 alternative cards (Rams-quiet); turn-by-turn HUD (next-maneuver banner + collapsible list)
- **Missed-turn detection + auto recalculation** — the "is this real navigation" bar
- ETA + remaining distance progression (honest, self-paced — no live traffic claims)
- Haptic turn cues; Telegram platform hygiene (BackButton, closing confirmation during guidance, `disableVerticalSwipes`, theme integration, safe areas)
- Visible privacy statement in-product (`/privacy`, "trip ended, deleted" confirmation)
**Should have (v1.x differentiators):**
- Backgrounded chat turn-cards (the marquee differentiator — guidance that lives in the chat; needs ephemeral server-side guidance session + static map snapshots; framed as best-effort projection, never promised as real-time)
- Voice guidance (mute-by-default, iOS webview TTS reliability risk), live-trip share via `sendLocation(live_period)`, toll-free route toggle, speed-limit display where OSM `maxspeed` exists
**Defer (v2+):** opt-in anonymized crowd speeds → traffic-weighted ETA (v1 ships only an architecture stub); incident reports (per PROJECT.md scope); gamification; AES camera layer (legal gray area).
**Anti-features (explicitly rejected):** ads/sponsored pins, gamification, re-engagement pushes, accounts/sign-up (initData is all), fat settings menus (max 3 route choices: fastest / toll-free / shorter), offline maps product, AI-assistant chat.
### Architecture Approach
One Cloudflare Worker serves everything; the Mini App owns the guidance state machine; the server stays stateless and ephemeral (full detail in ARCHITECTURE.md).
**Major components:**
1. **Bot/API Worker** — webhook intake, command router, search orchestration, geocoder/router proxies, chat turn-card pushes, secret custody. Both bot and Mini App call the identical `geo/` module.
2. **guidance_sessions (KV)** — the only "database": who's driving where, ~2h TTL, auto-purged. Everything longer does not exist in v1.
3. **Mini App (static SPA)** — map render (MapLibre + Rams style), guidance state machine `idle → routing → guiding ⇄ rerouting → arrived`, off-route detection local (snap to route polyline, >50m for >10s with hysteresis), recalculation is a client decision.
4. **Geocoder/Router boundaries** — TS interfaces in the Worker; self-hosted Photon/OSRM behind them, demo servers allowed only in local dev via config flag; PostGIS/Valhalla slot in later without rewrites.
**Key patterns:** webhook with secret path; initData → HMAC-validated session token exchange (Pattern 2); `start_param` destination handoff via short KV id; client-authoritative guidance (anti-pattern: server-driven turn-by-turn — webview JS pauses when minimized); Tailwind v4 preset + tokens package generated from mockup YAML, mockups as screenshot acceptance criteria only. Monorepo: `apps/worker`, `apps/web`, `packages/design-tokens`.
### Critical Pitfalls
Top 5 (full 10 + checklists in PITFALLS.md):
1. **Trusting `initDataUnsafe` / client-side validation** — HMAC with key `= HMAC(bot_token, "WebAppData")` (not the raw token), sorted `\n`-joined data-check string, `auth_date` freshness, one-time session-token exchange. Build the validator as the first backend artifact with CI vectors + tampered payloads.
2. **Silent geolocation death in Telegram WebView** — `navigator.geolocation` can fail with code 1 and no prompt in clients lacking the permission bridge. LocationManager primary, version-gated, `navigator.geolocation` fallback, chat `request_location` universal fallback, denied-screen; tested on a real Android/iOS/Desktop/browser matrix as the location slice's exit criterion. Android LocationManager stale-fix bug → combine both sources via one `getLiveData()` wrapper.
3. **Turn-by-turn engineering traps** — snapping (project GPS onto route polyline), reroute debounce/cooldown (≥5–8s, one OSRM call per off-route event), ETA smoothing (windowed speed, no visible whiplash), adaptive `watchPosition` sampling + Wake Lock only during active guidance; validate with recorded-drive replays in CI.
4. **Design-fidelity erosion** (user's #1 risk) — extract tokens from mockup HTML before any screen; no default-styled component libraries; per-UI-phase side-by-side mockup-vs-screenshot approval artifact; reject refactors touching spacing/color/typography without a mockup diff.
5. **KL search quality + OSM data gaps** — Nominatim-style failures on "KLCC"/"Jln"/"DUKE" queries kill the marquee feature; the curated alias gazetteer is the substance of the search phase, not polish. OSM Malaysia has patchy turn-lane/flyover data → golden-route test corpus (10–20 real KL pairs), honest quality bar, no lane-guidance claims.
Also load-bearing: Cloudflare Worker limits (free plan: 10ms CPU / 50 subrequests — cache-first, one intent = one small invocation, ≤6 subrequests worst-case, measured via `wrangler tail`), and ops-scope-creep discipline (every v1 service maps to a shipped requirement).
## Implications for Roadmap
Based on combined research, a 7-phase skeleton (Pitfalls assumed P1–P7 roles; Architecture's P0 maps to Phase 1).
### Phase 1: Design Tokens + Component Foundation
**Rationale:** Design fidelity is the flagged #1 risk; tokens are zero-risk work every later phase consumes. Building UI first and tokens later is the single most likely way to lose the Rams look. (Architecture P0; Pitfalls P1.)
**Delivers:** `packages/design-tokens` (exacted hexes, 8px grid + documented exceptions, fonts self-hosted as woff2 subsets), Tailwind v4 preset, ~15 hand-rolled Rams components, user-approved side-by-side gallery-vs-mockup artifact, map style JSON base (forked OpenFreeMap `liberty` restyled to Rams).
**Addresses:** visible platform for all FEATURES P1 UI items. **Avoids:** Pitfall 9 (fidelity death by translation).
### Phase 2: Bot Skeleton + initData Security
**Rationale:** The bot is how users reach the Mini App; the `startapp` handshake needs a real bot to test against. The initData validator must be the first backend artifact. (Architecture P1; Pitfalls P1–P2.)
**Delivers:** Worker deployed with webhook + secret path, `/start` + command menu, grammY + KV conversations, initData HMAC validator with CI vectors, session-token exchange, `/privacy`.
**Uses:** grammY, Hono, KV, wrangler. **Avoids:** Pitfall 1 (identity spoofing) — tampered/stale-payload tests gate the phase.
### Phase 3: Mini App Shell + Geolocation
**Rationale:** Session/auth + geolocation plumbing precedes any geo-service consumption; this is the riskiest platform-integration phase and its unknowns (WebView quirks, permission bridges) must surface early — the exit criterion is reading a live position on a real phone across the client matrix. (Architecture P2; Pitfalls P3–P4.)
**Delivers:** telegram-web-app.js shell (`ready`/`expand`, viewport handling — never `100vh`, BackButton wired to router, CloudStorage for tokens, theme vars), init/session flow, LocationManager + fallbacks (`getLiveData()` wrapper, denied screen, manual map-origin fallback), map rendering user position on styled basemap, boot with empty `start_param`.
**Avoids:** Pitfalls 2 (silent geolocation death) and 4 (localStorage/viewport/back-button traps).
### Phase 4: Destination Search (Geocoder + KL Gazetteer)
**Rationale:** Nothing user-meaningful exists until search resolves; search quality is the data-correctness experiment with the most unknowns — a Photon bake-off belongs here (budgeted explicitly, per Architecture gap #2). (Architecture P3; Pitfalls P5.)
**Delivers:** VPS provisioned with self-hosted Photon (GraphHopper MY dump) behind Worker proxy + KV cache; free-text bot search → inline keyboard results; curated KL landmark/alias gazetteer (~200 entries) + Malay abbreviation normalizer; `/api/search`; real-KL query acceptance set ("KLCC", "Sunway Pyramid", "Jln", "DUKE").
**Addresses:** search table stakes. **Avoids:** Pitfall 5 (Nominatim rate walls + landmark weakness).
### Phase 5: Routing Service + Route Display
**Rationale:** Full search → route → render loop lands here — the "product exists" milestone. Self-hosted OSRM must stand up *before* guidance makes routing latency-sensitive and chatty. Route model must carry step geometry + snapped-progress support from day one (Pitfall 3 pre-work). (Architecture P4; Pitfalls P6–P7, P8.)
**Delivers:** Self-hosted OSRM (GHCR image, MY extract, preprocess pipeline in Docker Compose); Router interface with demo-server dev-only config; route computation with `steps=true` + alternatives; Mini App route-choice screen (2–3 quiet Rams cards); static map snapshot renderer groundwork; Golden-route corpus (10–20 real KL pairs) + subrequest-budgets instrumentation.
**Addresses:** route display + alternatives table stakes. **Avoids:** Pitfalls 6 (demo-server dependency), 7 partially (golden routes), 8 (Worker limits).
### Phase 6: Turn-by-Turn Guidance (Live Driving)
**Rationale:** Builds on every prior piece and carries the most behavioral unknowns (reroute triggers, hysteresis tuning, ETA smoothing) — schedule slack here. Hybrid chat turn-cards may be its own follow-up phase or folded in; backgrounded turn-card framing (best-effort projection) needs a small product decision + live experiment. (Architecture P5; Pitfalls P3.)
**Delivers:** Client guidance state machine (snapped progress, off-route hysteresis, debounced reroute), maneuver banner + collapsible list, ETA progression, haptics, Wake Lock handling, `heartbeat` TTL endpoint, chat turn-cards (best-effort projection while backgrounded), closing confirmation during guidance.
**Addresses:** missed-turn recalculation, ETA, haptics, backgrounded cards. **Avoids:** Pitfall 3 (recalc storms/ETA whiplash/battery).
### Phase 7: Hardening + Private Launch
**Rationale:** The empty/error/permission states ARE the polish surface of a navigation tool; a named hardening phase (Architecture P6, Pitfalls pre-launch P7) enforces the "Looks Done But Isn't" checklist before real drivers.
**Delivers:** Timeout/off-road/no-geometry states, version-gating fallbacks, budgets audit (subrequests/CPU, cache hit-rate), battery drain QA (<10%/hr), full device matrix pass, privacy verification (no coordinates in logs), golden-route regression pass, known-limitations doc.
**Avoids:** Pitfalls 7 (data honesty), 8 (limits), plus the "looks done but isn't" checklist.
### Phase Ordering Rationale
- Strict dependency funnel: tokens → bot/auth → shell/location → search → route → guidance → hardening; each phase produces something end-to-end verifiable.
- Phase order deliberately surfaces unknowns early where they're cheap: WebView geolocation (Phase 3) and KL search quality (Phase 4) are the two experiments with the most uncertainty.
- v1.x features (voice, live-trip share, toll toggle, speed limits) hang off validated routing+guidance and should not enter the 7-phase spine until the core proves out — plan a v1.x milestone after user validation.
- Apparent conflict resolved: PROJECT.md/IDEAS.md say "Cloudflare Pages" for hosting; both ARCHITECTURE.md and STACK.md converge on **Workers with static assets** (one deployable, Cloudflare's directed path for all new projects). Recommend recording this as a Key Decision.
### Research Flags
Phases likely needing `/gsd-research-phase` during planning:
- **Phase 3 (Shell + Geolocation):** client-matrix quirks, Android LocationManager stale-fix workaround, browser-based testing harness — MEDIUM-confidence community findings.
- **Phase 4 (Search):** Photon-vs-alternatives KL bake-off is a budgeted data experiment; gazetteer sourcing/structure.
- **Phase 6 (Guidance):** backgrounded turn-card product framing ("best-effort projection"), static map snapshot generation, voice guidance TTS reliability (if Phase 6.x).
Phases with standard patterns (skip research-phase, use established docs):
- **Phase 1 (Design tokens):** internal asset work; referenced mockups are the spec.
- **Phase 2 (Bot + auth):** grammY + Cloudflare Workers hosting is first-class documented (official tutorial covers the exact setup verbatim).
- **Phase 5 (Routing/OSRM):** standardize on documented preprocess pipeline; no exotic APIs.
## Confidence Assessment
| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | Core versions verified against registries/official docs 2026-10-22; explicit "why not" for every rejected alternative; MEDIUM for geolocation workarounds + snapshot path |
| Features | MEDIUM-HIGH | Telegram platform details HIGH (official docs); competitor/UX findings MEDIUM; KL-local conventions MEDIUM (some single-source, flagged) |
| Architecture | HIGH | Core verified against official Telegram/grammY docs; two flagged platform-behavior gaps (turn-card UX, Photon KL quality) MEDIUM |
| Pitfalls | HIGH (security/platform) / MEDIUM (nav-engine UX, KL data quality) | Official sources for security/limits; navigation-engineering practice synthesized, community-corroborated |
**Overall confidence:** HIGH on the shape of the system; MEDIUM on KL-specific data quality and phone-in-hand platform behavior — both have concrete experiments baked into the phase plan.
### Gaps to Address
- **Backgrounded turn-card acceptability:** what "good enough" means for minimized-app hints is a product decision requiring a small live experiment — resolve during Phase 6 planning.
- **Photon KL search quality:** untested against real KL queries (abbreviation, mall, subdistrict phrasing) — Phase 4 must budget a bake-off with a fallback (self-hosted Nominatim or hand-seeded alias file escalation).
- **Voice guidance on iOS WKWebView:** `speechSynthesis` reliability LOW confidence, single-source inference — phase-specific verification before planning voice features (v1.x).
- **OSM maxspeed completeness per KL arterial / AES camera availability:** LOW — defer speed-limit display until data is sampled; AES stays in v2.
- **Bot API Rich Messages (`InputRichBlockMap`, 10.2):** potential native chat map blocks; flagged watch-only, not a v1 dependency.
- **Bot API "Serverless" (June 2026):** noted but not recommended; watch-only.
- **Exact `initData` validation API shape at grammY 1.46:** code-verified HIGH for mechanics, verify exact typings at Phase 2 start.
## Sources
Aggregated from STACK.md, FEATURES.md, ARCHITECTURE.md, PITFALLS.md (full source lists with per-source confidence in each file).
### Primary (HIGH confidence)
- Telegram Bot API / Mini Apps official docs (core.telegram.org) — Bot API 10.3, LocationManager, initData HMAC recipe, BackButton/viewport/CloudStorage, start_param
- OpenFreeMap official site + GitHub; MapTiler pricing/terms
- Cloudflare Workers docs (static assets, limits, migrate-from-Pages); Cloudflare Pages pricing
- Nominatim usage policy (OSMF); OSRM demo-server policy; OSRM v26.10.0 / Valhalla 3.9.1 releases; Docker Hub staleness (GHCR is current)
- npm registry version checks (grammy, maplibre-gl, wrangler, hono, vite, zod, turf) 2026-10-22
### Secondary (MEDIUM confidence)
- Telegram-Mini-Apps / tma.js issue trackers (Android LocationManager stale fix, Desktop geolocation gap, viewport quirks)
- OSM Malaysia quality studies; OSM wiki addressing/tagging
- grammY community walkthroughs (Workers + KV/DO patterns); AdsGram TMA mistakes write-up; Photon/Nominatim ecosystem notes
- Waze/Google Maps 2025–2026 comparative reviews
- Turn-by-turn engineering practice (snapping, hysteresis, replays) — known-wisdom synthesis
### Tertiary (LOW confidence — validate at phase research)
- WKWebView `speechSynthesis` reliability; per-arterial maxspeed completeness; AES camera data in OSM
- Bot API Serverless (June 2026) maturity; exact grammY 1.46 typed API surface
---
*Research completed: 2026-10-22*
*Ready for roadmap: yes*