forked from c-fu/arah2
900 lines
80 KiB
Markdown
900 lines
80 KiB
Markdown
# 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>
|
||
## 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
|
||
</user_constraints>
|
||
|
||
<phase_requirements>
|
||
## 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" |
|
||
</phase_requirements>
|
||
|
||
---
|
||
|
||
## 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<Fix | null> {
|
||
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<Fix | null> {
|
||
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 <memory token>`), 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 `<html>` 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 `<html>` 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 `<script src>` from `core.telegram.org/bots/webapps`; ****CORRECTED by device-matrix debug round 3 (2026-10-23): WRONG call — Telegram does NOT auto-inject; production had `window.Telegram` undefined (no initData → session 401, no LocationManager, no BackButton). `telegram-web-app.js` from telegram.org IS required in `<head>`**. Add the script tag (done in web/index.html). The npm-package 404 part still stands — `<script src>` is the only integration. **[ERRATUM logged]**
|
||
- **`@telegram-apps/web-app`** — E404 on npm.
|
||
- **Cloudflare Pages** — superseded by Workers + `[assets]` for this project (AGENTS.md).
|
||
- **`maplibre-gl` default import** (`import maplibregl from 'maplibre-gl'`) — removed in v6. Use named or namespace imports.
|
||
|
||
---
|
||
|
||
## Assumptions Log
|
||
|
||
> Claims below are **not** verified against primary sources. The planner should treat each as a decision point, not a fact.
|
||
|
||
| # | Claim | Section | Risk if Wrong |
|
||
|---|-------|---------|---------------|
|
||
| A1 | `navigator.geolocation.getCurrentPosition()` inside the Android WebView refreshes the stale native cache such that the next `LocationManager.getLocation()` returns fresh coordinates. | §Pattern 2 | **Medium.** The mechanism is community-reported (6 confirmations on issue #56) but unverified on our devices. If it does not hold, Android `LIVE` shows a frozen pin. Mitigation: the recovery path degrades gracefully to polling, and the exit gate is where it gets proven. |
|
||
| A2 | `@types/telegram-web-app@10.1.0`'s `LocationManager` typings match the official docs (especially `LocationData.course` being nullable and `getLocation`'s callback being `(LocationData \| null) => void`). | §Standard Stack | **Low.** If wrong, one wrapper file needs a local type override. Recommend verifying before commit. |
|
||
| A3 | Telegram WebView geolocation requires the page be served over HTTPS (or localhost) for `navigator.geolocation` to work at all — meaning `wrangler dev` on `http://localhost:8787` is fine but a LAN-IP dev URL would not be. | §Pitfalls / D-12 | **Medium.** Affects how the real-device verification session is staged (D-12 says remote staging is used "sparingly right before real-device verification sessions" — that is likely *because* of this). Planner should confirm the staging path before the device gate. |
|
||
| A4 | A reply keyboard with `one_time_keyboard: true` is the right presentation for the GEO-02 fallback (auto-dismisses after one use, keeps the chat clean). | §Pattern 3 | **Low.** UX preference; `resize_keyboard: true` is the load-bearing part. |
|
||
| A5 | The bot can relay a chat-shared pin to the open Mini App via a polled authenticated endpoint, given the Worker is stateless. | §Pattern 3 | **Low–Medium.** Architecturally sound with KV, but adds a new Worker route (`GET /api/location`) not present in Phase 2 — a real scope item the planner must budget for. |
|
||
| A6 | OpenFreeMap's tile endpoint tolerates the request rate a Phase 3 demo/user cohort generates. | §D-14 | **Low.** Not a Phase 3 concern; revisit in Phase 7 hardening. |
|
||
|
||
---
|
||
|
||
## Open Questions (RESOLVED)
|
||
|
||
All four questions below were open at research time and are resolved by this phase's plans. The
|
||
resolution is recorded here so a later phase can see the decision and its owner without re-deriving it.
|
||
|
||
1. **What does the shell look like with no map at all (WebGL2 absent)?**
|
||
- What we know: v6 throws `GPUInitializationError`; D-08 says the map is the dominant surface; the UI-SPEC defines no map-less state.
|
||
- What's unclear: whether to (a) render the shell chrome over a Rams-tinted field, (b) hide the chrome and show only the status strip, or (c) accept that this only happens on ancient devices and let it throw.
|
||
- **Recommendation:** the planner decides explicitly and the UI-SPEC gets a one-line amendment. Option (a) is cheapest and preserves "never blank." This is a genuine gap in the approved contract — flag it to `/gsd-ui-phase` if the planner wants the contract amended rather than silently extended.
|
||
- **RESOLVED — option (a).** `bootMap()` catches `GPUInitializationError` and returns `null`; the shell renders over `--color-surface-dim` with `data-map="absent"`, and a one-line planner amendment is appended to `03-UI-SPEC.md` §Boot Choreography. See plan 03-03 task 2 (map bootstrap + `shell.css`) and task 3 (the `main.ts` header note).
|
||
|
||
2. **Which source does Phase 6's continuous mode actually use — poll `getLocation()` or `watchPosition()`?**
|
||
- What we know: no native streaming API exists (Pitfall 1); `navigator.geolocation.watchPosition` works on iOS (issue #56 reporter confirms iOS updates correctly every second) and is the likely answer for continuous guidance.
|
||
- What's unclear: whether the Android stale-fix affects `watchPosition` too, or only `LocationManager`.
|
||
- **Recommendation:** Phase 3 builds the `Fix`-shaped abstraction and a pluggable cadence; Phase 6 picks. Write the abstraction so this choice stays open — **do not** hard-code a poll interval in Phase 3.
|
||
- **RESOLVED — deliberately left open, by design.** `chain.ts` exposes a `Fix`-shaped abstraction with a pluggable cadence and hard-codes no continuous-mode interval; `readWebviewFix` uses `maximumAge: 0`, and there is an acceptance gate that `setInterval|watchPosition` appear nowhere in `chain.ts`. The post-live 1s ticker in `main.ts` re-runs `acquireFix` rather than streaming. Phase 6 picks poll-vs-`watchPosition` without a rewrite. See plan 03-02 task 2 (`chain.ts`) and plan 03-03 task 3 (the post-live cadence).
|
||
|
||
3. **Does the Phase 3 exit gate have real device access?**
|
||
- What we know: Success Criterion 5 requires an Android + iOS + Desktop + plain-browser pass. D-11 says device batteries are spent at the exit gate.
|
||
- What's unclear: whether the executor has physical Android and iOS devices available at all. If not, the criterion is unverifiable and the phase cannot honestly close.
|
||
- **Recommendation:** the planner must confirm device availability **before** writing the exit-gate task, and A1 (the Android stale-fix) can only be retired with a real Android phone.
|
||
- **RESOLVED — handled as a blocking human checkpoint, not a pre-flight assumption.** Availability cannot be determined in advance by the executor, so plan 03-04 carries it as a blocking `checkpoint:human-verify` (task 3) with the five legs enumerated, and an explicit instruction that missing hardware means Success Criterion 5 is recorded as UNVERIFIED with the blocker in `.planning/STATE.md` — the phase is never closed claiming verification that did not happen. The automated half (capture harness + Desktop chat-pin leg) is built first so only the genuinely manual part is checkpointed.
|
||
|
||
4. **Should the theme package expose `basemap/liberty-rams.json` via a subpath export, or should `web/` import it relatively?**
|
||
- What we know: `@araharah/theme` has no `exports` map today (verified), so deep relative imports work; Vite resolves JSON imports natively.
|
||
- What's unclear: which reads better as the long-term contract for Phases 4–6.
|
||
- **Recommendation:** add a proper `exports` entry (`.` → `./dist/rams.css`, `./basemap/*`, `./fonts/*`). One small change now, avoids four phases of ad-hoc relative paths. OpenCode's discretion per D-02.
|
||
- **RESOLVED — deep package import, resolved at build time.** `web/src/map/map.ts` imports `@araharah/theme/basemap/liberty-rams.json` directly. `moduleResolution: "bundler"` already permits the deep subpath, so no `exports` map change is needed for Phase 3, and Vite inlines the JSON into `web/dist` at build (proven by the BA4 gate). The only TypeScript-side requirement is `resolveJsonModule: true`, pre-authorized in `web/tsconfig.json` by plan 03-01 task 1. If a later phase needs more subpaths, an `exports` map can be added then without touching Phase 3's code. See plan 03-03 task 2.
|
||
|
||
---
|
||
|
||
## Environment Availability
|
||
|
||
| Dependency | Required By | Available | Version | Fallback |
|
||
|------------|------------|-----------|---------|----------|
|
||
| Node.js | Vite 8 (needs `^20.19.0 \|\| >=22.12.0`) | ✓ | **24.18.0** | — |
|
||
| npm workspaces | D-02 `web/` workspace | ✓ | 11.16.0 | — |
|
||
| wrangler | D-12 dev on 8787, D-15 deploy | ✓ | 4.151.3 (already in `bot/`) | — |
|
||
| TypeScript | D-01 | ✓ | 5.9.3 (`bot/` pin) | — |
|
||
| Playwright + browsers | D-11 harness, DSGN-02 fidelity artifact | ✓ | chromium-1248, webkit-2272, firefox-1511 cached | — |
|
||
| esbuild (Vite dep) | Vite build | ✓ | in `node_modules/.bin` | — |
|
||
| Cloudflare KV (`chat_sessions`) | Phase 2 session, Phase 3 pin relay | ✓ | binding configured in `wrangler.jsonc` | — |
|
||
| **OpenFreeMap tiles** | D-14 runtime basemap | ✓ (network) | `https://tiles.openfreemap.org/planet` | — |
|
||
| **Android device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's Android claim and for retiring A1. |
|
||
| **iOS device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's iOS claim. |
|
||
| Telegram Desktop | Success Criterion 5 (GEO-02 path) | **?** | — | **None.** Blocking for the Desktop leg of the matrix. |
|
||
| Telegram BotFather registration | origin restriction (Bot API 10.2) | **?** | — | **None.** Must point at the served origin. |
|
||
|
||
**Missing dependencies with no fallback:** real Android and iOS devices, and a Telegram Desktop install. The roadmap's own Success Criterion 5 requires all of them. **The planner must surface this as a checkpoint before the exit-gate task, not discover it at execution.**
|
||
|
||
**Missing dependencies with fallback:** none identified — the toolchain is complete.
|
||
|
||
---
|
||
|
||
## Validation Architecture
|
||
|
||
> `.planning/config.json` → `workflow.nyquist_validation: true`. Included per protocol.
|
||
|
||
### Test Framework
|
||
|
||
| Property | Value |
|
||
|----------|-------|
|
||
| Framework | **Hand-rolled zero-dependency runner** — `web/test/runner.mjs` + `harness.mjs`, mirroring `bot/test/` and `theme/scripts/audit.test.mjs` (repo precedent, D-11's "hand-rolled test runner pattern prevails") |
|
||
| Config file | none (no vitest/jest config — by design) |
|
||
| Quick run command | `npm test -w web` |
|
||
| Full suite command | `npm test -w web && npm run typecheck -w web && npm run build -w web` |
|
||
| Browser/visual checks | `npm run capture -w web` — Playwright, extends the `theme/scripts/screenshot.mjs` pattern (static server + chromium + `MIN_BYTES` blank-bar check already established there) |
|
||
|
||
⚠️ **ESM caveat:** `maplibre-gl@6.15.0` exports **only** an `import` condition. The runner uses dynamic `import()` on `.mjs` files with `"type": "module"`, which resolves it correctly. Any test that `require()`s maplibre will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Unit tests should therefore test the **chain logic against injected fakes**, never against a real MapLibre instance — which is better design anyway.
|
||
|
||
### Phase Requirements → Test Map
|
||
|
||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||
|--------|----------|-----------|-------------------|-------------|
|
||
| **GEO-01** | `LocationManager.init()` then `getLocation()` yields a `Fix` with `source:'telegram'` | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-01** | A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-01** | `getLocation()` returning `null` (denied) advances to the next source, not to an error | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-01** | `getLocation()` that never calls back resolves `null` after the wrapper timeout (Pitfall 3) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-01** | Two identical successive fixes are detected as stale and route to the recovery path (Pitfall 2) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-01** | Grant persists across a fresh boot — re-read on revalidation, no re-prompt | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-02** | Bot sends `ReplyKeyboardMarkup` with `request_location: true`, `resize_keyboard`, `one_time_keyboard` | unit (bot-side, extends `bot/test/`) | `npm run bot:test` | ❌ Wave 0 |
|
||
| **GEO-02** | Bot receives `message.location` and writes it with an explicit `expirationTtl` | unit (bot-side) | `npm run bot:test` | ❌ Wave 0 |
|
||
| **GEO-02** | App enters `AWAITING PIN`, polls, and consumes the pin into `live` | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-02** | Poll is bounded — a never-delivered pin returns to the denied card, never wedges (Pitfall: indefinite `AWAITING PIN`) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-02** | Desktop Telegram (no `LocationManager`, no `navigator.geolocation`) reaches the denied card with **identical** copy | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-03** | No storage API referenced anywhere in `web/src` (grep gate) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **GEO-03** | Runtime probe: after boot → live in the harness, `localStorage`/`sessionStorage`/cookies/IndexedDB are all empty | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||
| **GEO-03** | Every KV `put` in the new location module carries `expirationTtl` | unit (bot-side grep gate) | `npm run bot:test` | ❌ Wave 0 |
|
||
| **PX-02** | `colorScheme:'dark'` sets `data-tg-theme="dark"`; light sets `light` | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **PX-02** | Missing `colorScheme` falls back to `header_bg_color` luminance ≥ 0.5 | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **PX-02** | `themeChanged` re-sets the attribute and touches nothing else (map style object identity unchanged — Pitfall 7) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **PX-02** | Dark-theme capture exists in the fidelity artifact | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||
| **D-09** | Boot renders stage 1 → stage 2 → live with no blank intermediate frame | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||
| **D-10** | `BackButton.hide()` at init; the handler is a total function (closes when no overlay — no dead-ends) | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **D-03** | Session token lives only in module scope; never written to any storage | unit | `npm test -w web` | ❌ Wave 0 |
|
||
| **D-14** | `web/dist` contains the inlined style JSON, self-hosted fonts, and a MapLibre worker file; no CDN references in the built output | unit (build-artifact gate) | `npm run build -w web` | ❌ Wave 0 |
|
||
| **D-08** | Boot output contains no route line, no search furniture, no layer toggles | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||
| Copy gate | `strings.ts` exports exactly the UI-SPEC copy table, en + ms; no inline copy in shell modules | unit | `npm test -w web` | ❌ Wave 0 |
|
||
|
||
### Sampling Rate
|
||
- **Per task commit:** `npm test -w web`
|
||
- **Per wave merge:** `npm test -w web && npm run bot:test && npm run typecheck -w web && npm run build -w web`
|
||
- **Phase gate:** full suite green + `npm run capture -w web` produced (DSGN-02 fidelity artifact) + **the device-matrix pass (manual, blocking)**
|
||
|
||
### Wave 0 Gaps
|
||
|
||
- [ ] `web/test/runner.mjs` — hand-rolled runner (copy the `bot/test/runner.mjs` shape)
|
||
- [ ] `web/test/harness.mjs` — test registry
|
||
- [ ] `web/src/mock/telegram-mock.ts` — D-11 fake `WebApp`; the keystone that makes every location test above possible without a device
|
||
- [ ] `web/test/location-chain.test.mjs` — covers GEO-01, GEO-02 (client half)
|
||
- [ ] `web/test/no-storage.test.mjs` — GEO-03 grep gate
|
||
- [ ] `web/test/theme.test.mjs` — PX-02 slot-switch gate
|
||
- [ ] `web/test/copy-gate.test.mjs` — UI-SPEC copy table gate
|
||
- [ ] `bot/test/location-pin.test.mjs` — GEO-02 bot half + TTL grep assertion
|
||
- [ ] `web/scripts/capture.mjs` — Playwright harness captures (extend `theme/scripts/screenshot.mjs`)
|
||
- [ ] **`GET /api/location` Worker route + KV module** — the missing link between the bot's received pin and the open Mini App (Open Question 5)
|
||
|
||
**None of these exist yet — `web/` does not exist.** Every test above is Wave 0.
|
||
|
||
---
|
||
|
||
## Security Domain
|
||
|
||
### Applicable ASVS Categories
|
||
|
||
| ASVS Category | Applies | Standard Control |
|
||
|---------------|---------|-----------------|
|
||
| V2 Authentication | **yes** | Phase 2's `/api/session` (HMAC-validated `initData` → opaque token). The app posts the **verbatim** `initData` string — never `initDataUnsafe` (Telegram's own docs warn on this). Token held in module scope only (D-03). |
|
||
| V3 Session Management | **yes** | 900s TTL (already enforced server-side, `SESSION_TTL_SECONDS`), rotate-on-new, revalidate on every fresh boot. No client-side session persistence at all — which is stricter than `SecureStorage`/`CloudStorage` and therefore immune to their availability quirks. |
|
||
| V4 Access Control | **yes** | The new `GET /api/location` route must validate the bearer token via `getClaimByToken` before returning anything. A pin must never be readable without a live, unexpired session token for that chat. |
|
||
| V5 Input Validation | yes | Validate `Location` message fields (`latitude`/`longitude` numeric, sane ranges) before use; cap `horizontal_accuracy` (Telegram documents 0–1500 m); reject absurd coordinates rather than flying the camera to null island. |
|
||
| V6 Cryptography | **no new crypto** | HMAC-SHA256 stays entirely server-side. The client never holds `BOT_TOKEN`. No crypto in `web/`. |
|
||
| **Origin restriction (Bot API 10.2)** | **yes — new** | All `Telegram.WebApp.*` calls originate from the registered Mini App origin. Auto-enabled since 2026-07-20. |
|
||
|
||
### Known Threat Patterns for this stack
|
||
|
||
| Pattern | STRIDE | Standard Mitigation |
|
||
|---------|--------|---------------------|
|
||
| Forging a session by posting a crafted `initData` | Spoofing | Phase 2's constant-time HMAC compare + `auth_date` freshness window. **Do not** add a client-side shortcut. |
|
||
| Reading another user's shared pin via `GET /api/location` | Information Disclosure | Token-scoped KV lookup — a token resolves to exactly one `chat_id`; the route must derive the KV key from the *claim*, never from a client-supplied id. |
|
||
| Coordinate persisted to a client store | Information Disclosure (privacy regression) | D-07 + the grep gate + the runtime probe. This is the GEO-03 spot-check made mechanical. |
|
||
| Stale/cached fix presented as `LIVE` | Repudiation (dishonest state) | Successive-fix staleness detection + `FIX Ns AGO` telemetry. The app must never claim freshness it has not measured. |
|
||
| Malicious bot update injecting a pin | Tampering | Only Telegram-authenticated webhook updates reach grammY; the secret-token compare runs before parsing (Phase 2's fail-closed guard). |
|
||
| Map style JSON fetched from a mutable URL at runtime | Tampering | D-14 already forbids this — the style is inlined at build time. Tiles are the only runtime network dependency. |
|
||
| Telegram WebApp called cross-origin | Elevation of Privilege | Bot API 10.2 restriction — keep all calls on the registered origin. |
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
### Primary (HIGH confidence)
|
||
- **https://core.telegram.org/bots/webapps** — `LocationManager` (`init`/`getLocation`/`openSettings`/`isInited`/`isLocationAvailable`/`isAccessRequested`/`isAccessGranted`), `LocationData` (all 9 fields incl. nullable `course`/`horizontal_accuracy`), `colorScheme`, `ThemeParams`, `BackButton`, `SafeAreaInset`/`ContentSafeAreaInset`, `isVersionAtLeast`, the complete event table (`locationManagerUpdated`, `locationRequested`, `themeChanged`, `safeAreaChanged`, `activated`/`deactivated`), `ready()`/`expand()`/`viewportStableHeight`. Fetched 2026-10-22. **This is the authority for the entire location chain.**
|
||
- **https://core.telegram.org/bots/api-changelog** — Bot API **10.2, July 14 2026**: *"Hardened the security of Mini Apps by disallowing the usage of Mini App methods from origins different from the original Mini App domain. The protection will be automatically enabled for all Mini Apps on July 20, 2026."* Also confirms LocationManager landed in 8.0 (Nov 2024). Fetched 2026-10-22.
|
||
- **https://core.telegram.org/bots/api** (HTML) — `KeyboardButton.request_location` (*"If True, the user's current location will be sent when the button is pressed. Available in private chats only."*), `Location` type fields, `ReplyKeyboardMarkup` (`resize_keyboard`, `one_time_keyboard`). Fetched 2026-10-22.
|
||
- **https://maplibre.org/maplibre-gl-js/docs/guides/v5-to-v6-migration-guide/** — ESM-only, `setWorkerUrl()` bundler-only, **WebGL2 required** + `GPUInitializationError`, `styleimagemissing` → `setMissingStyleImageResolver`, no default import. Fetched 2026-10-22.
|
||
- **https://www.maplibre.org/maplibre-gl-js/docs/ §Installation** — the exact Vite snippet (`?worker&url` → `setWorkerUrl`), CSS import path, `import 'maplibre-gl/dist/maplibre-gl.css'`, CSP directives. Fetched 2026-10-22.
|
||
- **https://developers.cloudflare.com/workers/static-assets/binding/** — `assets.directory`, `assets.binding`, **`run_worker_first` as a glob array** (`["/api/*", "!/api/docs/*"]`, max 100 entries), `not_found_handling`, `env.ASSETS.fetch(request)` passthrough. Fetched 2026-10-22. **This is the exact D-13 config shape.**
|
||
- **https://playwright.dev/docs/emulation#geolocation** — `context.setGeolocation()`, `grantPermissions(['geolocation'])`, `colorScheme`, `devices[...]` — the D-11 harness and exit-gate matrix primitives. Fetched 2026-10-22.
|
||
- **npm registry (`npm view`, run this session)** — `maplibre-gl@6.15.0` (2026-10-18) with `exports` containing **only** an `import` condition; `vite@8.3.5` (2026-10-21) `engines.node: ^20.19.0 || >=22.12.0`; `@types/telegram-web-app@10.1.0` (2026-10-04); `telegram-web-app` / `telegram-web-app.js` / `@telegram-apps/web-app` all E404 or abandoned.
|
||
- **Repo inspection** — `bot/src/webhook.ts` (`/api/session` contract, 4 KB cap, fail-closed webhook), `bot/src/kv/sessions.ts` (900s TTL, rotate-on-new, `getClaimByToken`), `bot/test/{runner,harness}.mjs` (test pattern), `bot/wrangler.jsonc` (`chat_sessions` binding), `theme/src/components.css` (`.btn-primary`, `.card`, `.text-label-sm`, `.checkbox` all confirmed present), `theme/src/theme.css` (`--shell-bg`/`--shell-on-surface` + `prefers-color-scheme` defaults), `theme/basemap/liberty-rams.json` (v8 style, 110 layers, `openmaptiles` + `ne2_shaded` sources → OpenFreeMap).
|
||
|
||
### Secondary (MEDIUM confidence)
|
||
- **https://github.com/Telegram-Mini-Apps/issues/issues/56** — Android stale-fix bug. Open since Dec 18 2024, labels `bug`/`in progress`/`Telegram for Android`, 6+ independent confirmations. Reporter's workaround: *"Integrating the browser's `navigator.geolocation.getCurrentPosition` … allows the Telegram mini app to retrieve the same coordinates afterward"*; *"Opening Google Maps on the device and then returning to the Telegram mini app also triggers the location to refresh."* Commenter `stefda`: *"This issue renders the new LocationManager api useless."* **The basis for A1 — treat as a hypothesis to verify at the device gate, not as established fact.**
|
||
- **https://github.com/Telegram-Mini-Apps/issues/issues/77** — Desktop geolocation absent. Open Apr 2025 → last activity Jan 2026. Second reporter (Sep 2025) confirms the button is *greyed out* on Telegram macOS and Desktop. Establishes GEO-02 as the only Desktop origin path.
|
||
- **https://crossminiapp.com/en/blog/webview-geolocation-broken-runtime** (Oct 20, 2026) — corroborating platform analysis: Android's `WebSettings.setGeolocationEnabled()` defaults to **false**; needs `WebChromeClient.onGeolocationPermissionsShowPrompt` + runtime permission + HTTPS/localhost; iOS routes every request through `WKUIDelegate.webView(_:requestGeolocationPermissionFor:…)`, and **a page that calls geolocation on load or without a direct user gesture is blocked or re-prompted**. Vendor ROMs (EMUI/MIUI) add background restrictions. *Vendor marketing site — used only for the well-known Android/iOS platform facts, which it cites to primary developer.android.com / developer.apple.com sources.*
|
||
- **https://adsgram.ai/blog/adsgram/telegram-mini-app-tma-development-mistakes-and-how-to-avoid-them** (Aug 23, 2026) — two independently valuable claims: (a) `localStorage` **may not persist** inside Telegram's WebView on iOS and some Linux desktop builds (corroborates D-03's choice); (b) the Bot API 10.2 origin restriction and its Jul 20 2026 enforcement date — which I then **confirmed against Telegram's own changelog** and therefore treat as HIGH. *Ads vendor blog; treat mechanism claims as MEDIUM.*
|
||
|
||
### Tertiary (LOW confidence — flagged for validation)
|
||
- Various Stack Overflow / Reddit / YouTube results on WKWebView geolocation permission prompts and iOS 14+ `CLLocationManager` authorization changes. These surfaced during search and were **not** used for any specific claim; they indicate the area is genuinely messy and support the general "gate everything, never assume" posture rather than any particular implementation detail.
|
||
- `@telegram-apps/sdk` (the community SDK) appeared in issue #77's reproduction. Not evaluated further — D-01 rules it out (no framework), and the official API surface is sufficient.
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Confidence breakdown:**
|
||
- **Standard stack: HIGH** — every version verified against the live npm registry this session; Vite/MapLibre/Telegram types resolved and their failures documented.
|
||
- **Architecture (platform API surface): HIGH** — the entire `LocationManager`, `ThemeParams`, safe-area, and event surface read directly from `core.telegram.org/bots/webapps` this session.
|
||
- **Architecture (the fallback mechanisms): HIGH** — `request_location` and the `Location` message shape confirmed against `core.telegram.org/bots/api`.
|
||
- **Pitfalls: HIGH** — Pitfalls 1, 3, 5, 6, 7 are direct consequences of documented API behavior, not inference.
|
||
- **The Android stale-fix workaround (A1): MEDIUM** — community-sourced, reproduced by multiple reporters, **not verified on our devices**. Explicitly listed in the Assumptions Log and gated behind a degradation path.
|
||
- **`localStorage` unreliability in the WebView: MEDIUM** — single secondary source; corroborates rather than drives the design.
|
||
- **`@types/telegram-web-app` accuracy (A2): MEDIUM** — package exists and is current; its `LocationManager` shape was not diffed against the official docs.
|
||
- **Device-matrix feasibility: UNKNOWN** — the availability of real Android/iOS devices is an open question that blocks Success Criterion 5 and cannot be resolved by research.
|
||
|
||
**What might I have missed:**
|
||
- The **WebGL2-absent shell state** (Open Question 1) is a genuine gap in an *approved* UI-SPEC. The planner should amend the contract rather than silently extend it.
|
||
- The **new `GET /api/location` Worker route** is a real scope item that appears in neither Phase 2's code nor Phase 3's stated deliverables, yet GEO-02 cannot work without it. Budget it explicitly.
|
||
- I did **not** research Telegram's *test server* (`test.telegram.org`) as an alternative to physical devices for the matrix. It could shorten iteration but does not substitute for real-device verification of a WebView-specific bug.
|
||
|
||
**Research date:** 2026-10-22
|
||
**Valid until:** 2026-11-05 (14 days) — **shorter than usual, deliberately.** The Android stale-fix bug (issue #56) and the Telegram Mini App platform are both moving targets; a fix landing would change the recommended chain. Re-check `core.telegram.org/bots/api-changelog` and issue #56 before executing. |