Files
Arah2/docs/ARCHITECTURE.md
T
2026-10-23 19:18:43 +08:00

502 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# arah² — Architecture
## System Overview
arah² is a Telegram-first GPS navigator: a grammY bot (`arah2bot`) running as a single
Cloudflare Worker serves both the Telegram webhook **and** the static Mini App bundle
served from the same origin. The Mini App (`@araharah/web`, a Vite + MapLibre GL
application) authenticates with the Worker by posting its verbatim
`Telegram.WebApp.initData` to `POST /api/session`, receives a short-lived opaque bearer
token, and then acquires a position through a three-link chain (Telegram
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). All server state
lives in a TTL-only Cloudflare KV namespace, so the system has no database, no persistent
storage, and no analytics. `@araharah/theme` is the shared design-token package: it
compiles Tailwind v4 CSS consumed by the web app and is the derivation source for the
Rams basemap style the map renders.
Architecturally this is a **three-workspace npm monorepo in a layered/hexagonal
arrangement**: a stateless edge worker with a KV-backed repository layer, a
framework-free TypeScript client with its own repository + adapter layers, and a pure
build-time token/cartography package that both consume.
```mermaid
graph TD
classDef client fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef worker fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
A["Telegram client<br/>chat + Mini App webview"]:::client
B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
C["grammY handlers<br/>commands + location-fallback"]:::worker
D["Security<br/>initdata.ts HMAC"]:::worker
E["KV chat_sessions<br/>TTL-only"]:::store
F["@araharah/web Mini App<br/>src/main.ts"]:::client
G["Session client + store<br/>src/session/"]:::client
H["Location chain<br/>src/location/"]:::client
I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client
J["@araharah/theme<br/>dist/rams.css + basemap"]:::build
A -->|"POST /telegram/webhook"| B
A -->|"loads Mini App"| B
B --> C
B --> D
B --> E
C --> E
C -->|"sendMessage reply keyboard"| A
F -->|"POST /api/session initData"| B
F -->|"GET /api/location Bearer"| B
F -->|"POST /api/location-fallback"| B
G --> F
H --> G
F --> H
H --> I
J -->|"compiled CSS + inlined style JSON"| F
```
## Component Diagram
```mermaid
graph LR
classDef ws fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef wsfill fill:#FFF8F0,stroke:#EB8008,stroke-width:2px,color:#1A1A1A
classDef botfill fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A
subgraph ROOT["root — npm workspaces"]
direction TB
subgraph THEME["theme · @araharah/theme"]
direction TB
T1["src/tokens.css<br/>@theme static, 55 colors"]:::wsfill
T2["src/components.css"]:::wsfill
T3["basemap/build.mjs"]:::wsfill
T4["dist/rams.css"]:::wsfill
T5["basemap/liberty-rams.json"]:::wsfill
T1 --> T4
T2 --> T4
T1 -->|"derives roles"| T3
T3 --> T5
end
subgraph WEB["web · @araharah/web"]
direction TB
W1["src/main.ts"]:::wsfill
W2["src/session/"]:::wsfill
W3["src/location/"]:::wsfill
W4["src/map/"]:::wsfill
W5["src/ui/ + src/telegram/"]:::wsfill
W6["src/shell.css"]:::wsfill
W7["dist/ (served)"]:::wsfill
W1 --> W2
W1 --> W3
W1 --> W4
W1 --> W5
W1 --> W7
W4 --> W7
W5 --> W7
end
subgraph BOT["bot · @araharah/bot"]
direction TB
B1["src/webhook.ts"]:::botfill
B2["src/security/initdata.ts"]:::botfill
B3["src/bot/"]:::botfill
B4["src/kv/"]:::botfill
B5["src/config.ts Env"]:::botfill
B1 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
end
KV[("KV namespace chat_sessions<br/>session:* 900s · chat:* · loc:* 300s")]:::store
TG["Telegram Bot API"]:::ext
OFM["OpenFreeMap tiles"]:::ext
end
WEB -->|"workspace dep @araharah/theme"| THEME
BOT -->|"assets binding ../web/dist"| WEB
B1 --> KV
B4 --> KV
B3 <-->|"webhookCallback / sendMessage"| TG
W3 <-->|"chat pin"| TG
W4 --> OFM
```
## Workspaces Dependency Graph
```mermaid
graph TD
classDef pkg fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef dep fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef out fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
ROOT["araharah (private root)"]:::out
TH["@araharah/theme"]:::pkg
BT["@araharah/bot"]:::pkg
WB["@araharah/web"]:::pkg
KVS[("KV chat_sessions")]:::dep
TGAPI["Telegram Bot API"]:::dep
ROOT -->|"workspaces"| TH
ROOT -->|"workspaces"| BT
ROOT -->|"workspaces"| WB
WB -->|"dependencies: @araharah/theme *"| TH
BT -->|"static assets ../web/dist"| WB
BT --> KVS
BT --> TGAPI
WB -->|"api routes"| BT
```
`@araharah/web` declares `"@araharah/theme": "*"` — the only workspace-to-workspace
runtime import in the repo (`@araharah/theme/dist/rams.css` in `web/src/shell.css`,
`@araharah/theme/basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no
workspace dependency; its coupling to web is a **build-order coupling only** — the
root `build` script builds theme then web, and the Worker's `[assets]` binding points
at `../web/dist`.
## Data Flow
### 1. Telegram command surface (bot → chat)
Telegram delivers an update to `POST /telegram/webhook`. The Worker first fails closed
if `WEBHOOK_SECRET` is unset, then hands the request to grammY's
`webhookCallback(bot, "cloudflare-mod", …)`, which constant-time-compares the
`X-Telegram-Bot-Api-Secret-Token` header. `createBot(env)` is instantiated **per
request** (the token only arrives with `env`), registers the command stack, and grammY
dispatches:
```
POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
→ registerStart / registerHelp / registerPrivacy → pickLocale(language_code)
→ ctx.reply(plain text, link_previews disabled)
```
All user-facing copy comes from `bot/src/i18n/strings.ts`; `bot/scripts/register-commands.mjs`
derives the persistent `/setMyCommands` menu descriptions from the same `helpMenu` lines,
so menu and reply cannot drift.
### 2. Mini App boot (session handshake)
```mermaid
sequenceDiagram
participant WA as WebApp webview
participant MAIN as web/src/main.ts
participant SESS as session/client.ts
participant WK as Worker /api/session
participant ID as security/initdata.ts
participant KV as KV chat_sessions
MAIN->>MAIN: applySafeArea(resolveSafeInsets(wa))
MAIN->>MAIN: bootMap() mounts MapLibre FIRST
MAIN->>MAIN: register strip, cards, recenter
MAIN->>SESS: createSession(wa.initData) [verbatim string]
SESS->>WK: POST /api/session, body = initData (≤ 4 KB)
WK->>ID: validateInitData(raw, BOT_TOKEN)
ID-->>WK: ok | malformed | tampered | stale
WK->>KV: mintSession(chat_sessions, claim)
KV-->>WK: opaque UUID token
WK-->>SESS: 200 { token }
SESS->>SESS: setSession(token) [module scope only]
```
The HMAC path: `dataCheckString` = all initData fields **minus `hash` only**, sorted by
field name, joined with `\n`; secret key = `HMAC-SHA256(key="WebAppData",
message=botToken)`; check = `HMAC-SHA256(key=secretKey, message=dataCheckString)`
compared in constant time against `hash`. `signature` participates in the check.
`auth_date` older than 900 s is rejected as `stale`.
### 3. Location acquisition chain
```mermaid
flowchart TD
classDef s fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef ok fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef no fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
START["acquireFix(deps, lastAccepted)"]:::ok
L1{"telegramLocationSupported<br/>isVersionAtLeast 8.0"}:::ok
NUDGE{"looksStale(prev, fix)<br/>haversine < 0.01 m"}:::ok
L2{"webviewLocationSupported"}:::ok
L3{"getToken() !== null"}:::ok
LIVE["return { fix, state: live }"]:::s
DEN1["denied / timeout"]:::no
DEN2["denied / no-permission"]:::no
START --> L1
L1 -->|"yes"| NUDGE
NUDGE -->|"no"| LIVE
NUDGE -->|"yes"||"discarded webview read"| L1
L1 -->|"no"| L2
L2 -->|"yes + fix"| LIVE
L2 -->|"no fix"| L3
L3 -->|"yes: bounded 60s poll"| LIVE
L3 -->|"poll expired"| DEN1
L3 -->|"no token"| DEN2
```
Link 1 (`telegram.ts`) calls `LocationManager.init()` then `getLocation()`, with a hard
8 s wrapper timeout because the API documents no timeout. A byte-identical fix to the
last accepted one routes to the nudge: one **discarded** webview read refreshes the
Android native cache, then telegram is retried. Link 2 (`webview.ts`) calls
`navigator.geolocation.getCurrentPosition` with `maximumAge: 0`. Link 3
(`chat-pin.ts`) polls `GET /api/location` every 2 s for at most 60 s.
### 4. Chat-pin relay (the Desktop-Telegram path)
```mermaid
sequenceDiagram
participant U as User (chat)
participant B as Bot handler
participant KV as KV loc:chat_id
participant MA as Mini App
participant W as Worker
MA->>W: POST /api/location-fallback (Bearer token)
W->>W: getClaimByToken → claim.chat_id
W->>B: requestLocationFallback(bot, chat_id, locale)
B->>U: reply keyboard, request_location: true
U->>B: taps button, native location picker
B->>KV: writePin(kv, chat_id, { lat, lng, acc }) expirationTtl 300
loop every 2 s, bounded 60 s
MA->>W: GET /api/location (Bearer token)
W->>KV: getPin(chat_sessions, claim.chat_id)
KV-->>W: LocationPin | null
W-->>MA: { pin } (200) or { pin: null }
end
```
Both `/api/location` and `/api/location-fallback` derive the target `chat_id` from the
**resolved session claim**, never from client input, and derive locale from
`claim.language_code`. Telegram has no push channel from chat to webview, so this poll
is server-mediated by necessity.
### 5. Worker routing table
| Method | Path | Handler in `bot/src/webhook.ts` | Purpose |
| --- | --- | --- | --- |
| POST | `/telegram/webhook` | inline in `fetch()` | grammY webhook adapter + secret-token compare |
| POST | `/api/session` | `handleSession` | initData → one-time opaque session token |
| GET | `/api/location` | `handleLocation` | Bearer-authenticated chat-pin relay |
| POST | `/api/location-fallback` | `handleLocationFallback` | Ask the bot to send the reply keyboard |
| * | anything else | — | 404 |
`run_worker_first: ["/api/*", "/telegram/*"]` guarantees the Worker's `fetch` owns these
paths before the static asset fallback runs; every other path resolves to the `web/dist`
bundle.
## Build & Deploy Topology
```mermaid
graph LR
classDef step fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
classDef art fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef cloud fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
T1["theme: tailwindcss -i src/theme.css -o dist/rams.css --minify"]:::step
T2["theme: basemap/build.mjs<br/>fetch liberty → derive roles → liberty-rams.json"]:::step
W1["web: vite build → dist/"]:::step
W2["web: vite build --mode harness → dist-harness/"]:::step
B1["bot: wrangler deploy"]:::step
B2["bot: scripts/set-webhook.mjs"]:::step
AR1["theme/dist/rams.css + fonts"]:::art
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style)"]:::art
AR3["web/dist-harness (never deployed)"]:::art
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist"]:::cloud
KVS[("KV chat_sessions")]:::store
T1 --> W1
T2 --> W1
T1 --> AR1
W1 --> AR2
W2 --> AR3
AR2 --> EDGE
B1 --> EDGE
B1 --> B2
EDGE --> KVS
```
The root scripts encode the ordering: `npm run build` = `build -w theme && build -w web`;
`npm run deploy` = `npm run build && npm run deploy -w bot` (which is
`wrangler deploy && node scripts/set-webhook.mjs`). `bot/wrangler.jsonc` binds
`assets.directory` to `../web/dist`, so `web/dist` must exist before a bot deploy.
The two web builds never share an outDir: `vite.config.ts` sets
`outDir: "dist-harness"` only under `--mode harness` and defines
`__ARAHARAH_HARNESS__` to `true` there and `false` everywhere else, so the mock
installer and the `fetch` stubs in `web/index.html` are dead-code-eliminated from
production. `web/test/build-artifact.test.mjs` (BA1–BA6) proves the shipped bundle
references no third-party CDN, self-hosts the woff2 fonts, contains the inlined Rams
style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
<!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. -->
## Key Abstractions
### `@araharah/bot`
| Abstraction | File | Description |
| --- | --- | --- |
| `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (must be non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`. |
| `default { fetch }` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` — four routes, 404 otherwise. Bot is constructed per request. |
| `validateInitData(raw, botToken, opts)` | `bot/src/security/initdata.ts` | Pure HMAC-SHA256 validation of Telegram WebApp initData. Depends only on `crypto.subtle`, so it runs unchanged in Workers and bare Node. Returns `InitDataResult`. |
| `SessionClaim` / `mintSession` / `getClaimByToken` / `getTokenForChat` | `bot/src/kv/sessions.ts` | TTL-only session layer. `SESSION_TTL_SECONDS = 900`. Key scheme `session:<token>` → claim, `chat:<chat_id>` → current token. Rotate-on-new with a best-effort reconciliation sweep. |
| `LocationPin` / `writePin` / `getPin` / `clearPin` | `bot/src/kv/location-pin.ts` | TTL-only pin layer. `PIN_TTL_SECONDS = 300`. Key `loc:<chat_id>`. `getPin` range-validates on read. |
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, and the location-fallback message handler. |
| `requestLocationFallback(bot, chatId, locale)` | `bot/src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard. Single caller: `handleLocationFallback`. |
| `strings` / `pickLocale` | `bot/src/i18n/strings.ts` | The bot's entire copy surface (`en`, `ms`). `pickLocale` maps an `ms`/`ms-*` language code to Malay, everything else to English. |
| `KvLike` | `bot/src/kv/*.ts` | Structural KV interface — lets tests pass a fake without depending on Workers types. |
### `@araharah/web`
| Abstraction | File | Description |
| --- | --- | --- |
| `Fix` | `web/src/location/types.ts` | The single normalized position shape every source reduces to: `lat`, `lng`, `accuracyM`, `headingDeg`, `ts`, `source`. Freshness lives in the fix. |
| `LocationState` | `web/src/location/types.ts` | Discriminated union: `idle` / `validating` / `acquiring` / `awaiting-pin` / `live` / `stale` / `denied`. |
| `acquireFix(deps, lastAccepted)` | `web/src/location/chain.ts` | The three-link state machine. Pluggable cadence — no `setInterval`, no `watchPosition`. |
| `isFixStale(fix, nowMs)` / `fixAgeSeconds` | `web/src/location/chain.ts` | The 45 s signal-lost threshold and the `FIX Ns AGO` telemetry helper. |
| `TelegramWebAppLike` | `web/src/telegram/webapp.ts` | Hand-rolled ~12-member contract for `window.Telegram.WebApp`. `getWebApp()` may honestly return `null` off-Telegram. |
| `createSession(initData)` | `web/src/session/client.ts` | POSTs the verbatim initData string; returns `{ ok: true, token }` or a `malformed`/`tampered`/`stale`/`network` reason. |
| `setSession` / `getSession` / `isSessionStale` / `clearSession` | `web/src/session/store.ts` | Module-scope token holder. No persistence call anywhere in the file. |
| `bootMap(container)` | `web/src/map/map.ts` | MapLibre v6 bootstrap with the build-time-inlined Rams style. Returns `null` on `GPUInitializationError`. |
| `createPositionMarker()` | `web/src/map/marker.ts` | SVG accuracy circle + 20 px dot + heading cone. Cone hidden entirely when heading is `null`. |
| `createFollowController(map, onModeChange)` | `web/src/map/follow.ts` | following/free camera. Only a user gesture (`movestart` with `originalEvent`) flips to free. |
| `createStatusStrip(host)` | `web/src/ui/strip.ts` | The status band — state lamp + state word on the left, telemetry on the right. |
| `buildBootCard` / `buildDeniedCard` / `buildSignalLostCard` | `web/src/ui/cards.ts` | Rams card surfaces composed from theme component classes; zero inline copy. |
| `createRecenterControl(host, locale, onRecenter)` | `web/src/ui/recenter.ts` | 40×40 follow plate with three instant notch states. |
| `watchTheme(wa)` / `resolveBranch` | `web/src/telegram/theme.ts` | Telegram light/dark signal → `data-tg-theme` slot switch. MapLibre is deliberately absent. |
| `resolveSafeInsets` / `applySafeArea` | `web/src/telegram/safe-area.ts` | Union of CSS `env()` and `WebApp.safeAreaInset`, max per edge, emitted as CSS custom properties. |
| `wireBackButton(wa, hasOverlay)` | `web/src/telegram/backbutton.ts` | Total function — at the root state a click closes the app; no no-op handler. |
| `installMockWebApp(options)` | `web/src/mock/telegram-mock.ts` | Harness-only fake `window.Telegram.WebApp` with a dev-signed initData. Excluded from `web/dist`. |
| `localeFromInitData(raw)` | `web/src/i18n/strings.ts` | Parses the locale out of the raw initData string — the session response carries only `{ token }`. |
### `@araharah/theme`
| Abstraction | File | Description |
| --- | --- | --- |
| `@theme static` block | `theme/src/tokens.css` | 55 `--color-*` tokens plus spacing and typography. `static` forces every token into the compiled CSS so the package is consumable as plain custom properties. |
| `theme/src/theme.css` | `theme/src/theme.css` | Entry stylesheet — `@import`s tailwindcss, tokens, components; declares eight self-hosted `@font-face` rules; defines the `--shell-bg` / `--shell-on-surface` theme slots. |
| Component classes | `theme/src/components.css` | `.card`, `.btn-primary`, `.btn-secondary`, `.chip-*`, `.maneuver-*`, `.text-*`, `.toggle*`, `.input-field`, `.checkbox`. |
| `build.mjs` | `theme/basemap/build.mjs` | Forks OpenFreeMap `liberty`, deriving every cartographic color from `tokens.css` via `desaturate(tokenHex, satMultiplier, lightnessPerRole)` with saturation clamped to `[0.06, 0.12]`. |
| `liberty-rams.json` | `theme/basemap/liberty-rams.json` | The generated style. Accent guard fails the build if `#EB8008` / `#ED3F1C` / `#736B1E` leak into any layer. `building-3d` is removed (faux 3D). |
## Directory Structure Rationale
```
araharah/
├── package.json # private root; workspaces + build ordering only
├── bot/ # @araharah/bot — the Cloudflare Worker
│ ├── src/
│ │ ├── webhook.ts # the single fetch handler; four routes
│ │ ├── config.ts # Env contract
│ │ ├── bot/ # grammY factory, commands/, handlers/
│ │ ├── security/ # initdata.ts — pure, isolation-free crypto
│ │ ├── kv/ # sessions.ts, location-pin.ts — TTL-only repos
│ │ └── i18n/strings.ts # every user-facing bot string
│ ├── scripts/ # set-webhook, register-commands, set-menu-button
│ ├── test/ # hand-rolled zero-dependency runner + suites
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, KV
├── web/ # @araharah/web — the Mini App
│ ├── src/
│ │ ├── main.ts # boot choreography (order is load-bearing)
│ │ ├── session/ # client.ts (network), store.ts (memory)
│ │ ├── location/ # chain.ts + telegram/webview/chat-pin sources
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area
│ │ ├── map/ # map.ts, marker.ts, follow.ts
│ │ ├── ui/ # strip.ts, cards.ts, recenter.ts
│ │ ├── mock/ # harness-only Telegram fake
│ │ ├── i18n/strings.ts # every user-facing shell string
│ │ └── shell.css # Rams chrome; imports @araharah/theme/dist/rams.css
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain)
│ ├── scripts/capture.mjs # screenshot capture
│ └── vite.config.ts # dual-build dist/ vs dist-harness/
├── theme/ # @araharah/theme — design tokens + cartography
│ ├── src/ # tokens.css, components.css, theme.css, sheet.html
│ ├── fonts/ # self-hosted Geist + Space Grotesk woff2
│ ├── basemap/ # build.mjs + generated liberty-rams.json
│ ├── scripts/ # audit.mjs (token audit), screenshot.mjs
│ └── dist/rams.css # compiled output
├── stitch_minimalist_route_navigator/ # read-only design reference (mockups)
└── docs/
```
Rationale for the boundaries:
- **`bot/src/webhook.ts` owns routing; nothing else dispatches.** The Worker is the only
place that decides which path runs, so `run_worker_first` and the secret-token gate
apply uniformly.
- **`bot/src/security/` and `bot/src/kv/` are dependency-light on purpose.**
`initdata.ts` imports nothing from grammY and runs in both Workers and bare Node;
`kv/*.ts` take a structural `KvLike` so the suites pass fakes.
- **`bot/src/i18n/strings.ts` is the single copy source.** The commands register
script derives its menu text from the same `helpMenu` lines, and `bot/test/commands.test.mjs`
gates that the `/privacy` copy still matches the `SessionClaim` fields in
`sessions.ts`.
- **`web/src/location/` separates sources from the chain.** Each source is a small module
that normalizes to `Fix` and answers `null` for denial; `chain.ts` owns ordering and
knows nothing about any single source's API.
- **`web/src/session/` splits network from state.** `client.ts` owns fetch and error
vocabulary; `store.ts` owns the in-memory token and the 900 s staleness rule. Neither
ever touches storage.
- **`web/src/telegram/` is the only place that touches `Telegram.WebApp`.** Every other
module takes the narrow `TelegramWebAppLike` interface, which is what lets the mock,
the test fakes, and real clients satisfy the same contract.
- **`theme/` is pure build-time.** It exports no runtime code — a CSS bundle and a JSON
style. That is why `web` can depend on it without pulling a bundler into the token
package.
- **`stitch_minimalist_route_navigator/` is `@source`-scanned, never edited.** The theme
compiles every class the four mockups use so the design vocabulary stays provable
against the shipped CSS.
## Cross-Cutting Concerns
### Privacy by construction
Two grep gates pin this rather than documenting it:
- `bot/test/privacy.test.mjs` — every `put` on `chat_sessions` must carry an explicit
`expirationTtl` on the same call line. No write path can persist without a TTL.
- `web/test/no-storage.test.mjs` — comments stripped, every `web/src` file scanned; zero
occurrences of `localStorage`, `sessionStorage`, `indexedDB`, `caches`,
`document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`.
The persisted surface is exactly `SessionClaim` (user_id, chat_id, issued_at,
language_code) for 900 s and `LocationPin` (latitude, longitude, horizontal_accuracy)
for 300 s. The client holds the token and the last fix in module scope only.
### Access control
The `chat_id` used by `/api/location` and `/api/location-fallback` is always resolved
from the session claim, and the locale from `claim.language_code`. The request body of
the fallback route is never read — no chat id or language can arrive from the client.
### Honest degradation
Every degradation is a labeled state, never a silent one or a fabricated value:
`bootMap()` returning `null` renders the shell over `--color-surface-dim` with
`data-map="absent"`; a null heading hides the marker cone rather than guessing an
orientation; `getPin` range-validates so a corrupt record cannot fly the camera to null
island; the poll is bounded at 60 s so `AWAITING PIN` cannot persist; every source
timeout resolves `null` so the chain always advances.
### Logging
The Worker's only log statements are category-only — `malformed`, `tampered`, `stale`,
`unauthorized`, `misconfigured`, `telegram-send`. Never raw payloads, never user ids,
never timestamps. Rejection responses carry the category only.
### Testing
Both `bot/test` and `web/test` use the same hand-rolled zero-dependency runner
(`runner.mjs` + `harness.mjs`), auto-discovering `*.test.mjs`. `theme` runs
`scripts/audit.test.mjs` in the same shape. Gates are grep/introspection assertions
against source and build artifacts, not just behavioral assertions.
## What Is Not Built Yet
The following appear in `IDEAS.md` as roadmap items and are **not** in the codebase:
routing engines (Valhalla/OSRM), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
Cloudflare R2, Photon geocoding. The current system is exactly the two implemented
phases: the bot skeleton with initData-gated sessions and TTL-only KV, and the Mini App
shell with the three-link location chain and the Rams basemap. Routing/guidance is
Phase 5+.