From 363e1ddd82a0ea1ba81bf274234b80331b4bfd07 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Fri, 23 Oct 2026 21:05:27 +0800 Subject: [PATCH] =?UTF-8?q?wiki:=20mirror=20project=20documentation=20?= =?UTF-8?q?=E2=80=94=20Home,=20Architecture,=20Getting-Started,=20Developm?= =?UTF-8?q?ent,=20Testing,=20Configuration,=20Design,=20Ideas,=20per-packa?= =?UTF-8?q?ge=20pages=20+=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Architecture.md | 504 +++++++++++++++++++++++++++++++++++++++++++++ Bot-Package.md | 268 ++++++++++++++++++++++++ Configuration.md | 259 +++++++++++++++++++++++ Design.md | 89 ++++++++ Development.md | 462 +++++++++++++++++++++++++++++++++++++++++ Getting-Started.md | 366 ++++++++++++++++++++++++++++++++ Home.md | 221 ++++++++++++++++++++ Ideas.md | 95 +++++++++ Testing.md | 446 +++++++++++++++++++++++++++++++++++++++ Theme-Package.md | 301 +++++++++++++++++++++++++++ Web-Package.md | 208 +++++++++++++++++++ _Sidebar.md | 20 ++ 12 files changed, 3239 insertions(+) create mode 100644 Architecture.md create mode 100644 Bot-Package.md create mode 100644 Configuration.md create mode 100644 Design.md create mode 100644 Development.md create mode 100644 Getting-Started.md create mode 100644 Home.md create mode 100644 Ideas.md create mode 100644 Testing.md create mode 100644 Theme-Package.md create mode 100644 Web-Package.md create mode 100644 _Sidebar.md diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..316017c --- /dev/null +++ b/Architecture.md @@ -0,0 +1,504 @@ + +# 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
chat + Mini App webview"]:::client + B["@araharah/bot Worker
src/webhook.ts"]:::worker + C["grammY handlers
commands + location-fallback"]:::worker + D["Security
initdata.ts HMAC"]:::worker + E["KV chat_sessions
TTL-only"]:::store + F["@araharah/web Mini App
src/main.ts"]:::client + G["Session client + store
src/session/"]:::client + H["Location chain
src/location/"]:::client + I["MapLibre map + UI
src/map/, src/ui/"]:::client + J["@araharah/theme
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
@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
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
isVersionAtLeast 8.0"}:::ok + NUDGE{"looksStale(prev, fix)
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
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,
inlined liberty-rams style)"]:::art + AR3["web/dist-harness (never deployed)"]:::art + EDGE["Cloudflare Worker araharah-bot
[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` — 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:` → claim, `chat:` → 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:`. `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 (module-private, not exported — the derived `MintSessionKv`/`LocationPinKv` are the public surface) — 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 seven 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 names the four never-stored facts (location, routes, + trip history, profile) and keeps the ~15-minute TTL wording, in both locales. That gate + does not assert against the `SessionClaim` fields in `sessions.ts` — keeping the copy in + step with those field names is a convention, not a test. +- **`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+. \ No newline at end of file diff --git a/Bot-Package.md b/Bot-Package.md new file mode 100644 index 0000000..6b4246b --- /dev/null +++ b/Bot-Package.md @@ -0,0 +1,268 @@ + + +# @araharah/bot + +The arah² Telegram bot — **arah2bot**. A [grammY](https://grammy.dev) bot running on a single Cloudflare Worker that receives Telegram updates, keeps TTL-only chat sessions in Workers KV, gates the Mini App's session handshake, and serves the Mini App's static assets from the same origin. + +Part of the [arah²](Home) monorepo. For the design system see [`theme/`](/c-fu/arah2/src/branch/main/theme), for the Mini App see [`web/`](/c-fu/arah2/src/branch/main/web), and for cross-package architecture see [`docs/ARCHITECTURE.md`](Architecture). + +--- + +## What it does + +Three jobs, all inside one Worker fetch handler (`src/webhook.ts`): + +- **Chat commands** — `/start` (`/mula`), `/help`, `/privacy` (`/privasi`) reply with plain text in English or Bahasa Melayu. +- **Session gate** — validates a Mini App's verbatim `Telegram.WebApp.initData` string by HMAC and mints an opaque, 15-minute session token. +- **Location relay** — when the Mini App can't get a fix, the bot sends a `request_location` reply keyboard into the user's own chat; the shared pin lands in KV and the app polls for it. + +Privacy is structural rather than promised: every write to KV carries an explicit `expirationTtl`, and the persisted record is exactly the four claim fields plus exactly the three location fields — nothing else can be serialized. A test (`PG1`) greps the storage sources to hold that line. + +```mermaid +graph TD + TG["Telegram Bot API"] + W["Cloudflare Worker — araharah-bot"] + BOT["grammY bot — commands + location keyboard"] + SEC["initData HMAC gate"] + KV[("KV chat_sessions — session TTL 900s, pin TTL 300s")] + APP["Mini App — web/dist via ASSETS binding"] + + TG -->|"POST /telegram/webhook + secret header"| W + W --> BOT + W --> SEC + SEC -->|"mints opaque token"| KV + BOT -->|"request_location keyboard"| TG + TG -->|"shared pin message"| BOT + BOT -->|"writePin loc:chat_id"| KV + APP -->|"POST /api/session — initData"| W + APP -->|"GET /api/location — Bearer"| W + APP -->|"POST /api/location-fallback — Bearer"| W + W --> KV + + classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A + classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3 + classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A + classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14 + class TG telegram + class W,BOT,SEC worker + class KV storage + class APP web +``` + +--- + +## Installation + +`@araharah/bot` is private and consumed through the workspace, not installed on its own. From the repo root: + +```bash +git clone http://gitmirah:3000/c-fu/arah2.git +cd arah2 +npm install +``` + +Then create the local secrets file (gitignored — never commit it): + +```bash +cp bot/.dev.vars.example bot/.dev.vars +``` + +`bot/.dev.vars` holds three values: + +| Variable | Purpose | +|---|---| +| `BOT_TOKEN` | Telegram Bot API token from [@BotFather](https://t.me/BotFather). | +| `WEBHOOK_SECRET` | Secret token Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`. **Must be non-empty** — the webhook fails closed with `500` otherwise. | +| `WEBHOOK_URL` | Full origin for the Worker; `set-webhook.mjs` appends `/telegram/webhook` when only an origin is set. | + +> ⚠ **VERIFY: Wrangler authenticates to your Cloudflare account on first `wrangler dev` / `wrangler deploy` — confirm which login flow your team uses (OAuth vs. `CLOUDFLARE_API_TOKEN`).** + +--- + +## Quick start + +1. **Build the workspace** — `web/dist` is served through the Worker's assets binding, so the Mini App must exist first. + + ```bash + npm run build + ``` + +2. **Run the Worker locally** on port `8787` with local KV emulation. + + ```bash + npm run dev + ``` + +3. **Check the surface.** + + ```bash + curl -i http://localhost:8787/api/location + # → 401 {"error":"unauthorized"} (no bearer token) + + curl -i -X GET http://localhost:8787/api/session + # → 405 Method not allowed (route exists, method is wrong) + + curl -i http://localhost:8787/nope + # → 404 Not found + ``` + +4. **Register the Telegram-side wiring** after deploying: the webhook, the command menu, and the menu button. + + ```bash + npm run deploy # wrangler deploy + set-webhook.mjs + npm run bot:commands + node scripts/set-menu-button.mjs # points the menu button at the Mini App origin + ``` + +--- + +## HTTP surface + +The `fetch` handler in `src/webhook.ts` routes exactly four paths and returns `404` for everything else. Every log line and every rejection body is **category only** — never a payload, a user id, a chat id, or a timestamp. + +| Method | Path | Behaviour | +|---|---|---| +| `POST` | `/telegram/webhook` | grammY's `cloudflare-mod` webhook adapter. Fails closed with `500` when `WEBHOOK_SECRET` is missing or empty — grammY's own secret check silently accepts everything when its secret is `undefined`. Otherwise a constant-time compare of `X-Telegram-Bot-Api-Secret-Token` runs **before** any bot processing or KV touch. The bot is instantiated per request because the token only arrives with `env`. | +| `POST` | `/api/session` | The security gate. The client posts its verbatim initData string, capped at 4096 characters (a lying or absent `Content-Length` is caught by a post-read length check, both reject `413` **before** parsing). HMAC-validated and fresh → a one-time opaque token; rejection precedence is `malformed` → `tampered` → `stale`, each a `401` carrying only the category. Minting rotates any prior token for that chat. | +| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Authorization: Bearer `. The KV key derives from the **claim's** `chat_id`, never from a client-supplied id, so one user's token can never read another's pin. Honest `{"pin":null}` when nothing is stored; `401` for a missing or expired token; `405` for a non-GET. | +| `POST` | `/api/location-fallback` | The "ask via chat" path. The worker asks the bot to send the `request_location` reply keyboard into the chat named by the resolved claim. The request body is never read — neither chat id nor locale is accepted from the client. A rejected Telegram send is a category-only `502`. | + +```bash +# Mint a session from a Mini App initData string +curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt +# → {"token":""} + +# Poll the relay with that token held in memory +curl -H "Authorization: Bearer " http://localhost:8787/api/location +# → {"pin":null} +``` + +--- + +## Commands + +| Command | Description | +|---|---| +| `/start`, `/mula` | One-sentence introduction to arah² and its privacy posture. | +| `/help` | Lists the commands that exist in this phase. | +| `/privacy`, `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and what is never stored (location, routes, trip history, profile). | +| *shared pin message* | Not a command — a `message` handler records `ctx.message.location` into KV and confirms. | + +Replies are plain text with link previews disabled. There are no inline buttons, callback routes, or reply menus in the command tree — the location reply keyboard in `handlers/location-fallback.ts` is the one deliberate exception, and a test (`CS4`) holds that boundary. + +--- + +## Package scripts + +Run from the repo root (`npm run bot: