# Phase 3: Mini App Shell + Geolocation - Research
**Researched:** 2026-10-22
**Domain:** Telegram Mini App platform integration, WebView geolocation, MapLibre GL JS v6, Vite build, Cloudflare Workers static assets
**Confidence:** HIGH for platform API surface (official Telegram + MapLibre + Cloudflare docs fetched this session); MEDIUM for the Android stale-fix workaround shape (community-sourced, unverified on device)
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Mini App stack + repo layout**
- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
**Location acquisition strategy**
- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
**Shell UX + screens**
- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
**Dev/test harness**
- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
**Worker static-asset wiring**
- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2.
### OpenCode's Discretion
- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
- `assets` config tuning (binding name, not_found_handling, asset prefixes)
- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
### Deferred Ideas (OUT OF SCOPE)
- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
- Placeholder-shaped search drawer / route furniture — later phases by design
- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| **GEO-01** | The Mini App can obtain the user's current location via Telegram LocationManager, with permission persisting across sessions | `LocationManager.init()` → `getLocation()` gate (`isAccessGranted` persists per-bot across sessions — the API's `openSettings()` path is the manual re-grant). **Version floor Bot API 8.0.** §"The definitive LocationManager API" + §"Android stale-fix" + §"Version gating floor". **Critical:** `getLocation()` is one-shot; there is **no** `getLiveData`/`startLocationUpdates` in the official API — see §"getLiveData does not exist" |
| **GEO-02** | When in-app location fails (denied permissions or Desktop Telegram without geolocation), the chat "send location" keyboard fallback obtains the origin | `KeyboardButton.request_location: true` in a `ReplyKeyboardMarkup` → user shares a `Location` message → bot receives it in an update. Desktop geolocation is confirmed broken (issue #77). §"The chat-keyboard fallback mechanism (GEO-02)" + §"Desktop Telegram has no geolocation at all" |
| **GEO-03** | The user's location is never written to persistent storage — only ephemeral TTL server state exists during the active trip | Zero-persistence is enforceable and *testable*: Vite emits no service worker by default; MapLibre's tile cache is `CacheStorage`/HTTP-cache (transient, and tile URLs are not coordinates). §"GEO-03 is enforceable, not aspirational" — includes the grep gate + runtime probe design. Confirms D-03's localStorage caution (`localStorage` may not even persist in the WebView) |
| **PX-02** | The app follows Telegram light/dark themes for chrome while the map keeps the Rams cartography constant | `WebApp.colorScheme` (`"light"`/`"dark"`) + `themeChanged` event. Phase 1's `--shell-bg`/`--shell-on-surface` slots are the switch target. Map is *never* re-styled. §"PX-02 theme mapping — the map is untouched" |
---
## Summary
Phase 3 is the project's highest platform-integration risk, and this research surfaces **four findings that materially change the plan**:
1. **`getLiveData()` does not exist.** The roadmap note (`.planning/ROADMAP.md:91`) asks research to find a "`getLiveData()` wrapper" for the Android stale-fix. There is no such method in the official Telegram WebApp API. `LocationManager` has exactly `init()`, `getLocation(callback)`, `openSettings()`, and two events (`locationManagerUpdated`, `locationRequested`). D-05's "gated streaming" must therefore be built on top of either repeated `getLocation()` polling or `navigator.geolocation.watchPosition()` — the planner must choose explicitly and Phase 6 must not assume a native streaming primitive exists.
2. **The Android stale-fix bug is real, confirmed, and unfixed.** [Telegram-Mini-Apps issue #56](https://github.com/Telegram-Mini-Apps/issues/issues/56) — opened Dec 2024, still open, labeled `bug` + `in progress` + `Telegram for Android`, with 6 independent confirmations and the reporter concluding "This issue renders the new LocationManager api useless." The reported workaround: **`navigator.geolocation.getCurrentPosition()` inside the WebView refreshes the same fix** that a later `LocationManager.getLocation()` then returns. This inverts D-04's naive reading — the chain is not "try Telegram, then browser"; it is "Telegram first, browser to nudge it."
3. **Desktop Telegram has no geolocation, confirmed.** [Issue #77](https://github.com/Telegram-Mini-Apps/issues/issues/77) (open, Apr 2025 → Jan 2026) reports no permission prompt at all on Telegram Desktop/Windows/Mac/Linux, with a second reporter confirming in Sep 2025 that the geolocation button is *greyed out*. GEO-02's chat-keyboard fallback is therefore the *only* origin path on Desktop — not a degraded backup. This strengthens D-06's "no client-specific variant" copy rule: the Desktop experience must not read as an error state.
4. **MapLibre v6 is ESM-only and WebGL2-only — this breaks the repo's existing test runner shape.** `maplibre-gl@6.15.0` ships `exports: { ".": { import: "./dist/maplibre-gl.mjs" } }` with **no `require` condition** (verified against the npm registry). Any `.mjs` test that `import`s maplibre through the hand-rolled runner is fine, but any CJS-style `require()` or an untranspiled tool will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Separately, **WebGL2 is now required** and the `Map` constructor throws `GPUInitializationError` when unavailable — the planner needs a deliberate fallback for that, and the Phase-1 Playwright capture toolchain (chromium-1217/1248 cached) is WebGL2-capable so the harness is fine.
**Bonus finding — a live security constraint nobody has in the docs:** Bot API **10.2** (July 14, 2026, auto-enabled July 20, 2026) *"disallowing the usage of Mini App methods from origins different from the original Mini App domain."* This is now in force. D-11's Mock-Telegram harness and D-12's `wrangler dev` on `localhost:8787` are fine (same origin), but it means the app must never call `Telegram.WebApp.*` from an iframe, a partner domain, or a secondary origin — and BotFather's registered URL is the authority.
**Primary recommendation:** Ship the location chain as a single `locationSource` state machine with three named sources (`telegram` / `webview` / `chat-pin`), each returning the same `Fix` shape, selected by an explicit capability probe — and treat "Telegram fix is stale" as a *recoverable* condition that fires the `navigator.geolocation` nudge before falling through, rather than as success.
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Telegram WebApp API access (initData, theme, BackButton, safe-area, LocationManager) | Frontend Server (browser runtime) | — | `window.Telegram.WebApp` only exists inside the WebView; nothing server-side can read it. All gating (`isVersionAtLeast`, `colorScheme`, `isAccessGranted`) is client-side. |
| Session mint + validation | API / Backend (Worker) | Frontend Server | Phase 2 already owns `/api/session`. The app only *posts* `initData` and holds the token in memory (D-03). |
| Chat-keyboard location fallback (GEO-02) | API / Backend (Worker) | Frontend Server | The bot must send the `ReplyKeyboardMarkup` and receive the `Location` message via webhook. The app can only *request* the fallback and *display* the waiting state. |
| Fix normalization (`LocationData` vs `GeolocationPosition` vs bot `Location`) | Frontend Server | API / Backend | Three sources, three shapes, one internal `Fix`. Normalization is a client concern; the bot's `Location` message must be relayed to the app via an authenticated endpoint (bot can't push to the WebView). |
| Map rendering + camera + marker | Frontend Server (MapLibre) | CDN / Static (tiles) | MapLibre runs in the page; tiles come from OpenFreeMap at runtime (D-14). Style JSON is inlined at build time. |
| Theme slot switching (PX-02) | Frontend Server | — | `colorScheme` + `themeChanged` are client events. Backend never sees them. |
| Safe-area / viewport | Frontend Server | — | `env(safe-area-inset-*)` + `WebApp.safeAreaInset` are both client-read CSS/JS values. |
| BackButton wiring | Frontend Server | — | `WebApp.BackButton.show()/hide()/onClick()` is client-only. Phase 3 ships the wire; Phase 6 extends the state graph. |
| Coordinate persistence policy | API / Backend (KV TTL) + Frontend Server (memory only) | — | D-07 splits it: the *client* must write nothing (verifiable by grep + runtime probe); the *server* keeps only TTL-expiring session claims (already built in Phase 2). |
---
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `maplibre-gl` | **6.15.0** (published 2026-10-18) | Vector map renderer for the Rams basemap | D-01 locks MapLibre GL JS v6. Zero license cost (BSD-3), no Mapbox token. `theme/basemap/liberty-rams.json` is already a v8 style spec built for it. |
| `vite` | **8.3.5** (published 2026-10-21) | Dev server + production bundler | D-01 locks Vite. v8 requires `node: ^20.19.0 \|\| >=22.12.0` — the repo runs Node **24.18.0**, satisfied. v8 handles the `?worker&url` MapLibre worker import natively. |
| `typescript` | **5.9.3** (pinned in `bot/package.json`) | Type safety for `web/` | Already a workspace dependency; matching the bot's pin avoids workspace version skew. *Note:* npm registry shows `typescript@7.0.2` as latest — do **not** upgrade the shared pin as a side effect of this phase. |
| `@araharah/theme` | workspace | Rams CSS tokens + component classes | D-02 locks build-time consumption of `theme/dist/rams.css`. Component classes `.btn-primary`, `.btn-secondary`, `.card`, `.text-label-sm` etc. are **verified present** in `theme/src/components.css`. |
| `@telegram-apps/types` (or hand-written `.d.ts`) | see note | TypeScript types for `window.Telegram.WebApp` | **`@types/telegram-web-app@10.1.0` exists** (published 2026-10-04) and is the only viable npm option — `telegram-web-app`, `telegram-web-app.js`, `@telegram-apps/web-app` are all dead ends (E404 / abandoned 2022). Verify its `LocationManager` typings match the official docs before committing; otherwise hand-write a narrow interface for the ~12 members this phase uses. **[MEDIUM confidence]** |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `wrangler` | **4.151.3** (already in `bot/`) | Local dev on 8787 + deploy with `[assets]` | D-12/D-13/D-15. The `assets` binding config is documented and supported at this version. |
| `playwright` | already in `theme/` (`^1.50.0`, browsers cached: chromium-1248, webkit-2272, firefox-1511) | Mock-Telegram harness captures + exit-gate device matrix | D-11 + the DSGN-02 fidelity artifact. Playwright's `context.setGeolocation()` + `grantPermissions(['geolocation'])` are the clean way to drive the harness's location stream. |
| `grammy` | `^1.46.0` (already in `bot/`) | Receiving the `Location` message for GEO-02 | Already the bot's framework; a `bot.on('message', ...)` handler filtered on `message.location` is the whole mechanism. |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `maplibre-gl` built-in `GeolocateControl` | Custom follow/recenter control | **Reject.** GeolocateControl renders its own chrome (violates D-08's map-only surface) and owns its own geolocation strategy, which would fight D-04's chain. The UI-SPEC specifies a custom 40×40 machined control. |
| `maplibre-gl` `Marker` | GeoJSON source + circle layers | **Either works.** `Marker` gives exact DOM control over the accuracy ring + heading cone (the UI-SPEC's geometry is DOM/SVG-shaped: 20px dot, 16px cone). A GeoJSON source is better for Phase 5's route line. **Recommendation:** `Marker` for the position group in Phase 3; the route line (Phase 5) will use a GeoJSON source. They coexist. |
| A third-party Telegram SDK (`@telegram-apps/sdk`) | Direct `window.Telegram.WebApp` calls | **Reject.** D-01 says no framework; the SDK is a React-oriented abstraction layer. Direct calls + a thin typed wrapper keep the bundle small and the platform surface auditable — which matters because the location chain *is* the phase. |
| `vitest` / `jest` | Existing hand-rolled `test/runner.mjs` | **Reject for unit tests** (repo convention, zero deps, proven in `theme/scripts/audit.test.mjs` + `bot/test/`). **But see the ESM caveat in §"Don't Hand-Roll"** — maplibre-gl has no `require` export. |
**Installation:**
```bash
# from repo root — add "web" to workspaces, then:
npm install -w web maplibre-gl@^6.15.0
npm install -w web -D vite@^8.3.5 @types/telegram-web-app@^10.1.0
```
Root `package.json` gains `"web"` in `workspaces` plus delegate scripts (`web:dev`, `web:build`, `web:test`, `web:capture`) mirroring the existing `bot:*` pattern (D-02).
**Version verification (run this session, `npm view` against the live registry):**
| Package | Version | `time.modified` |
|---------|---------|-----------------|
| `maplibre-gl` | 6.15.0 | 2026-10-18 |
| `vite` | 8.3.5 | 2026-10-21 |
| `typescript` | 7.0.2 (latest) — **repo pins 5.9.3** | 2026-10-22 |
| `@types/telegram-web-app` | 10.1.0 | 2026-10-04 |
| `wrangler` (installed) | 4.151.3 | — |
---
## Architecture Patterns
### System Architecture Diagram
```text
┌─────────────────────────────────────────────────────────────────┐
│ TELEGRAM CLIENT (Android / iOS / Desktop) │
│ │
│ WebView ── window.Telegram.WebApp ── │
│ • initData ──────────┐ │
│ • colorScheme ──┐ │ │
│ • LocationManager│ │ │
│ • BackButton ──┼───┼── (client-only APIs) │
│ • safeAreaInset│ │ │
└────────────────────┼───┼───────────────────────────────────────┘
│ │
┌────────────┘ └──────────────┐
│ │
▼ ▼
┌───────────────────────┐ ┌──────────────────────────┐
│ web/ (Vite SPA) │ │ bot/ (Worker) │
│ │ │ │
│ 1. boot shell │ │ POST /telegram/webhook │
│ staged states │ │ ├─ /start /help /privacy│
│ 2. POST /api/session │──▶│ POST /api/session │
│ initData → token │ │ validate HMAC │
│ (token = memory) │ │ mint opaque token │
│ 3. location chain │ │ KV put TTL=900 │
│ ┌──────────────┐ │ │ │
│ │ telegram │ │ │ GEO-02 fallback: │
│ │ LocationMgr │ │ │ sendMessage( │
│ │ .getLocation│ │ │ reply_keyboard:[ │
│ │ ↓ null / │ │ │ {text:"Share my │
│ │ ↓ STALE │ │ │ location", │
│ │ ↓ (platform │ │ │ request_location: │
│ │ gating) │ │ │ true}]}) │
│ ├──────────────┤ │ │ ← receives message │
│ │ webview │ │ │ .location │
│ │ navigator. │ │ │ write to KV (TTL) │
│ │ geolocation │ │ │ │
│ │ (THE NUDGE │ │ └──────────┬───────────────┘
│ │ that │ │ │
│ │ refreshes │ │ │ GET /api/location
│ │ the stale │ │ │ (token-auth'd,
│ │ telegram │ │ │ TTL-ephemeral)
│ │ fix) │ │ ▼
│ ├──────────────┤ │ ┌──────────┐
│ │ chat-pin │◀──┼────────│ app │ ← D-04 link 3
│ │ (awaiting) │ │ poll │ polls │
│ └──────────────┘ │ └──────────┘
│ │ │
│ ▼ │
│ normalize → Fix │
│ {lat,lng,acc,heading,ts,source}
│ │
│ ├──────────────▶ MapLibre (Rams style, INLINED at build)
│ │ • Marker: dot + cone + accuracy ring
│ │ • camera: follow / free (96px padding)
│ │ • style NEVER changes on theme flip
│ ▼
│ status strip + boot card + denied card
│ (chrome re-slots on themeChanged; map does not)
│
│ RUNTIME (never touched): localStorage · sessionStorage · cookies
│ IndexedDB · CacheStorage · SW
└─────────────────────────────────────────────────────────────────┘
```
### Recommended Project Structure
```
web/
├── package.json # workspace: @araharah/web
├── tsconfig.json
├── vite.config.ts # base:'/', build.outDir:'dist'
├── index.html # viewport-fit=cover meta (UI-SPEC safe-area)
├── public/ # (empty or minimal — nothing static ships)
├── src/
│ ├── main.ts # boot entry: mount map → session → location
│ ├── shell.css # ONLY var(--color-*) / var(--spacing-*) refs
│ ├── telegram/
│ │ ├── webapp.ts # typed window.Telegram.WebApp accessor + isVersionAtLeast gate
│ │ ├── theme.ts # colorScheme → data-tg-theme (PX-02)
│ │ ├── backbutton.ts # D-10 wire; total fn (no dead-ends)
│ │ └── safe-area.ts # env() ∪ WebApp.safeAreaInset, per-edge max
│ ├── session/
│ │ ├── client.ts # POST /api/session; token in MODULE SCOPE (D-03)
│ │ └── store.ts # in-memory only; TTL staleness check
│ ├── location/
│ │ ├── types.ts # Fix {lat,lng,acc,heading?,ts,source}
│ │ ├── telegram.ts # LocationManager.init/getLocation + staleness detect
│ │ ├── webview.ts # navigator.geolocation getCurrentPosition/watchPosition
│ │ ├── chat-pin.ts # poll the bot-side pin endpoint
│ │ └── chain.ts # the D-04 state machine (three sources)
│ ├── map/
│ │ ├── map.ts # MapLibre Map, Rams style, setWorkerUrl once
│ │ ├── marker.ts # dot + cone + accuracy ring (DOM Marker)
│ │ └── follow.ts # follow/free toggle, 96px camera padding
│ ├── ui/
│ │ ├── strip.ts # status strip (lamp + label + telemetry)
│ │ ├── cards.ts # boot / denied / signal-lost cards
│ │ └── recenter.ts # 40×40 machined control
│ ├── i18n/
│ │ └── strings.ts # en + ms, UI-SPEC copy table verbatim (Copy Gate)
│ └── mock/
│ └── telegram-mock.ts # D-11 fake WebApp (dev/build-tagged only)
├── test/
│ ├── runner.mjs # hand-rolled (repo precedent)
│ ├── harness.mjs
│ ├── location-chain.test.mjs # GEO-01/02 chain with injected fake WebApp
│ ├── no-storage.test.mjs # GEO-03 grep + AST gate
│ └── copy-gate.test.mjs # strings.ts matches UI-SPEC table
└── scripts/
└── capture.mjs # extends theme/scripts/screenshot.mjs pattern
```
### Pattern 1: The Location Chain as an Explicit State Machine
**What:** Three named sources, one `Fix` output type, one selection function. Every failure mode is a *state*, never an exception that escapes to a blank screen.
**When to use:** Always — this is the phase's core abstraction and Phases 5/6 both consume it (Phase 5 snaps routes to fixes; Phase 6's guidance loop drives the watch cadence).
**The three sources and their real behavior:**
```typescript
// Source 1: Telegram-native (GEO-01 primary path)
export type Fix = {
lat: number; lng: number;
accuracyM: number | null; // horizontal_accuracy | coords.accuracy
headingDeg: number | null; // course | coords.heading — null when unknown
ts: number; // ms epoch, for FIX AGE telemetry
source: 'telegram' | 'webview' | 'chat-pin';
};
// Version gate FIRST — LocationManager is Bot API 8.0+.
// Do not feature-detect by truthiness alone: an old client exposes
// `WebApp.LocationManager` as undefined, but a client that has the
// field with isLocationAvailable=false is a DIFFERENT failure.
export function telegramLocationSupported(wa: WebApp): boolean {
return typeof wa.isVersionAtLeast === 'function' && wa.isVersionAtLeast('8.0');
}
```
```typescript
// Source 1 continued — init() then getLocation().
// getLocation's callback receives `null` when access was NOT granted;
// there is no error object, so the null case is the whole denial class.
export function requestTelegramFix(wa: WebApp, timeoutMs = 8000): Promise {
return new Promise((resolve) => {
const lm = wa.LocationManager;
if (!lm) return resolve(null);
const finish = (d: unknown | null) => {
if (!d) return resolve(null); // ← not granted, or unavailable
const loc = d as LocationData;
resolve({
lat: loc.latitude,
lng: loc.longitude,
accuracyM: loc.horizontal_accuracy ?? null,
headingDeg: loc.course ?? null, // null ⇒ UI-SPEC hides the cone
ts: Date.now(),
source: 'telegram',
});
};
// init() is REQUIRED before first use (official docs).
if (lm.isInited) return lm.getLocation(finish);
lm.init(() => lm.getLocation(finish));
// getLocation has no documented timeout — add one, or the boot
// choreography hangs in stage 2 forever on a wedged client.
setTimeout(() => resolve(null), timeoutMs);
});
}
```
**Why this shape:** `getLocation` is documented as *"called with `null` as the first argument if access to location was not granted"* — there is no rejection path and no error code. Every caller must therefore treat `null` as "could not get a fix, try the next source," and the chain must have a hard timeout because the API has none. **[CITED: core.telegram.org/bots/webapps §LocationManager]**
### Pattern 2: Android Stale-Fix Recovery (the "nudge")
**What:** On Android, `LocationManager.getLocation()` returns a *static* coordinate that does not refresh until some other app triggers a location update. Calling `navigator.geolocation.getCurrentPosition()` inside the same WebView forces a refresh.
**When to use:** Only as a *recovery* path after a Telegram fix is detected stale. Not as the primary source.
```typescript
/**
* Android: LocationManager.getLocation() returns a cached, non-refreshing
* fix (Telegram-Mini-Apps issue #56, open since Dec 2024).
* Detecting it: compare two successive telegram fixes. A device that has
* MOVED but returns byte-identical coordinates is the signature.
*/
export function looksStale(prev: Fix | null, next: Fix): boolean {
if (!prev) return false;
const movedMeters = haversineMeters(prev, next);
// Identical to sub-centimetre precision ⇒ the cache never refreshed.
return movedMeters < 0.01;
}
```
```typescript
// The nudge: a single webview read refreshes the native cache, so the
// NEXT telegram read returns fresh coordinates.
export async function nudgeAndRetryTelegram(
wa: WebApp,
): Promise {
const first = await requestTelegramFix(wa);
if (!first) return null;
if (!looksStale(lastAccepted, first)) return first;
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result discarded
return requestTelegramFix(wa); // now fresh (issue #56)
}
```
**Honest caveat — this is MEDIUM confidence.** The mechanism is community-reported and reproduced by 6+ developers, but **it has not been verified on a real device in this project.** The planner should:
- Implement it behind the chain (it degrades to "keep polling telegram," never to a crash), and
- Make the device-matrix exit gate (§Success Criterion 5) the place where it is *proven*, with an explicit note that a phone's Location settings may need to be toggled if the first Android verification shows a still-static fix.
### Pattern 3: The Chat-Keyboard Fallback (GEO-02)
**What:** The bot sends a `ReplyKeyboardMarkup` with `request_location: true`. The user taps it, Telegram's native location picker opens, and the bot receives a `Message` with a `location` field.
**Bot side** (worker, grammY):
```typescript
// KeyboardButton.request_location — available in private chats only.
bot.chat.sendMessage(chatId, strings.awaitingPin, {
reply_markup: {
keyboard: [[{ text: strings.shareLocationButton, request_location: true }]],
resize_keyboard: true, // ← one tap to the native picker
one_time_keyboard: true, // ← dismisses after use; keeps the chat clean
},
});
// The bot then receives the shared pin as a normal message:
bot.on('message', (ctx) => {
const loc = ctx.message?.location; // Location { latitude, longitude,
if (!loc) return; // horizontal_accuracy?, live_period? }
// …write to TTL-ephemeral KV keyed by the session token…
});
```
**App side:** the Mini App **cannot** receive the pin directly — Telegram has no push channel from chat → WebView. The app must poll an authenticated endpoint (`GET /api/location`, `Authorization: Bearer `), which reads the same TTL-ephemeral KV. This endpoint is a **new Worker route this phase must add** — it does not exist in Phase 2.
**[CITED: core.telegram.org/bots/api §KeyboardButton — "If True, the user's current location will be sent when the button is pressed. Available in private chats only."; §Location — `latitude`, `longitude`, `horizontal_accuracy?`, `live_period?`, `heading?`]**
**Design consequence for D-06:** the UI-SPEC's `AWAITING PIN` strip state and "Waiting for the pin you send in chat" card body are exactly right — the wait is genuinely server-mediated, and the app must poll rather than assume instant delivery. Set a bounded poll (e.g. 60s, then back to the denied card with the hint re-shown) so the UI can never wedge on `AWAITING PIN`.
### Pattern 4: Version-Gated Capability Probes
**What:** Every Telegram API this phase touches is version-gated. Gate with `isVersionAtLeast()` and give each gated feature a working fallback.
| Feature | Bot API | Fallback if unavailable |
|---------|---------|--------------------------|
| `LocationManager` | 8.0 | `navigator.geolocation` → chat-pin |
| `safeAreaInset` / `contentSafeAreaInset` | 8.0 | pure `env(safe-area-inset-*)` (UI-SPEC already specifies this) |
| `colorScheme` | always present | luminance test on `themeParams.header_bg_color` (UI-SPEC already specifies this) |
| `BackButton` | 6.1 | hidden; native swipe-down close remains the exit gesture |
| `themeChanged` event | 6.0 | n/a (event support is broad) |
**The one non-negotiable:** `isAccessGranted` is what makes GEO-01's "permission persists across sessions" true — Telegram stores the grant **per bot, on the device**, outside the WebView's storage. That is exactly why D-03's memory-only token and D-07's zero-persistence do not conflict with a persistent permission: **the grant lives on Telegram's side of the boundary, not in the page.** The planner should state this explicitly in the GEO-01 verification notes, because it is the subtlest point in the phase.
### Anti-Patterns to Avoid
- **Blank map while the chain runs.** D-09 requires the basemap to mount and render tiles *immediately*, with boot furniture overlaid. Mount the map first, start the chain second. The reverse order produces the exact blank screen the UI-SPEC forbids.
- **Swapping the style JSON on theme flip.** D-10 / PX-02: the map is Rams-constant. A `map.setStyle()` on `themeChanged` would destroy and rebuild all 110 layers and flash — it also violates "zero re-styling, zero re-creation" in the UI-SPEC's mapping table. Only `data-tg-theme` on `` changes.
- **Treating `null` from `getLocation()` as an error to surface.** It is the *normal* denial signal. There is no error object and no code. Branch on `null`, not on exceptions.
- **Calling `LocationManager.init()` more than once, or using before init.** Official docs: "Before the first use of this object, it needs to be initialized." Guard on `isInited`.
- **Assuming a polling interval delivers live location on Android.** It does not — that is issue #56. Without the nudge (or without accepting staleness honestly), a "LIVE" strip on Android may be showing a frozen pin. **The strip's `FIX Ns AGO` telemetry is the honesty mechanism**; make it honest rather than assuming freshness.
- **Storing the fix "just for convenience"** in `localStorage` to survive a webview revival. That is exactly what D-07 forbids and D-03 warns about — and it would not even work reliably (see §"localStorage is unreliable in the WebView").
- **Adding a service worker** for offline tiles. Vite emits none by default; PWA work is explicitly out of scope; and a SW cache is a GEO-03 audit surface for zero benefit this phase.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Telegram WebApp types | A 500-line ambient `.d.ts` | `@types/telegram-web-app@10.1.0`, narrowed by a thin local wrapper | The full `WebApp` interface is large and versioned; the package tracks Bot API 10.x. Wrap it in `web/src/telegram/webapp.ts` so a types mismatch is one file to fix. **[MEDIUM]** — verify its `LocationManager` shape against the official docs before relying on it. |
| MapLibre worker plumbing | Copying `maplibre-gl-worker.mjs` into `public/` and hand-maintaining a URL | Vite's `?worker&url` query | The docs are explicit: for bundlers (Vite included), `import.meta.url` does not resolve the worker, so a one-time `setWorkerUrl()` is required. Vite's query does it correctly and hash-caches it for free. |
| A geolocation state machine from scratch | A bespoke event emitter + retry/timeout plumbing | A single explicit `chain.ts` with three named branches | The state space is small and *known* (verified sources: telegram / webview / chat-pin). A generic abstraction would obscure exactly the platform-specific behavior that is the point of this phase. |
| A test framework | Adding vitest/jest | The existing hand-rolled `runner.mjs` + `harness.mjs` | Repo convention across `bot/test/` and `theme/scripts/audit.test.mjs`; zero dependencies; proven. **Caveat below.** |
| Battery-farming in dev | Real phones in the daily loop | The Mock-Telegram harness (D-11); phones at the exit gate | D-11 already locks this. Device time is expensive and the harness covers ~90% of the loop. |
| HTTP polling | WebSockets / SSE for the chat-pin | Bounded `fetch` polling | Cloudflare Workers + Durable Objects are not in the v1 stack (AGENTS.md: ops-stack stripped). A bounded poll against the TTL-ephemeral KV endpoint is the honest v1 answer. |
**⚠️ Key insight — the ESM trap in our own test runner.** `maplibre-gl@6.15.0` publishes:
```json
"exports": { ".": { "types": "./dist/maplibre-gl.d.ts", "import": "./dist/maplibre-gl.mjs" } }
```
There is **no `require` condition** (verified against the npm registry this session). The repo's test runner is `node test/runner.mjs` doing `await import(...)` on `.mjs` files with `"type": "module"` — **that is fine**, dynamic `import()` resolves the `import` condition. What breaks is any CJS `require('maplibre-gl')` or a tool that doesn't transform ESM. The planner must state this in the plan so an executor doesn't lose an hour to `ERR_PACKAGE_PATH_NOT_EXPORTED`.
**Second key insight — don't trust `localStorage` even if you wanted it.** Independently reported: inside Telegram's WebView, `localStorage` *may not persist at all* on iOS and some Linux desktop builds, so a restart can clear it. This is independent corroboration of D-03's memory-only choice: it isn't only the privacy rule, it's the only choice that actually behaves predictably. **[MEDIUM confidence — community source, not official docs.]**
---
## Runtime State Inventory
> Phase 3 is **not** a rename/refactor/migration phase, so this section is included only for the GEO-03 zero-persistence audit — the phase's own "runtime state" question.
| Category | Items Found | Action Required |
|----------|-------------|------------------|
| **Stored data** | Client: nothing may be written (D-07). Server: `chat_sessions` KV already TTL-only at 900s (verified in `bot/src/kv/sessions.ts`, every `put` carries `expirationTtl`). **New this phase:** a location record for the chat-pin path — must carry `expirationTtl` on its own `put` line, subject to the existing `privacy.test.mjs` grep gate. | Code edit + a new grep-gate assertion that every `put` in the location module carries an explicit TTL. |
| **Live service config** | `wrangler.jsonc` `kv_namespaces` binding `chat_sessions` (id `59f58a54…`) — unaffected. BotFather's registered Mini App URL is now security-critical under Bot API 10.2's origin restriction. | None for KV. **Verify** the BotFather URL points at the same origin the app is served from. |
| **OS-registered state** | **Telegram's per-bot location grant** lives here (inside the Telegram client, keyed by bot + device). This is the mechanism GEO-01's "permission persists across sessions" depends on. `LocationManager.openSettings()` is the deep-link to re-grant. | None — do not attempt to clear it. It is the feature, not the bug. |
| **Secrets / env vars** | `BOT_TOKEN`, `WEBHOOK_SECRET` (via `bot/.dev.vars`). The app needs **no** secrets — it holds an opaque session token issued by the Worker, not a bot token. | None. Confirm the app never receives `BOT_TOKEN` (it cannot and must not). |
| **Build artifacts** | `web/dist/` is new. `theme/dist/rams.css` + `theme/dist/fonts/**` are copied at build time (D-14). MapLibre's worker is emitted by Vite's `?worker&url` into a hashed file. | Add `web/dist/` to `.gitignore` (mirroring `theme/screenshots/`). |
**Nothing found in category:** No pre-existing client-side persistence to migrate — the repo has no `web/` directory yet, so GEO-03 starts from a clean slate. The audit surface is *"prove nothing gets added."*
---
## Common Pitfalls
### Pitfall 1: Assuming a native streaming location primitive exists
**What goes wrong:** The planner writes `LocationManager.getLiveData()` or `startLocationUpdates()` because the roadmap note mentions "`getLiveData()` wrapper." Neither method exists in the official API — the plan's core module would be dead on arrival, or worse, silently undefined.
**Why it happens:** The roadmap's research recommendation was written from an assumption, not from the API docs.
**How to avoid:** Treat D-05's "gated streaming" as *our* abstraction. Phase 3 ships one-shot `getLocation()` calls; Phase 6's continuous mode is either a bounded poll over `getLocation()` or `navigator.geolocation.watchPosition()` — **the planner must pick and write it down, because it changes Phase 6.**
**Warning signs:** Any plan task naming a `LocationManager` member that is not `init`, `getLocation`, `openSettings`, `isInited`, `isLocationAvailable`, `isAccessRequested`, or `isAccessGranted`.
### Pitfall 2: Treating the D-04 chain as strictly sequential and terminal
**What goes wrong:** Code does `telegram → webview → chat-pin`, first-success-wins. On Android, `getLocation()` *succeeds* with a frozen coordinate, so the chain never advances to `navigator.geolocation`, and the app shows a confidently-wrong `LIVE` pin that never moves.
**Why it happens:** `getLocation()` returning data is not evidence that the data is fresh.
**How to avoid:** Freshness is a separate axis from availability. Compare successive fixes (Pattern 2) and route staleness into the recovery path, not into success. The `FIX Ns AGO` strip telemetry is the user-visible honesty surface for this.
**Warning signs:** No code path anywhere computes the age of the current fix.
### Pitfall 3: `getLocation()` with no timeout hangs the boot choreography
**What goes wrong:** The WebView is wedged, `init()`'s callback never fires, and the app sits in `ACQUIRING` forever — the exact "never blank, always progressing" contract (D-09) is violated by an indefinite stage.
**Why it happens:** Telegram documents no timeout for `init()` or `getLocation()`, and both are callback-based with no error channel.
**How to avoid:** Every `getLocation` wrapper carries its own `setTimeout` that resolves `null` (→ next source). The UI-SPEC's `elapsedSeconds` telemetry in the `acquiring` strip state is the affordance that makes a long wait legible — use it.
### Pitfall 4: Desktop Telegram treated as an error state
**What goes wrong:** The denied card reads as a failure on Desktop, where geolocation is *never* going to work (issue #77: no prompt, button greyed out). Users on Desktop read a broken app.
**Why it happens:** The same code path serves mobile-denial and desktop-unsupported, and the copy ("Location is switched off") implies the user can fix it.
**How to avoid:** The UI-SPEC already handles this — *"Desktop Telegram / no geolocation → denied card with identical copy (no client-specific variant)."* That is the right call and the research confirms it is load-bearing. The card's `ASK VIA CHAT` secondary CTA is the real Desktop path; make sure it is *not* styled as secondary there.
### Pitfall 5: Assuming WebGL2 exists everywhere
**What goes wrong:** MapLibre v6 removed WebGL1 support. On a device/browser without WebGL2, `new Map()` throws `GPUInitializationError` and there is no map at all — violating D-08's "map is the dominant surface" and D-09's "never blank."
**Why it happens:** v6 made this a hard requirement (it was a soft degradation in v5).
**How to avoid:** Feature-detect before constructing (`GPUInitializationError` is exported from `maplibre-gl`). Older Android WebViews on low-end devices are the realistic risk. Decide now what the shell looks like without a map — and note that in the plan, because the UI-SPEC has no "no map" state defined. **[Planner decision needed.]**
### Pitfall 6: Bot API 10.2's origin restriction breaks an innocent refactor
**What goes wrong:** Someone moves the location chain into an iframe, a CDN-hosted shell, or a preview subdomain. `Telegram.WebApp.*` calls stop working on updated clients — with no clear error.
**Why it happens:** The restriction landed 2026-07-20, after most third-party tutorials were written.
**How to avoid:** Every `Telegram.WebApp.*` call originates from the app's own document. The Mock-Telegram harness (D-11) must inject the fake into the *same* window the app runs in, not into an iframe. **[VERIFIED against core.telegram.org/bots/api-changelog, Bot API 10.2, July 14 2026]**
### Pitfall 7: Re-theming the map by rebuilding the style
**What goes wrong:** `themeChanged` handler calls `map.setStyle(darkRamsStyle)`. All 110 layers are torn down and rebuilt; the map flashes, tiles re-fetch, and the camera can jump. Directly violates D-10 and the UI-SPEC's "identical — zero re-styling, zero re-creation."
**Why it happens:** `themeChanged` fires and the reflex is to do *something*.
**How to avoid:** The handler sets `data-tg-theme` on `` and stops. CSS custom properties re-slot; MapLibre is not touched. Write the handler so its only statement is the attribute set — that makes the invariant greppable.
### Pitfall 8: The accent color leaks into states it shouldn't carry
**What goes wrong:** `signal-orange` is used for a "waiting" or "error-ish" chip. Phase 1 D-08's rule is that orange is **navigation-primary only** — Phase 5 draws the route in it, so any non-navigation use of orange now reads as "this is the route."
**Why it happens:** Orange is the most visually available accent.
**How to avoid:** The UI-SPEC's reserved-for list is exact and short (position cone · follow-active control · focus brackets · boot lamp · primary CTA fill · boot-card emphasis dot). Degraded/lost/denied lamps are `functional-red`; the LIVE lamp is `olive-green`. Enforce with the same grep-style off-token audit Phase 1 built (`theme/scripts/audit.mjs`).
---
## Code Examples
### Vite + MapLibre v6 bootstrap (verified pattern)
```typescript
// web/src/main.ts
// Source: https://www.maplibre.org/maplibre-gl-js/docs/ §Installation → Vite
import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError }
from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
// v6 is ESM-only; `?worker&url` is Vite's supported way to emit a
// bundled worker URL. Required for ALL bundlers per the v5→v6 guide.
setWorkerUrl(workerUrl);
import ramsStyle from '@araharah/theme/basemap/liberty-rams.json';
function bootMap(container: HTMLElement): MapLibreMap | null {
try {
// WebGL2 is REQUIRED in v6. This constructor throws otherwise.
return new MapLibreMap({
container,
style: ramsStyle, // inlined at build time (D-14) — never re-set
center: [101.6869, 3.139], // KL, per AGENTS.md region focus
zoom: 12,
attributionControl: { compact: true },
});
} catch (err) {
if (err instanceof GPUInitializationError) {
// Pitfall 5: WebGL2 absent. Shell still renders (D-08/D-09).
return null;
}
throw err;
}
}
```
**Note on the style import:** `theme/basemap/liberty-rams.json` is not inside `@araharah/theme`'s `exports` map (that package has no `exports` field, so deep imports work), but Vite needs it reachable from `web/`. Either add a `basemap` export/subpath to the theme package, or import it via a relative path `../../theme/basemap/liberty-rams.json`. **[Planner decision — D-14 says "build-time dependency", which both satisfy.]**
### Theme slot switching (PX-02)
```typescript
// web/src/telegram/theme.ts
// Source: core.telegram.org/bots/webapps §WebApp.colorScheme, §Events (themeChanged)
import type { WebApp } from './webapp';
type ThemeBranch = 'light' | 'dark';
function resolveBranch(wa: WebApp): ThemeBranch {
// Primary signal — present on all clients this phase targets.
if (wa.colorScheme === 'dark' || wa.colorScheme === 'light') return wa.colorScheme;
// Fallback (UI-SPEC §Theme mapping): luminance of header_bg_color ≥ 0.5
const hex = wa.themeParams?.header_bg_color;
if (typeof hex === 'string' && /^#[0-9a-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum >= 0.5 ? 'light' : 'dark';
}
return 'light';
}
export function applyTheme(wa: WebApp): void {
// The ONLY thing a theme change is allowed to touch.
// MapLibre is deliberately absent from this function (D-10).
document.documentElement.dataset.tgTheme = resolveBranch(wa);
}
export function watchTheme(wa: WebApp): void {
applyTheme(wa);
wa.onEvent?.('themeChanged', () => applyTheme(wa));
}
```
### Position marker geometry (per UI-SPEC)
```typescript
// web/src/map/marker.ts — DOM Marker; geometry from 03-UI-SPEC §Marker
// The accuracy circle radius must be METRES → pixels via map.project().
// It is NOT a CSS pixel value.
function accuracyRadiusPx(map: MapLibreMap, center: LngLat, metres: number): number {
const sw = map.getCanvas().width;
const mPerPx = (40075016.686 * Math.cos((center.lat * Math.PI) / 180))
/ (sw * Math.pow(2, map.getZoom()));
return Math.min(metres / mPerPx, 100 / mPerPx); // UI-SPEC display cap: 100 m
}
```
```typescript
// Heading cone: HIDDEN when heading is unknown.
// Source: 03-UI-SPEC §Position Marker — "no default orientation guess,
// no half-open cone".
if (fix.headingDeg === null) cone.style.display = 'none';
else { cone.style.display = ''; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }
```
### Follow / free mode detection
```typescript
// web/src/map/follow.ts
map.on('movestart', (e) => {
// A programmatic move we initiated is not the user panning away.
if (followLock) return;
if (e.originalEvent) setFollowMode('free'); // user gesture only
});
// Recenter (UI-SPEC): flyTo ≤400ms, camera padding 96px.
function recenter(map: MapLibreMap, fix: Fix): void {
followLock = true;
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX /* 96 */,
duration: 400 });
setFollowMode('following');
}
```
### GEO-03 zero-persistence grep gate (extends Phase 2's `privacy.test.mjs` pattern)
```javascript
// web/test/no-storage.test.mjs
// GEO-03: no coordinate — and no session token — reaches ANY storage API.
const BANNED = [
'localStorage', 'sessionStorage', 'indexedDB', 'caches',
'document.cookie', 'CacheStorage',
];
// Plus Telegram's own persistence surfaces, which D-07 also forbids:
// 'WebApp.DeviceStorage', 'WebApp.SecureStorage', 'WebApp.CloudStorage'
test('no storage API is referenced in web/src', async () => {
const files = await glob('src/**/*.{ts,html}');
for (const f of files) {
const src = await readFile(f, 'utf8');
for (const api of BANNED) {
assert.ok(!src.includes(api), `${f} references ${api} — GEO-03 violated`);
}
}
});
```
**Plus a runtime probe** (stronger than grep, and the real phase-exit spot-check): after a full boot → live cycle in the Mock-Telegram harness, assert in-page that `localStorage.length === 0`, `sessionStorage.length === 0`, `document.cookie === ""`, and `indexedDB.databases()` is empty. Run it under Playwright — that is the artifact GEO-03's success criterion asks to be "spot-checkable at phase exit."
### Dev initData signing for the Mock-Telegram harness (D-11)
Phase 2's `bot/src/security/initdata.ts` already implements HMAC-SHA256 with `HMAC(bot_token, "WebAppData")`. The harness's fake `initData` must be signed with the **dev** bot token so `POST /api/session` accepts it unmodified. The signing logic is HTTP-world and reusable verbatim — do not reimplement it in `web/`. **[Repo inspection — `bot/src/security/initdata.ts` exists and is the canonical implementation.]**
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `navigator.geolocation` only, re-prompt each launch | `WebApp.LocationManager` (Bot API 8.0) — grant persists per-bot on device | Bot API 8.0, Nov 2024 | GEO-01's "permission persists across sessions" is only achievable via LocationManager. The WebView API *cannot* deliver this. |
| Treat `getLocation()` data as fresh | Freshness must be measured (successive-fix comparison + `FIX Ns AGO` telemetry) | Still open upstream (issue #56, Dec 2024 →) | D-06's honesty principle extends to data age, not just permission state. |
| MapLibre v5 UMD/CJS bundles | v6 ESM-only + mandatory `setWorkerUrl()` in bundlers | MapLibre v6 | Breaks CJS consumers; Vite's `?worker&url` is the blessed path. |
| WebGL1 tolerated (v5 degraded gracefully) | WebGL2 required; `Map` throws `GPUInitializationError` | MapLibre v6 | Needs an explicit no-map shell state (Pitfall 5). |
| `styleimagemissing` + `map.addImage()` | `map.setMissingStyleImageResolver()` | MapLibre v6 | Relevant if Phase 5's route line uses a generated icon. |
| Mini App methods callable from any origin | Bot API 10.2 origin restriction, auto-enabled 2026-07-20 | July 2026 | All `Telegram.WebApp.*` calls must come from the registered origin. |
| Telegram storage choices: `CloudStorage` / `DeviceStorage` / `SecureStorage` | n/a — this project uses **none** of them | — | D-07's zero-persistence is stricter than Telegram's model and is what makes GEO-03 provable. |
**Deprecated / do not use:**
- **`telegram-web-app.js` as an npm package** — does not exist on npm (E404). Telegram's official integration is a `