diff --git a/README.md b/README.md index 0fa7603..f1acb4e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,221 @@ -# araharah + -A privacy-respecting, Telegram-first GPS navigator — Rams-inspired instrument-panel design, OpenStreetMap-based routing, KL metro first. +# arah² + +A privacy-respecting, Telegram-first GPS navigator — a waze-like route navigator delivered through a Telegram bot and Mini App instead of an app-store install, built on OpenStreetMap data, and designed to Dieter Rams' "less, but better." First users are in the Kuala Lumpur metro. + +The Telegram bot is **arah2bot**. + +--- + +## Overview + +arah² is an npm-workspaces monorepo with three packages: + +| Package | Name | What it does | +|---|---|---| +| `theme/` | `@araharah/theme` | The Telemetry Rams design system as a Tailwind v4 CSS-first token package. Every color, spacing, type, and radius value in the project is defined once in `theme/src/tokens.css` and compiled to `theme/dist/rams.css`. Also ships the MapLibre basemap style `theme/basemap/liberty-rams.json` plus gallery/audit/screenshot tooling. | +| `bot/` | `@araharah/bot` | A [grammY](https://grammy.dev) Telegram bot running on a Cloudflare Worker. It serves the `/telegram/webhook` route, four JSON API routes, keeps TTL-only chat sessions in Workers KV, and serves the Mini App's static assets through a wrangler assets binding. | +| `web/` | `@araharah/web` | The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets). | + +The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`run_worker_first`) and serves everything else out of `web/dist`. + +```mermaid +graph TD + U["Telegram client — arah2bot chat + Mini App webview"] + W["Cloudflare Worker — araharah-bot"] + KV[("Workers KV — chat_sessions, TTL 900s")] + TG["Telegram Bot API"] + APP["Mini App assets — web/dist via ASSETS binding"] + MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"] + THEME["@araharah/theme — tokens.css + liberty-rams.json"] + + U -->|"POST /telegram/webhook (secret-token header)"| TG + TG --> W + U -->|"GET Mini App"| APP + APP --> W + W -->|"POST /api/session, /api/location-fallback"| TG + APP -->|"POST /api/session — initData HMAC"| W + APP -->|"GET /api/location — Bearer token"| W + W --> KV + APP --> MAP + THEME -.->|"inlined at build time"| MAP + THEME -.->|"dist/rams.css"| APP + + 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 U,TG telegram + class W worker + class KV storage + class APP,MAP,THEME web +``` + +--- + +## Installation + +Clone the repository and install workspace dependencies: + +```bash +git clone http://gitmirah:3000/c-fu/arah2.git +cd arah2 +npm install +``` + +Requires Node.js and npm (no version is pinned in the repo — use a current LTS). Wrangler handles Cloudflare authentication on first use via `wrangler dev` / `wrangler deploy`. + + + +For the bot's local secrets, copy the example file and fill it in: + +```bash +cp bot/.dev.vars.example bot/.dev.vars +``` + +`bot/.dev.vars` is gitignored and must never be committed. It holds `BOT_TOKEN` (from [@BotFather](https://t.me/BotFather)), `WEBHOOK_SECRET`, and `WEBHOOK_URL` (full origin; `set-webhook.mjs` appends `/telegram/webhook` if only an origin is given). + +--- + +## Quick start + +1. **Build the design tokens and the Mini App.** `theme/dist/rams.css` is an input to the web build, so it must exist first. + + ```bash + npm run build + ``` + +2. **Run everything locally.** Builds the theme, then starts the Worker on port `8787` with `--local`, serving `/telegram/webhook`, the `/api/*` routes, and `web/dist`. + + ```bash + npm run dev + ``` + +3. **Iterate on the Mini App alone** with Vite on port `5173` (`strictPort`), for map/UI work that doesn't need the bot: + + ```bash + npm run web:dev + ``` + +--- + +## Usage + +### Talking to the bot + +The bot answers in English and Bahasa Melayu. Commands are registered into Telegram's persistent menu by `npm run bot:commands`, which derives its descriptions from `bot/src/i18n/strings.ts` so the menu and the replies cannot drift. + +| Command | Alias | Behaviour | +|---|---|---| +| `/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 exactly what is never stored (location, routes, trip history, profile). | + +### HTTP surface + +The Worker's `fetch` handler routes exactly four paths and returns `404` for anything else. All rejections carry a category only — never a payload, a user id, or a timestamp. + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/telegram/webhook` | grammY webhook adapter. Fails closed with `500` if `WEBHOOK_SECRET` is missing, then constant-time-compares `X-Telegram-Bot-Api-Secret-Token` before any bot processing or KV touch. | +| `POST` | `/api/session` | The security gate. The client posts its verbatim `Telegram.WebApp.initData` string (capped at 4 KB, over-cap bodies are rejected `413` before parsing). An HMAC-validated, fresh identity receives a one-time opaque session token. | +| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Bearer` token. The KV key derives from the resolved claim's `chat_id`, never from a client-supplied id, so one user's token can never read another user's pin. Returns honest nulls; a missing or expired token is `401`. | +| `POST` | `/api/location-fallback` | The "ask via chat" path when geolocation is denied. The bot sends a `request_location` reply keyboard into the user's own chat. Target chat id and locale come from the resolved claim — the request body is never read. 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 location relay with the token in memory +curl -H "Authorization: Bearer " http://localhost:8787/api/location +# → {"pin":null} (honest null, not a fabricated coordinate) +``` + +### Location acquisition in the Mini App + +`web/src/location/chain.ts` is a three-link state machine with a locked order — **Telegram → WebView → chat pin** — and freshness is a separate axis from availability. A Telegram fix byte-identical to the last accepted one routes to the nudge, never to success; denial is a state, not an exception. The chain exposes a `Fix`-shaped abstraction with a pluggable cadence and hard-codes no interval, so continuous-mode polling is a later decision, not a rewrite. Past 45 seconds the strip flips to `SIGNAL LOST` and the last marker dims. + +### Consuming the design tokens + +`theme/src/tokens.css` is the single source of truth — 108 custom properties in the `@theme static` block, declared `static` so Tailwind v4's tree-shaking cannot drop tokens the MapLibre basemap derivation and plain-CSS consumers need. + +```css +/* raw token access — any stylesheet, no Tailwind required */ +@import "@araharah/theme/dist/rams.css"; + +.instrument { + background: var(--color-signal-orange); + color: var(--color-matte-black); + padding: var(--spacing-space-md); +} +``` + +```ts +// build-time inlining of the Rams basemap style (never re-set at runtime) +import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"; +``` + +--- ## Design system The Telemetry Rams design system lives in `theme/` as a Tailwind v4 CSS-first token package — the single source of truth for every color, spacing, typography, and radius value in the project. Consume the tokens by importing `theme/src/tokens.css` (plain CSS custom properties, usable by any stylesheet or the MapLibre basemap) or the compiled stylesheet produced by `npm run build`. The token vocabulary mirrors the mockup screens verbatim, so markup derived from them maps one to one. Rams philosophy lives on in `DESIGN.md`, which is superseded for specifics — values are never restated outside the token package. + +```bash +npm run build -w theme # src/theme.css → dist/rams.css (minified) +npm run sheet # build + print the sheet.html path +npm run gallery # build + screenshot → serve gallery.html and basemap on :8123 +npm run audit # off-token audit of the rendered CSS — exits non-zero on violations +npm run capture # Playwright screenshots of the token sheet +``` + +The audit script treats "zero off-token values" as evidence rather than an eyeball claim: it scans rendered CSS and flags any color, spacing, typography, radius, shadow, or blur value that does not derive from `tokens.css`. Fonts (Geist, Space Grotesk) are self-hosted from `theme/fonts/` — both SIL Open Font License 1.1, no Google Fonts CDN. + +--- + +## Commands + +Run from the repo root, or with `-w `. + +| Command | Description | +|---|---| +| `npm run build` | Build the theme, then the Mini App | +| `npm run dev` | Build, then run the Worker locally on port `8787` | +| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` | +| `npm run bot:test` / `npm run web:test` | Run each package's zero-dependency test suite | +| `npm run bot:typecheck` / `npm run web:typecheck` | `tsc --noEmit` | +| `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) | +| `npm run web:capture` | Capture Mini App screenshots | +| `npm run web:build:harness` | Dual build into `dist-harness/` with the mock installer and fetch stubs | +| `npm run sheet` / `gallery` / `audit` / `capture` | Design-token tooling (see above) | + +The two build outputs must never share an `outDir` — the harness build emits mock fetch stubs that production must not carry. + +--- + +## Privacy + +Privacy is structural, not a policy page: + +- Every write to the `chat_sessions` KV namespace carries an explicit `expirationTtl` on its call line; the storage test suite greps for this. +- The persisted claim is exactly four fields — user id, chat id, issued-at, language code — under a 900-second TTL. Minting rotates any prior token for the chat. +- The Mini App holds its session token in a module-scope closure. There is no `localStorage`, `sessionStorage`, cookie, IndexedDB, or Cache API anywhere in `web/src` — the `no-storage` test suite pins this. +- Nothing durable, nothing reconstructable. A token cannot outlive the 15-minute initData freshness proof that minted it. + +## Repository layout + +``` +theme/ @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/ +bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, i18n/, scripts/, wrangler.jsonc +web/ @araharah/web — src/main.ts entry, location/, map/, session/, telegram/, ui/, mock/ +stitch_minimalist_route_navigator/ read-only mockup screens (class-vocabulary source for the theme build) +DESIGN.md Rams philosophy — superseded for any specific value +``` + +## License + +Private repository. No license file is present; all rights reserved by the project owner. + + \ No newline at end of file diff --git a/bot/README.md b/bot/README.md new file mode 100644 index 0000000..176077e --- /dev/null +++ b/bot/README.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²](../README.md) monorepo. For the design system see [`theme/`](../theme/), for the Mini App see [`web/`](../web/), and for cross-package architecture see [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md). + +--- + +## 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. | + + + +--- + +## 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: