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

34 KiB
Raw Blame History

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.


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).

// 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):

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