Files
Arah2/.planning/research/ARCHITECTURE.md
T
2026-10-23 22:37:55 +08:00

438 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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*