forked from c-fu/arah2
add sume
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
<!-- 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+.
|
||||
Reference in New Issue
Block a user