docs: full docs-update pass — 9 GSD docs regenerated + verified (434→454 claims; verifier corrections applied)
This commit is contained in:
+420
-85
@@ -9,16 +9,26 @@ 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.
|
||||
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). Searching a
|
||||
destination and routing to it are real today: `@araharah/search` is the in-Worker
|
||||
gazetteer (search backend #1 of the D-01 ladder), and `@araharah/routing` is the
|
||||
routing data plane — its CSR road-graph codec, snapper, and A\* power **engine A**,
|
||||
the Worker-side router (`bot/src/routing/worker-router.ts`) that routes over a
|
||||
ROAD_GRAPH R2-hosted KL road-graph binary through `POST /api/route`. Guidance is
|
||||
end-to-end: a turn-card cron (the repo's first Cron Trigger, `*/2 * * * *`) pushes
|
||||
turn-cards into the chat from a TTL-only guidance envelope, statelessly. All durable
|
||||
state is TTL-only — one Cloudflare KV namespace (`chat_sessions`, plus `guidance:*`
|
||||
envelopes in it) — 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.
|
||||
Architecturally this is a **five-workspace npm monorepo in a layered/hexagonal
|
||||
arrangement**: a stateless edge worker with a KV repository layer and an embedded
|
||||
search index plus A\* router, a framework-free TypeScript client with its own
|
||||
repository, adapter, route, and guidance layers, and two pure V8-native data-plane
|
||||
packages (search, routing) that both the worker and the client import — and a pure
|
||||
build-time token/cartography package underneath.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -26,16 +36,22 @@ graph TD
|
||||
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
|
||||
classDef plane fill:#736B1E,stroke:#4A440F,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
|
||||
C["grammY handlers<br/>commands + /cari + fallbacks"]:::worker
|
||||
D["Security<br/>initdata.ts HMAC"]:::worker
|
||||
E["KV chat_sessions<br/>TTL-only"]:::store
|
||||
E[("KV chat_sessions<br/>session:* 900s · chat:*<br/>loc:* 300s · guidance:* 7200s")]:::store
|
||||
R[("R2 ROAD_GRAPH<br/>kl-roadgraph.v1.bin")]:::store
|
||||
Q["scheduled()<br/>*/2 turn-card cron"]:::worker
|
||||
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
|
||||
RT["Route controller + guidance<br/>src/route/, src/guidance/"]:::client
|
||||
S["@araharah/search<br/>gazetteer SearchSource"]:::plane
|
||||
U["@araharah/routing<br/>graph · snap · A*"]:::plane
|
||||
J["@araharah/theme<br/>dist/rams.css + basemap"]:::build
|
||||
|
||||
A -->|"POST /telegram/webhook"| B
|
||||
@@ -43,15 +59,24 @@ graph TD
|
||||
B --> C
|
||||
B --> D
|
||||
B --> E
|
||||
B --> R
|
||||
Q --> E
|
||||
Q --> R
|
||||
C --> E
|
||||
C -->|"sendMessage reply keyboard"| A
|
||||
C --> S
|
||||
C -->|"sendPhoto turn-cards"| A
|
||||
F -->|"POST /api/session initData"| B
|
||||
F -->|"GET /api/location Bearer"| B
|
||||
F -->|"POST /api/location-fallback"| B
|
||||
F -->|"POST /api/route Bearer"| B
|
||||
F -->|"POST|GET|DELETE /api/guidance"| B
|
||||
F -->|"build-time imports"| S
|
||||
F -->|"build-time imports"| U
|
||||
G --> F
|
||||
H --> G
|
||||
F --> H
|
||||
H --> I
|
||||
RT -->|"reroute"| B
|
||||
J -->|"compiled CSS + inlined style JSON"| F
|
||||
```
|
||||
|
||||
@@ -62,6 +87,7 @@ 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 plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
|
||||
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
|
||||
classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A
|
||||
|
||||
@@ -86,38 +112,68 @@ graph LR
|
||||
W3["src/location/"]:::wsfill
|
||||
W4["src/map/"]:::wsfill
|
||||
W5["src/ui/ + src/telegram/"]:::wsfill
|
||||
W6["src/shell.css"]:::wsfill
|
||||
W6["src/search/ + src/route/<br/>+ src/guidance/"]:::wsfill
|
||||
W7["dist/ (served)"]:::wsfill
|
||||
W1 --> W2
|
||||
W1 --> W3
|
||||
W1 --> W4
|
||||
W1 --> W5
|
||||
W1 --> W6
|
||||
W1 --> W7
|
||||
W4 --> W7
|
||||
W5 --> W7
|
||||
W6 --> W7
|
||||
end
|
||||
subgraph BOT["bot · @araharah/bot"]
|
||||
direction TB
|
||||
B1["src/webhook.ts"]:::botfill
|
||||
B2["src/security/initdata.ts"]:::botfill
|
||||
B3["src/bot/"]:::botfill
|
||||
B3["src/bot/ + src/guidance/cron.ts"]:::botfill
|
||||
B4["src/kv/"]:::botfill
|
||||
B5["src/config.ts Env"]:::botfill
|
||||
B5["src/routing/worker-router.ts"]:::botfill
|
||||
B6["src/config.ts Env"]:::botfill
|
||||
B1 --> B2
|
||||
B1 --> B3
|
||||
B1 --> B4
|
||||
B1 --> B5
|
||||
B1 --> B6
|
||||
end
|
||||
KV[("KV namespace chat_sessions<br/>session:* 900s · chat:* · loc:* 300s")]:::store
|
||||
subgraph SEARCH["search · @araharah/search"]
|
||||
direction TB
|
||||
S1["src/gazetteer.ts<br/>lazy memoized index"]:::plane
|
||||
S2["src/source.ts<br/>SearchSource #1"]:::plane
|
||||
S3["src/score.ts + normalize.ts"]:::plane
|
||||
S1 --> S2
|
||||
S3 --> S2
|
||||
end
|
||||
subgraph ROUTING["routing · @araharah/routing"]
|
||||
direction TB
|
||||
R1["src/graph.ts<br/>CSR codec"]:::plane
|
||||
R2["src/snap.ts + astar.ts"]:::plane
|
||||
R3["src/fixture-source.ts<br/>#1 · http-source.ts<br/>engine-A client seam"]:::plane
|
||||
R1 --> R2
|
||||
R1 --> R3
|
||||
R2 --> R3
|
||||
end
|
||||
KV[("KV chat_sessions")]:::store
|
||||
R2B[("R2 ROAD_GRAPH<br/>araharah-graphs")]:::store
|
||||
CRON["Cron Trigger<br/>*/2 * * * *"]:::ext
|
||||
TG["Telegram Bot API"]:::ext
|
||||
OFM["OpenFreeMap tiles"]:::ext
|
||||
end
|
||||
|
||||
WEB -->|"workspace dep @araharah/theme"| THEME
|
||||
WEB -->|"workspace deps @araharah/{theme,search,routing}"| THEME
|
||||
WEB --> SEARCH
|
||||
WEB --> ROUTING
|
||||
BOT -->|"deps @araharah/{search,routing}"| SEARCH
|
||||
BOT --> ROUTING
|
||||
BOT -->|"assets binding ../web/dist"| WEB
|
||||
B1 --> KV
|
||||
B4 --> KV
|
||||
B3 <-->|"webhookCallback / sendMessage"| TG
|
||||
B5 --> R2B
|
||||
B3 --> KV
|
||||
CRON --> B1
|
||||
B1 <-->|"webhookCallback / sendMessage"| TG
|
||||
W3 <-->|"chat pin"| TG
|
||||
W4 --> OFM
|
||||
```
|
||||
@@ -132,27 +188,45 @@ graph TD
|
||||
|
||||
ROOT["araharah (private root)"]:::out
|
||||
TH["@araharah/theme"]:::pkg
|
||||
SE["@araharah/search"]:::pkg
|
||||
RO["@araharah/routing"]:::pkg
|
||||
BT["@araharah/bot"]:::pkg
|
||||
WB["@araharah/web"]:::pkg
|
||||
KVS[("KV chat_sessions")]:::dep
|
||||
R2S[("R2 ROAD_GRAPH")]:::dep
|
||||
TGAPI["Telegram Bot API"]:::dep
|
||||
|
||||
ROOT -->|"workspaces"| TH
|
||||
ROOT -->|"workspaces"| SE
|
||||
ROOT -->|"workspaces"| RO
|
||||
ROOT -->|"workspaces"| BT
|
||||
ROOT -->|"workspaces"| WB
|
||||
WB -->|"dependencies: @araharah/theme *"| TH
|
||||
WB -->|"dependencies: @araharah/search *"| SE
|
||||
WB -->|"dependencies: @araharah/routing *"| RO
|
||||
BT -->|"dependencies: @araharah/search *"| SE
|
||||
BT -->|"dependencies: @araharah/routing *"| RO
|
||||
BT -->|"static assets ../web/dist"| WB
|
||||
BT --> KVS
|
||||
BT --> R2S
|
||||
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`.
|
||||
`@araharah/search` and `@araharah/routing` are the shared V8-native data planes: the
|
||||
**bot Worker** imports them at runtime (gazetteer search, graph decode, snap, A\*),
|
||||
the **web app** imports the same sources at build time (destination resolution, the
|
||||
`RoutingSource` ladder, polyline decoding). Neither data plane reads the environment,
|
||||
the network, or storage. `@araharah/theme` stays web-only (`"@araharah/theme": "*"`
|
||||
in `web/package.json` — `dist/rams.css` in `web/src/shell.css`,
|
||||
`basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no workspace
|
||||
dependency on theme or web; 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`.
|
||||
|
||||
Both data-plane packages have **no `exports`/`main` field** — consumers import the
|
||||
explicit subpath with extension, e.g. `@araharah/search/src/index.ts` and
|
||||
`@araharah/routing/src/index.ts`. A bare package specifier resolves to nothing.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -171,6 +245,15 @@ POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
|
||||
→ ctx.reply(plain text, link_previews disabled)
|
||||
```
|
||||
|
||||
`createBot` also builds ONE memoized gazetteer `SearchSource` (backend #1) shared
|
||||
between the `/cari` + `/search` command surface (`bot/src/bot/commands/cari.ts`) and
|
||||
the alternates switcher (`bot/src/bot/handlers/search-alternate.ts`): `/cari` renders
|
||||
a venue card whose OPEN button deep-links `<MINI_APP_URL>/?dest=<id>` (origin from the
|
||||
configured `MINI_APP_URL` var, never user input), and the alternates handler swaps the
|
||||
selected entry via a callback. The venue card's inline buttons and the
|
||||
location-fallback reply keyboard are the two documented exceptions to the
|
||||
plain-text-only command rule.
|
||||
|
||||
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.
|
||||
@@ -225,7 +308,7 @@ flowchart TD
|
||||
START --> L1
|
||||
L1 -->|"yes"| NUDGE
|
||||
NUDGE -->|"no"| LIVE
|
||||
NUDGE -->|"yes"||"discarded webview read"| L1
|
||||
NUDGE -->|"yes: discarded webview read"| L1
|
||||
L1 -->|"no"| L2
|
||||
L2 -->|"yes + fix"| LIVE
|
||||
L2 -->|"no fix"| L3
|
||||
@@ -270,7 +353,95 @@ Both `/api/location` and `/api/location-fallback` derive the target `chat_id` fr
|
||||
`claim.language_code`. Telegram has no push channel from chat to webview, so this poll
|
||||
is server-mediated by necessity.
|
||||
|
||||
### 5. Worker routing table
|
||||
### 5. Destination deep-link (search, both carriers)
|
||||
|
||||
The destination id is a **plain public gazetteer slug** — no crypto, no token anywhere
|
||||
on this path. `web/src/search/resolve-destination.ts` reads it from TWO carriers in a
|
||||
fixed order with ONE shared validation (`[A-Za-z0-9_-]{1,64}`, optional `dest_` wire
|
||||
prefix):
|
||||
|
||||
1. **PRIMARY — the URL query string** (`?dest=<id>`): the `/cari` OPEN inline-button
|
||||
path; Telegram opens the button's URL verbatim in the webview.
|
||||
2. **SECONDARY — `start_param` inside the SIGNED initData string**: the documented
|
||||
direct-link (`t.me/<bot>?startapp=<id>`) and home-screen-shortcut path, treated as
|
||||
an untrusted exact-lookup key.
|
||||
|
||||
The id resolves against the webview's **own build-time copy of the shared gazetteer**
|
||||
(`@araharah/search` inlined by Vite). A hit renders the destination card with a
|
||||
START-route action; a miss renders the honest "destination unavailable" card and the
|
||||
boot continues staged — never a blank map. The resolved entry lives in module scope
|
||||
(memory only); nothing persists.
|
||||
|
||||
### 6. Route consult (engine A over R2)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MAIN as main.ts (auto-consult, once per boot)
|
||||
participant CTRL as route/route.ts
|
||||
participant HTTP as http-source.ts (backend #2 seam)
|
||||
participant W as Worker /api/route
|
||||
participant G as loadGraph (R2 ROAD_GRAPH)
|
||||
participant A as snapToGraph + routeAstar
|
||||
|
||||
MAIN->>CTRL: route { origin: fix, destinationId }
|
||||
CTRL->>HTTP: POST endpoint (injected), bearer = memory token
|
||||
HTTP->>W: { origin, destinationId } (≤ 4 KB)
|
||||
W->>W: getClaimByToken — Bearer claim gate
|
||||
W->>G: R2 GET kl-roadgraph.v1.bin (ONE per cold isolate, memoized)
|
||||
G-->>W: RoadGraph | null
|
||||
W->>A: bbox-check origin → snap origin → snap destination → A*
|
||||
A-->>W: RouteResult | null
|
||||
W-->>HTTP: 200 { result } | 200 JSON null
|
||||
HTTP-->>CTRL: RouteResult | null (fail-honest)
|
||||
```
|
||||
|
||||
`bot/src/routing/worker-router.ts` is engine A's whole server surface: the decoded
|
||||
graph memoizes per isolate (`loadGraph` — the ONLY R2 loader, shared with the cron
|
||||
arm), the origin is bbox-checked against `graph.bbox` before any snap/A\* work, and
|
||||
**every miss class answers `200 null`** — unknown destination, snap miss, unreachable
|
||||
pair, absent/corrupt graph — the honest router-pending state (never a 500, never a
|
||||
fake route). Alternatives generation is prohibited on this path: exactly one best
|
||||
route. The client composes the ladder at the import site —
|
||||
`createFixtureRoutingSource` (backend #1, the 15-pair golden corpus) followed by
|
||||
`createHttpRoutingSource` (engine A) — with the null contract end to end.
|
||||
|
||||
### 7. Guidance (mint → resume → cron push)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MA as Mini App
|
||||
participant W as Worker /api/guidance
|
||||
participant KV as KV guidance:id (7200s)
|
||||
participant C as scheduled() every 2 min
|
||||
|
||||
MA->>W: POST /api/guidance (Bearer) — six-field route snapshot
|
||||
W->>W: getClaimByToken → chat_id minted from the CLAIM (body chat_id structurally ignored)
|
||||
W->>KV: mintGuidanceSession — inline snapshot, expirationTtl 7200
|
||||
KV-->>W: envelope (uuid id)
|
||||
W-->>MA: 201 { id }
|
||||
Note over MA: webview killed / phone locked
|
||||
MA->>W: GET /api/guidance?id=<uuid> — resume carrier ?g=<uuid>
|
||||
W->>KV: getGuidanceSession — foreign or absent are indistinguishable 404s
|
||||
KV-->>MA: envelope → straight into the live trip
|
||||
loop every 2 min (stateless — zero KV writes)
|
||||
C->>KV: list prefix guidance: → getGuidanceSession per key
|
||||
C->>C: projectTurnCards(envelope, nowSec, streetAt(loadGraph))
|
||||
C->>MA: sendPhoto glyph + OPEN (?g=<id>) — due cards only, cadence 240 s
|
||||
end
|
||||
```
|
||||
|
||||
The envelope is the **frozen Phase 5 contract**: exactly four fields (`id`, `chat_id`,
|
||||
`route_snapshot`, `created_at`); the snapshot is INLINE (one key, one TTL) with six
|
||||
fields (`destinationId`, `destinationName`, `distanceM`, `durationS`, `geometry`
|
||||
polyline6, `startedAtSec`). Every write carries `expirationTtl: 7200` on the same call
|
||||
line — the PG1 TTL law covers this file. On the web side,
|
||||
`web/src/guidance/guidance.ts` is the pure reroute state machine (`idle → on_route →
|
||||
off_route → resuming/rerouting → arrived`, accuracy-aware threshold, 20 s storm guard)
|
||||
and `web/src/guidance/watch.ts` is the throttled `watchPosition` stream (guidance work
|
||||
runs on ≥ 10 m moved or ≥ 5 s elapsed — fixes are consumed in the caller's frame and
|
||||
never transmitted).
|
||||
|
||||
### 8. Worker routing table
|
||||
|
||||
| Method | Path | Handler in `bot/src/webhook.ts` | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -278,11 +449,14 @@ is server-mediated by necessity.
|
||||
| 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 |
|
||||
| POST/GET/DELETE | `/api/guidance` | `handleGuidance` | Mint / resume / delete the 7200 s guidance envelope |
|
||||
| POST | `/api/route` | `handleRoute` (in `src/routing/worker-router.ts`) | Bearer-gated engine-A routing over the R2 road graph |
|
||||
| * | 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.
|
||||
bundle. The same `fetch` handler also exports `scheduled()` — the `*/2` cron that runs
|
||||
the turn-card push (flow 7).
|
||||
|
||||
## Build & Deploy Topology
|
||||
|
||||
@@ -299,11 +473,14 @@ graph LR
|
||||
W2["web: vite build --mode harness → dist-harness/"]:::step
|
||||
B1["bot: wrangler deploy"]:::step
|
||||
B2["bot: scripts/set-webhook.mjs"]:::step
|
||||
RG["routing: build-road-graph.mjs<br/>→ kl-roadgraph.v1.bin"]:::step
|
||||
RU["routing: upload-road-graph.mjs<br/>→ R2 (fork contract)"]:::step
|
||||
AR1["theme/dist/rams.css + fonts"]:::art
|
||||
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style)"]:::art
|
||||
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style, public/glyphs)"]:::art
|
||||
AR3["web/dist-harness (never deployed)"]:::art
|
||||
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist"]:::cloud
|
||||
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist + cron */2"]:::cloud
|
||||
KVS[("KV chat_sessions")]:::store
|
||||
R2B[("R2 ROAD_GRAPH")]:::store
|
||||
|
||||
T1 --> W1
|
||||
T2 --> W1
|
||||
@@ -313,21 +490,31 @@ graph LR
|
||||
AR2 --> EDGE
|
||||
B1 --> EDGE
|
||||
B1 --> B2
|
||||
RG --> RU
|
||||
RU --> R2B
|
||||
EDGE --> KVS
|
||||
EDGE --> R2B
|
||||
```
|
||||
|
||||
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 road-graph binary is a separate provisioning track (never a build prerequisite):
|
||||
`routing/scripts/build-road-graph.mjs` writes `routing/data/kl-roadgraph.v1.bin`
|
||||
(~15 MB, a build artifact that is NOT committed) and
|
||||
`routing/scripts/upload-road-graph.mjs` puts it on R2 — the fork contract is
|
||||
`docs/ROAD-GRAPH.md`.
|
||||
|
||||
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
|
||||
production. `web/test/build-artifact.test.mjs` (BA1–BA10) 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__`.
|
||||
style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`. The turn-card
|
||||
glyphs (`web/public/glyphs/*.png`, rendered by `routing/scripts/build-glyphs.mjs`) are
|
||||
static assets the cron references as `${MINI_APP_URL}/glyphs/<glyph>.png`.
|
||||
|
||||
<!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. -->
|
||||
|
||||
@@ -337,15 +524,43 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
|
||||
| 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. |
|
||||
| `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`, `ROAD_GRAPH: R2Bucket` (first R2 binding), `MINI_APP_URL` (public deep-link origin). |
|
||||
| `createExportedHandler(opts)` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` factory — six fetch routes plus `scheduled()`; 404 otherwise. Production passes nothing; offline tests inject a recording `baseFetch`. |
|
||||
| `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. |
|
||||
| `GuidanceSessionEnvelope` / `GuidanceRouteSnapshot` / `mint/get/deleteGuidanceSession` / `GUIDANCE_KEY_PREFIX` | `bot/src/kv/guidance-sessions.ts` | The frozen four-field envelope (six-field inline snapshot), `GUIDANCE_SESSION_TTL_SECONDS = 7200`, key `guidance:<uuid>`. Defensive reads (garbage → null); ids UUID-guarded before any KV touch. |
|
||||
| `handleRoute` / `loadGraph` | `bot/src/routing/worker-router.ts` | Engine A's server surface: Bearer claim gate, 4 KB body cap, bbox pre-check, snap → A\* over the R2-hosted graph, `200 null` for every miss. `loadGraph` is the ONLY R2 loader — one memoized GET per cold isolate, shared with the cron arm. |
|
||||
| `projectTurnCards` / `makeStreetAt` | `bot/src/guidance/cron.ts` | The PURE projection over the frozen snapshot — when a card is due (`nowSec % CARD_CADENCE_S < SCAN_TICK_S`), glyph + caption composition, street reconstruction from the graph. `CARD_CADENCE_S = 240`; the cron is stateless (zero KV writes). |
|
||||
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, `/cari` + `/search`, the alternates switcher, and the location-fallback handler. Builds one shared gazetteer `SearchSource`. |
|
||||
| `registerCari(bot, source, origin)` | `bot/src/bot/commands/cari.ts` | The search command surface: venue card with OPEN deep link (`?dest=<id>`) and alternates callbacks — the second documented exception to the text-only rule. |
|
||||
| `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. |
|
||||
| `KvLike` | `bot/src/kv/*.ts` | Structural KV interface (module-private, not exported — the derived `MintSessionKv`/`LocationPinKv`/`GuidanceSessionKv` are the public surface) — lets tests pass a fake without depending on Workers types. |
|
||||
|
||||
### `@araharah/search`
|
||||
|
||||
| Abstraction | File | Description |
|
||||
| --- | --- | --- |
|
||||
| `SearchSource` | `search/src/types.ts` | The pluggable search seam: async `search(q)` + synchronous `byId(id)`. The documented ladder is backend #1 (gazetteer, shipped) with later rungs slotted at the import site — no env read, no registry. |
|
||||
| `createGazetteerSource()` | `search/src/source.ts` | Backend #1. The refusal contract is load-bearing: an EMPTY array means "not in the v1 area" — never a fabricated pin. Ranking is score-band DESC then entry-id ASC (deterministic tie-break). |
|
||||
| `search(text, limit)` | `search/src/source.ts` | The synchronous scoring path — shared by the bot, the Mini App, and the corpus runner: the measured quality IS the shipped quality. |
|
||||
| `getEntries` / `getEntryById` / `getIndex` | `search/src/gazetteer.ts` | The lazy, memoized index over a static JSON import (`data/kl-gazetteer.json`, ODbL notice inside). Three laws: lazy + memoized (never module-scope work), survives three environments (Worker bundle, Vite bundle, bare Node), no logging/storage. |
|
||||
| `scoreCandidate` / `TIER` / `FUZZY_ADMIT_SIMILARITY` | `search/src/score.ts` | Tiered admission — EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET bands always admitted; FUZZY only at similarity ≥ 0.72. `normalize` (with `rules/abbreviations.ts`) canonicalizes both sides of every comparison. |
|
||||
| `GazetteerEntry` | `search/src/types.ts` | `id` (slug), `name`, `aliases`, coordinates, category — the entry shape every carrier (bot card, webview resolver, guidance snapshot) reduces to. |
|
||||
|
||||
### `@araharah/routing`
|
||||
|
||||
| Abstraction | File | Description |
|
||||
| --- | --- | --- |
|
||||
| `RoutingSource` | `routing/src/types.ts` | The pluggable routing seam: `route(req) → RouteResult \| null`. Backend ladder: #1 `"fixture"` → #2 `"engine-a"` (LIVE, via `/api/route`) → engine B (client-side webview router, documented post-v1). Selection happens at the import site in code — no env read, no registry. |
|
||||
| `RouteResult` / `RouteOption` / `RouteStep` / `RouteGeometry` | `routing/src/types.ts` | Day-one contract: options 1..3 (index 0 = best), `cumulativeDistanceM` parallel to points (the guidance projection contract), polyline6 `geometry`, optional `kind` maneuver tags. Fixture-world steps are empty arrays — honest "not wired". |
|
||||
| `RoadGraph` / `encodeGraph` / `decodeGraph` | `routing/src/graph.ts` | The CSR road-graph binary codec (`GRAPH_MAGIC`, `GRAPH_VERSION`) — the on-disk form of `kl-roadgraph.v1.bin`. V8-native, dependency-free, no network. |
|
||||
| `snapToGraph` / `SnapResult` | `routing/src/snap.ts` | Snaps a raw fix onto the graph (bbox-margined); the origin of every route computation and of the cron's street reconstruction. |
|
||||
| `routeAstar` / `deriveSteps` / `classifyTurn` | `routing/src/astar.ts` | The A\* engine plus the maneuver classifier (`depart`/`left`/…/`arrive`) that feeds both the webview guidance machine and the cron's turn cards. |
|
||||
| `createFixtureRoutingSource` | `routing/src/fixture-source.ts` | Backend #1: the committed 15-pair golden KL corpus (`data/route-fixtures.json` + `test/fixtures/golden-pairs.json`). Demo-grade by design; the tolerance gate is `npm run routing:corpus`. |
|
||||
| `createHttpRoutingSource(options)` | `routing/src/http-source.ts` | Engine A's client seam: injected `endpoint` + `bearer` getter, defensively validates every response field, resolves null on any failure (the null contract crossing HTTP). Never holds or persists the token. |
|
||||
| `decodePolyline6` / `encodePolyline6` / `haversineM` / `deriveCumulativeDistanceM` | `routing/src/polyline.ts`, `routing/src/geo.ts` | The storage geometry format and the ONE distance authority — every distance in the system flows through `geo.ts`. |
|
||||
|
||||
### `@araharah/web`
|
||||
|
||||
@@ -355,7 +570,13 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
| `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. |
|
||||
| `readDestinationId` / `resolveDestination` / `setDestination` | `web/src/search/resolve-destination.ts` | The two-carrier deep-link resolver (`?dest=` URL, then signed initData's `start_param`), one shared slug validation, memory-only result, honest-null on miss. |
|
||||
| `buildDestinationCard` / `buildDestinationUnavailableCard` | `web/src/search/destination-card.ts` | The search surface's Rams cards (destination confirmed / honest unavailable). |
|
||||
| `createRouteController` | `web/src/route/route.ts` | The consult/select/walk-back state machine and the guidance pump: auto-consults once per boot, draws the selected line (`setData` on the `route` source), starts the guidance watch, delegates every guidance decision. Touches no DOM/map directly — everything injected. |
|
||||
| `installRouteLayers(m)` | `web/src/route/route-layers.ts` | Installs the `route` / `route-alt` map sources inside map.ts's ONE load handler; a drawn route is static furniture (only selection changes it). |
|
||||
| `createGuidanceSession` / `stepGuidance` | `web/src/guidance/guidance.ts` | The pure reroute state machine: `idle → on_route → off_route → resuming/rerouting`, accuracy-aware threshold (`max(35, 2×accuracy)` capped 100), 3-fix + 8 s grace, 20 s storm guard. A pure reducer — replay-verifiable in Node. |
|
||||
| `createGuidanceWatch(geo, onFix)` | `web/src/guidance/watch.ts` | The gated position stream: `watchPosition` high-accuracy, throttled to ≥ 10 m moved or ≥ 5 s elapsed. Fixes are consumed in the caller's frame — never stored, logged, or transmitted. |
|
||||
| `TelegramWebAppLike` | `web/src/telegram/webapp.ts` | Hand-rolled ~20-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`. |
|
||||
@@ -364,9 +585,10 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
| `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. |
|
||||
| `watchTheme(wa)` / `resolveBranch` | `web/src/telegram/theme.ts` | The theme-slot machine: Telegram light/dark (or `header_bg_color` luminance fallback) selects between two Rams token pairs — `applyTheme`'s ONLY statement is the `data-tg-theme` attribute set; MapLibre is deliberately absent (the map style is never re-set). Pinned by `web/test/theme.test.mjs`. |
|
||||
| `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. |
|
||||
| `wireBackButton(wa, hasOverlay)` | `web/src/telegram/backbutton.ts` | Total function — the context-aware resolver (guidance → confirm; confirm → CONTINUE; ended → search-root walk; route → Phase 5 walk; root → close) and the wire's per-state `sync()`. |
|
||||
| `wireHomeScreen(wa, locale)` | `web/src/telegram/homescreen.ts` | The ONBD-03 home-screen affordance behind the Bot API 8.0 capability guard — Telegram-native methods only. |
|
||||
| `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 }`. |
|
||||
|
||||
@@ -375,7 +597,7 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
| 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. |
|
||||
| `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 (the two Rams token pairs the slot switch flips). |
|
||||
| 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). |
|
||||
@@ -384,38 +606,59 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
|
||||
```
|
||||
araharah/
|
||||
├── package.json # private root; workspaces + build ordering only
|
||||
├── package.json # private root; workspaces + script passthroughs
|
||||
├── bot/ # @araharah/bot — the Cloudflare Worker
|
||||
│ ├── src/
|
||||
│ │ ├── webhook.ts # the single fetch handler; four routes
|
||||
│ │ ├── config.ts # Env contract
|
||||
│ │ ├── bot/ # grammY factory, commands/, handlers/
|
||||
│ │ ├── webhook.ts # the single fetch handler; six routes + scheduled()
|
||||
│ │ ├── config.ts # Env contract (KV, R2, MINI_APP_URL)
|
||||
│ │ ├── bot/ # grammY factory, commands/, handlers/ (incl. /cari)
|
||||
│ │ ├── security/ # initdata.ts — pure, isolation-free crypto
|
||||
│ │ ├── kv/ # sessions.ts, location-pin.ts — TTL-only repos
|
||||
│ │ ├── kv/ # sessions.ts, location-pin.ts, guidance-sessions.ts — TTL-only repos
|
||||
│ │ ├── routing/ # worker-router.ts — engine A's server surface
|
||||
│ │ ├── guidance/ # cron.ts — the pure turn-card projection
|
||||
│ │ └── 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
|
||||
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, R2, cron
|
||||
├── 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
|
||||
│ │ ├── search/ # resolve-destination.ts, destination-card.ts
|
||||
│ │ ├── route/ # route.ts controller, card, markers, layers
|
||||
│ │ ├── guidance/ # guidance.ts machine, watch.ts, banner, eta, camera, haptics
|
||||
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area, homescreen
|
||||
│ │ ├── 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)
|
||||
│ ├── public/glyphs/ # turn-card PNGs the cron deep-links
|
||||
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain, guidance)
|
||||
│ ├── scripts/capture.mjs # screenshot capture
|
||||
│ └── vite.config.ts # dual-build dist/ vs dist-harness/
|
||||
├── search/ # @araharah/search — the gazetteer data plane
|
||||
│ ├── src/ # gazetteer.ts, source.ts, score.ts, normalize.ts, types.ts
|
||||
│ ├── src/data/ # kl-gazetteer.json (committed, ODbL notice inside)
|
||||
│ ├── scripts/ # gazetteer-corpus.mjs, json-to-sqlite.mjs, overpass-topup.mjs
|
||||
│ └── test/ # runner + suites + golden corpus fixtures
|
||||
├── routing/ # @araharah/routing — the routing data plane
|
||||
│ ├── src/ # graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts,
|
||||
│ │ # fixture-source.ts, http-source.ts, types.ts, index.ts
|
||||
│ ├── data/ # kl-roadgraph.v1.bin — BUILD ARTIFACT, not committed
|
||||
│ ├── scripts/ # build-road-graph.mjs, upload-road-graph.mjs,
|
||||
│ │ # build-glyphs.mjs, route-corpus.mjs, fetch-route-fixtures.mjs
|
||||
│ └── test/ # runner + suites + mini-graph fixtures
|
||||
├── 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
|
||||
│ ├── 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
|
||||
├── scripts/ # root audits — privacy-audit.mjs, budget-audit.mjs,
|
||||
│ │ # lib/ (source-gates, bundle-gates, budget-gates,
|
||||
│ │ # cloudflare-probes), test/ (their suites)
|
||||
├── stitch_minimalist_route_navigator/ # read-only design reference (mockups)
|
||||
└── docs/
|
||||
```
|
||||
@@ -423,20 +666,30 @@ araharah/
|
||||
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.
|
||||
place that decides which path runs, so `run_worker_first`, the secret-token gate,
|
||||
the body caps, and the cron tick all apply uniformly.
|
||||
- **`bot/src/security/`, `bot/src/kv/`, and the two data-plane packages 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; `@araharah/search` and `@araharah/routing` are V8-native with zero runtime
|
||||
dependencies, no env reads, and no network — the same sources run inside the Worker
|
||||
bundle, the Vite bundle, and the Node corpus runners.
|
||||
- **The backend ladders are constructed in code, not configured.** Search and routing
|
||||
each expose a seam (`SearchSource`, `RoutingSource`) whose backend selection happens
|
||||
at the import site — no env read, no config read, no dynamic registry. Swapping a
|
||||
backend is an import-site change.
|
||||
- **`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.
|
||||
trip history, profile) and keeps the ~15-minute TTL wording, in both locales.
|
||||
- **`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/route/` + `web/src/guidance/` split wiring from decisions.**
|
||||
`route.ts` only wires (consult once, draw, start the pump); `guidance.ts` is the pure
|
||||
state machine that decides every guidance behavior; `watch.ts` owns the throttle; the
|
||||
view modules (banner, eta, camera, haptics) each own one concern. Node-testable with
|
||||
injected DOM/map/geo.
|
||||
- **`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.
|
||||
@@ -446,31 +699,85 @@ Rationale for the boundaries:
|
||||
- **`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.
|
||||
- **`scripts/` is the audit layer, not product code.** `privacy-audit.mjs` and
|
||||
`budget-audit.mjs` are maintainer-time gates that import from `scripts/lib/`; nothing
|
||||
in `bot/src`, `web/src`, `routing/src`, or `search/src` imports them.
|
||||
- **`routing/data/` is a build artifact by law.** The 15 MB graph binary is gitignored;
|
||||
forks build and upload their own (`docs/ROAD-GRAPH.md` is the fork contract).
|
||||
- **`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
|
||||
### Privacy by construction — the gate lineage
|
||||
|
||||
Two grep gates pin this rather than documenting it:
|
||||
Two layers of 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.
|
||||
**The consolidated maintainer audit** — `npm run privacy-audit`
|
||||
(`scripts/privacy-audit.mjs`), five static gates in `scripts/lib/source-gates.mjs` and
|
||||
`scripts/lib/bundle-gates.mjs`, plus `--live` probes in
|
||||
`scripts/lib/cloudflare-probes.mjs`:
|
||||
|
||||
| Gate id | Rule |
|
||||
| --- | --- |
|
||||
| `storage-api` | The memory-only law, repo-wide (the no-storage suite generalized). |
|
||||
| `kv-ttl` | The PG1 same-line TTL law, repo-wide — every `.put(` line in `bot/src` carries `expirationTtl` as a named constant. |
|
||||
| `log-vocabulary` | The D-14 category-only log law — no coordinate, payload, user id, or timestamp in any log line. |
|
||||
| `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch to any host outside it. |
|
||||
| `bundle-telemetry` | The PRODUCTION `web/dist` scan — no analytics, no beacon, no telemetry in the shipped artifact. |
|
||||
| `kv-expiry` / `asset-headers` (with `--live`) | Live expiry metadata and cache-header verification — a skipped probe is never mistaken for a pass (exit 2 = NOT RUN). |
|
||||
|
||||
Exit codes: `0` clean, `1` violations, `2` NOT RUN (environment drift) — no silent
|
||||
passes. The audit doubles as the fork onboarding check. `docs/PRIVACY-AUDIT.md` is the
|
||||
human record.
|
||||
|
||||
**The in-suite gates** (the same laws asserted where the code lives):
|
||||
|
||||
- `bot/test/privacy.test.mjs` — the PG1/PG2 lineage: every `put` on `chat_sessions`
|
||||
(now spanning `sessions.ts`, `location-pin.ts`, `guidance-sessions.ts`, and
|
||||
`webhook.ts`) must carry an explicit `expirationTtl` on the same call line, as a
|
||||
named `SCREAMING_SNAKE` constant. 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`.
|
||||
`document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`,
|
||||
or any service-worker registration.
|
||||
- `bot/test/route-privacy.test.mjs` — the route handler's structural proof: the only
|
||||
persisted surface is the guidance envelope; the route request's origin never touches
|
||||
storage or a log line.
|
||||
|
||||
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.
|
||||
language_code) for 900 s, `LocationPin` (latitude, longitude, horizontal_accuracy)
|
||||
for 300 s, and the `GuidanceSessionEnvelope` for 7200 s — all in ONE KV namespace.
|
||||
On the web side, guidance on-route fixes never leave the device; the ONLY
|
||||
network-bound position is a reroute's origin (one point, on a real excursion, snapped
|
||||
server-side). The client holds the token, the destination, and the last fix in module
|
||||
scope only.
|
||||
|
||||
### TTL-only storage law
|
||||
|
||||
Every KV write carries an explicit TTL; nothing survives past it:
|
||||
|
||||
| Key family | TTL | Constant |
|
||||
| --- | --- | --- |
|
||||
| `session:<token>` / `chat:<chat_id>` | 900 s (~15 min) | `SESSION_TTL_SECONDS` |
|
||||
| `loc:<chat_id>` | 300 s | `PIN_TTL_SECONDS` |
|
||||
| `guidance:<uuid>` | 7200 s (~2 h) | `GUIDANCE_SESSION_TTL_SECONDS` |
|
||||
|
||||
The R2 bucket (`ROAD_GRAPH`) is a read-only binary host, not a persistence surface.
|
||||
Exactly ONE `kv_namespaces` entry is the law (`GE5`) — a new KV family is a design
|
||||
change, not a refactor.
|
||||
|
||||
### 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.
|
||||
Every chat-scoped route (`/api/location`, `/api/location-fallback`, `/api/guidance`)
|
||||
resolves the `chat_id` from the Bearer session claim, never from client input; locale
|
||||
comes from `claim.language_code`. The location-fallback request body is never read; a
|
||||
guidance POST's `chat_id` field is structurally dropped (`validateSnapshot` builds the
|
||||
new object field-by-field); GET/DELETE compare `envelope.chat_id === claim.chat_id` so
|
||||
foreign and absent are indistinguishable 404s (no existence oracle for cross-chat
|
||||
probing). `/api/route` uses the claim purely as an identity gate — routing itself is
|
||||
chat-agnostic and nothing about the caller is stored, logged, or echoed.
|
||||
|
||||
### Honest degradation
|
||||
|
||||
@@ -479,26 +786,54 @@ Every degradation is a labeled state, never a silent one or a fabricated value:
|
||||
`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.
|
||||
timeout resolves `null` so the chain always advances; **the route null contract** —
|
||||
every `/api/route` miss class answers `200 null` and the client renders the pending
|
||||
card; a null road graph degrades the cron card to the bare-verb street-less
|
||||
composition — the send still fires; guidance deviation becomes a labeled off-route
|
||||
state with a storm-guarded reroute, never a silent camera jump.
|
||||
|
||||
### Logging
|
||||
### Category-only 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.
|
||||
The Worker's only log statements are categories — `[webhook] rejected: misconfigured`,
|
||||
`[initdata] rejected: malformed|tampered|stale`, `[location]`/`[location-fallback]`/
|
||||
`[guidance]`/`[route] rejected: unauthorized|malformed`,
|
||||
`[guidance-cron] skipped: unconfigured|malformed`, `[guidance-cron] send failed`,
|
||||
`[location-fallback] failed: telegram-send`. Never raw payloads, never user ids,
|
||||
never chat ids, never coordinates, never timestamps. Rejection responses carry the
|
||||
category only. The web app's console lines are state tokens (`ARAHARAH-ROUTE`,
|
||||
`ARAHARAH-GUIDE`, `RAMS-BASEMAP-READY`, `ARAHARAH-LIVE`) — never coordinates. The
|
||||
audits (`log-vocabulary` gate) prove this mechanically.
|
||||
|
||||
### The budget law
|
||||
|
||||
`npm run budget-audit` (`scripts/budget-audit.mjs`) measures the free-tier posture
|
||||
instead of assuming it: route CPU (the A\* ladder, child-process), bundle size, KV
|
||||
write classes (the cron's ZERO-writes law), subrequest classes, and the golden
|
||||
corpora; `--live` adds observed cache headers. The record is `docs/BUDGETS.md`. The
|
||||
cron's cadence is tuned to it: `*/2 * * * *` × one KV list-op per tick = 720
|
||||
list-ops/day, inside the free plan's 1,000/day KV-list ceiling, and each tick performs
|
||||
zero KV writes (stateless projection over the 7200 s envelopes).
|
||||
|
||||
### 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.
|
||||
Five workspaces, one test idiom: `bot/test`, `web/test`, `search/test`, and
|
||||
`routing/test` each 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, and `scripts/test/` holds the audit tools' own suites. Gates are
|
||||
grep/introspection assertions against source and build artifacts, not just behavioral
|
||||
assertions. The corpus suites (`search/test/corpus.test.mjs`,
|
||||
`routing/test/corpus.test.mjs`) assert the same measurements the standalone corpus
|
||||
runners print — measured quality and shipped quality cannot drift.
|
||||
|
||||
## 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+.
|
||||
The following are documented targets, **not** in the codebase: **engine B** (the fully
|
||||
client-side webview router — graph on the phone; engine A's documented post-v1
|
||||
evolution; the Valhalla-to-JS port was ruled infeasible and geojson-path-finder was
|
||||
rejected — recorded so neither is re-proposed), Valhalla-on-VPS (the documented
|
||||
provision path, not shipped), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
|
||||
opt-in telemetry (v1 carries zero telemetry — there is nothing to opt into yet), and
|
||||
the Maptiler tile provider (the basemap today is the OpenFreeMap `liberty` fork).
|
||||
Alternatives generation on `/api/route` is prohibited by design, not deferred.
|
||||
Search backend #1 (gazetteer) and routing backends #1/#2 are the only ladder rungs
|
||||
implemented; later rungs slot in at the import site without a client rewrite.
|
||||
|
||||
Reference in New Issue
Block a user