--- phase: 07-hardening-private-launch plan: 05 type: execute wave: 1 depends_on: [] files_modified: - bot/src/security/initdata.ts - bot/test/initdata.test.mjs - bot/wrangler.jsonc - bot/src/i18n/strings.ts - docs/KNOWN-LIMITATIONS.md - .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md autonomous: true requirements: - PX-04 must_have_notes: - "This plan pays the deferred debts (D-07) and ships the known-limitations document (D-08) — SC4's second half, delivered under the PX-04 phase." must_haves: truths: - "The exact-900-second initData freshness boundary is pinned by a test — a `>=` regression cannot ship." - "The dead `ParsedInitData` export and the unreachable branch are gone; the read helpers carry their intent comment." - "The stale wrangler.jsonc comment is replaced with a present-tense record of the live namespace id." - "A driver can reach the known-limitations document from /help in BOTH locales with one pointer line." - "The limitations document tells the truth about OSM data gaps, the cross-metro CPU degradation, the /cari menu defect, the beta quality, and the roadmap pointers." artifacts: - path: "bot/test/initdata.test.mjs" provides: "The T4b exact-900s boundary vector (IN-03)" contains: "age 900 s" - path: "bot/src/security/initdata.ts" provides: "The IN-01 cleanup — the dead export deleted, the intent comments added" contains: "tampered-vs-malformed" - path: "bot/wrangler.jsonc" provides: "The IN-02 present-tense namespace comment" contains: "chat_sessions namespace created via" - path: "bot/src/i18n/strings.ts" provides: "The /help limitations pointer line (en + ms)" contains: "Known limits:" - path: "docs/KNOWN-LIMITATIONS.md" provides: "The known-limitations document for drivers AND forks (D-08)" contains: "Known limitations" key_links: - from: "bot/src/i18n/strings.ts" to: "docs/KNOWN-LIMITATIONS.md" via: "the helpMenu pointer line names the doc's public URL — a SINGLE constant, never a scattered URL" pattern: "Known limits:" - from: "bot/src/i18n/strings.ts" to: "bot/scripts/register-commands.mjs" via: "the pointer line does NOT start with '/', so the menu parse (CS5) and the setMyCommands derivation are unaffected" pattern: "Had panduan:" - from: "bot/src/security/initdata.ts" to: "bot/test/initdata.test.mjs" via: "the exact-900s vector pins the strictly-greater comparator's INCLUSIVE edge" pattern: "900" --- Pay every debt D-07 parked, and ship the known-limitations document the drivers (and forks) need — with a single pointer line from /help. Purpose: D-07 — the three Phase-2 review INFOs (IN-01 dead code, IN-02 stale comment, IN-03 the unpinned 900 s security boundary), the /cari command-menu re-arm, and the Phase-3 A1/A3 assumption notes. D-08 — the roadmap's SC4 requires a known-limitations document BEFORE the first real drivers; an honest one is also the product's integrity claim. The privacy audit in plan 07-03 links to it, and the launch gate in plan 07-06 checks it. Output: the IN-01/IN-02/IN-03 fixes, the /cari re-arm record, the A1/A3 closure, `docs/KNOWN-LIMITATIONS.md` (new), and the /help pointer line in `bot/src/i18n/strings.ts` (en + ms). @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/07-hardening-private-launch/07-CONTEXT.md @.planning/phases/07-hardening-private-launch/07-RESEARCH.md @.planning/phases/07-hardening-private-launch/07-PATTERNS.md @.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md The IN-03 boundary vector — copy the T4 harness shape verbatim from `bot/test/initdata.test.mjs` lines 172–189: ```javascript test("T4 freshness boundary: age 899 s → ok:true", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now - 899); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, true, `expected ok:true at 899 s, got ${JSON.stringify(res)}`); }); ``` The comparator under test — `bot/src/security/initdata.ts` lines 193–196: ```typescript // — stale: age strictly beyond the D-13 window (899 ok / 900 ok / 901 stale) — if (nowSeconds - authDate > maxAgeSeconds) { return { ok: false, reason: "stale" as const }; } ``` The IN-01 targets — `bot/src/security/initdata.ts` lines 59–66 (the dead export) and lines 87–104 / 154–165 / 198–214 (the parse blocks whose catch branches keep raw/undefined): ```typescript /** Shape of a fully parsed initData payload (hash/signature excluded from * rawFields — they are auth metadata, not payload fields). */ export interface ParsedInitData { user: WebAppInitUser; chat?: WebAppInitChat; authDate: number; rawFields: Map; } ``` The IN-02 target — `bot/wrangler.jsonc` line 2 (the stale comment) versus the live id on the `kv_namespaces` line: ```jsonc // KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions` ... "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] ``` The CS5 gate — `bot/test/commands.test.mjs` lines 134–155 (the menu parse and the exact-command assertion). The limitations pointer line MUST NOT start with `/` or it is parsed as a command and breaks CS5: ```javascript function menuEntries(helpMenu) { return helpMenu .split("\n") .filter((line) => line.startsWith("/")) .map((line) => { const em = line.indexOf(" — "); assert.ok(em !== -1, `helpMenu line lacks its " — " description marker: ${line}`); return { command: line.slice(1, em), description: line.slice(em + 3).trim() }; }); } ``` The `helpMenu` strings as they stand — `bot/src/i18n/strings.ts` lines 94–95 (en) and 132–133 (ms): ```typescript helpMenu: "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores\n/cari — Search for a destination in the KL metro", ``` ```typescript helpMenu: "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL", ``` The `/help` handler — `bot/src/bot/commands/help.ts` lines 18–20 (the reply is the `helpMenu` string verbatim, so the pointer line rides it automatically): ```typescript export function helpReplyText(locale: Locale): string { return strings[locale].helpMenu; } ``` The re-arm tool — `bot/scripts/register-commands.mjs` (it arms every scope it may shadow: default, default+ms, all_private_chats, all_private_chats+ms, and clears the group scopes; it derives its descriptions from `helpMenu`). task 1: pay IN-01 and IN-02 (dead code + the stale wrangler comment) and pin IN-03 (the exact-900s boundary) bot/src/security/initdata.ts, bot/test/initdata.test.mjs, bot/wrangler.jsonc - bot/src/security/initdata.ts (the dead export, the parse blocks, the comparator) - bot/test/initdata.test.mjs (the T4 harness shape + `signedUser`/`loadValidator` helpers) - bot/wrangler.jsonc (line 2's stale comment versus the live namespace id) - .planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md §Info (IN-01, IN-02, IN-03 verbatim) **IN-01** — in `bot/src/security/initdata.ts`: 1. DELETE the dead `ParsedInitData` interface (lines 59–66) entirely, INCLUDING its doc comment. It is never constructed and never returned (`validateInitData` returns `InitDataResult`). Before deleting, run `grep -rn "ParsedInitData" bot/ web/ routing/ search/` and confirm the ONLY hits are the declaration itself — if another file imports it, do NOT delete it; instead re-point it with an intent comment naming why it stays, and record that in the SUMMARY. 2. Add ONE intent comment over the `parseFields` catch block (lines 96–100) reading, in the file's voice: `// Intent: an undecodable percent-escape keeps the RAW value so the HMAC compare fails ("tampered") rather than silently dropping the field — malformed-vs-tampered precedence (D-14).` 3. Add ONE intent comment over the `user` JSON parse catch (lines 161–164) reading: `// Intent: a malformed user payload is the "malformed" class, not a tampered one — the field never reaches the HMAC path.` 4. Add ONE intent comment over the `chat` JSON parse catch (lines 210–213) reading: `// Intent: a malformed chat payload degrades to "no chat context" (a private chat) — it never fails an otherwise-valid private-chat payload.` **IN-02** — in `bot/wrangler.jsonc`, REPLACE line 2 (the stale "filled in plan 03 task 1" comment) with this present-tense record, in the file's own comment voice: ```jsonc // chat_sessions namespace created via `npx wrangler kv namespace create chat_sessions` — the id below is live. ``` **IN-03** — in `bot/test/initdata.test.mjs`, add EXACTLY ONE new case immediately after the existing `T4 freshness boundary: age 901 s → reason stale` case, copying that case's harness shape verbatim (`loadValidator()`, `signedUser(now - 900)`, `TEST_TOKEN`): ```javascript test("T4 freshness boundary: age 900 s → ok:true (the boundary is INCLUSIVE)", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now - 900); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, true, `expected ok:true at exactly 900 s, got ${JSON.stringify(res)}`); }); ``` The case name MUST contain the literal `age 900 s` — it pins the comparator direction (`>` vs `>=`) that the implementation comment already claims. Do NOT change the comparator in `initdata.ts` — the implementation is correct today; the test is what makes the claim a pin. Finally, run `npm test -w bot` and `npm run bot:typecheck` — both must pass. - `grep -c "ParsedInitData" bot/src/security/initdata.ts` returns 0 (the dead export is gone) and `grep -rn "ParsedInitData" bot/src web/src` returns nothing - `grep -c "Intent:" bot/src/security/initdata.ts` returns ≥ 3 (the three read-helper intent comments) - `grep -c "age 900 s" bot/test/initdata.test.mjs` returns ≥ 1 (the IN-03 vector) - `grep -c "plan 03 task 1" bot/wrangler.jsonc` returns 0 (the stale comment is gone) and `grep -c "the id below is live" bot/wrangler.jsonc` returns 1 - `npm test -w bot` exits 0 with `0 failed` export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -12 && npm run bot:typecheck 2>&1 | tail -5 IN-01's dead export is gone and the three read helpers carry their intent comments; IN-02's comment states the post-fact truth; IN-03's exact-900s vector is green. task 2: the known-limitations document (D-08) + the /help pointer line (en + ms) docs/KNOWN-LIMITATIONS.md, bot/src/i18n/strings.ts - docs/CONFIGURATION.md (the clinical-prose style: a leading "surfaces, by kind" table, `File | Applies to` scoping, backticked identifiers, bold for requirements, every statement names its evidence) - bot/src/i18n/strings.ts (the D-05 law in the header + the two helpMenu strings) - bot/test/commands.test.mjs (the CS5 menu parse — the pointer line must not start with "/") - .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-08 verbatim) - routing/scripts/measure-route-cpu.mjs (the LADDER HISTORY, lines 30–40 — the c3 evidence) Create `docs/KNOWN-LIMITATIONS.md` (NEW — the whole file is new), in the `docs/CONFIGURATION.md` clinical-prose style (no marketing, no emoji, one honest sentence per claim, every claim naming its evidence). It is written for TWO readers — a DRIVER and a FORK — and must contain these sections in this order: 1. `# Known limitations` + a one-paragraph lead: what araharah is today (a KL-metro turn-by-turn navigator inside Telegram, on OpenStreetMap data), and the plain statement that this page lists what it does NOT do well. 2. `## Data coverage` — the OSM gaps, honestly: - **No traffic awareness.** There is no live traffic feed; the ETA is a distance-and-speed estimate from the route model, not a prediction. - **No lane guidance.** There is no lane-level arrow guidance; the maneuver glyph names the turn, not the lane. - **Speed limits appear only where OSM maps them** — many KL roads have no `maxspeed` tag, so no limit is shown. - **Turn restrictions and one-ways** are only as good as the OSM snapshot the road graph was built from; a recent change may not be reflected. - **The road graph is a build-time artifact** (`routing/data/kl-roadgraph.v1.bin`), rebuilt by a maintainer — forks rebuild their own (`docs/ROAD-GRAPH.md`). 3. `## Cross-metro routes may not resolve` — the c3 class, in one paragraph: short product routes (2–8 km) measure 2–8 ms of Worker CPU (inside the free plan's 10 ms), while cross-metro routes (20–30 km) measured p95 38–93 ms and therefore degrade HONESTLY to the router-pending card ("the router is not ready for this trip") rather than freezing or drawing a wrong line. Evidence: `routing/scripts/measure-route-cpu.mjs` (LADDER HISTORY) and the Phase 6 plan summary. The committed upgrade lever is the ALT landmark preprocessing — a NEXT-MILESTIME item, NOT available in this release. State the free-tier reason: Workers Paid was rejected (the $0 mandate). 4. `## The /cari command may be missing from the Telegram menu` — the Telegram `setMyCommands` non-default-scope defect. State the symptom: the menu shows /help and /privacy but not /cari (Telegram resolves a chat's menu from the narrowest scope that has ANY commands set, and a stale all_private_chats set shadows the default scope). The WORKAROUND: type `/cari` in the chat — the command works. The bot re-arms every scope (`bot/scripts/register-commands.mjs`) but the Telegram client may cache the old menu until the app restarts. 5. `## Beta quality` — one honest paragraph: this is a private beta with a handful of drivers in Kuala Lumpur; the device matrix was walked on real Android, iOS, Telegram Desktop and a plain browser; battery drain was MEASURED on one Android phone (record the method and the caveats pointer: the launch gate file holds the run); fullscreen and orientation behaviour varies by Telegram client version, and the app degrades to a usable portrait/landscape layout when the platform refuses a fullscreen request. Name the KNOWN limits honestly: no voice guidance, no live trip sharing, no toll toggle, no traffic ETA — those are post-validation milestones, not defects. 6. `## What is being worked on next` — the roadmap pointers D-08 names, each with its honest status: the WASM-router track (a client-side router as the static-hosted endgame), the ALT landmark preprocessing (the cross-metro lever), and the "engine B" (a fully client-side webview router). State that none of them are in this release. 7. `## Privacy in one paragraph` — coordinates live only in memory and in TTL-expiring KV (15-minute sessions, 5-minute location pins, 2-hour guidance envelopes); there is NO telemetry of any kind in this release — no analytics, no crash reporting, no usage tracking; the shipped bundle contacts only the map tile host, the Telegram API, and the app's own Worker. Point at `docs/PRIVACY-AUDIT.md` for the full record. 8. `## How to run the audit yourself` — for the FORK reader: the exact command `npm run privacy-audit` and the exact command `npm run budget-audit`, with one line on what each proves and what exit code 2 means (environment drift, never a pass). THEN — in `bot/src/i18n/strings.ts`, add ONE pointer line to the END of BOTH `helpMenu` strings. It MUST NOT start with `/` (CS5). Use EXACTLY these strings: - en — append `\nKnown limits: https://github.com/araharah/araharah/blob/main/docs/KNOWN-LIMITATIONS.md` - ms — append `\nHad panduan: https://github.com/araharah/araharah/blob/main/docs/KNOWN-LIMITATIONS.md` The URL MUST be identical in both locales and MUST appear in EXACTLY ONE place per locale (the strings module is the single copy source — the D-05 law). If the repository's public URL is not `github.com/araharah/araharah`, use the repo's actual remote URL (run `git remote -v` and use the HTTPS remote) — but the SAME URL in both locales, and record the choice in the SUMMARY. After the edit, run `npm test -w bot` — CS5 must still pass (the pointer line is filtered out of the menu parse because it does not start with `/`). - `docs/KNOWN-LIMITATIONS.md` exists and contains the section headings `## Data coverage`, `## Cross-metro routes may not resolve`, `## The /cari command may be missing from the Telegram menu`, `## Beta quality`, `## What is being worked on next`, `## Privacy in one paragraph`, `## How to run the audit yourself` - `grep -c "KNOWN-LIMITATIONS.md" bot/src/i18n/strings.ts` returns exactly 2 (one per locale — the single-copy law) - `grep -c "Known limits:" bot/src/i18n/strings.ts` returns 1 and `grep -c "Had panduan:" bot/src/i18n/strings.ts` returns 1 - `grep -c "helpMenu" bot/src/i18n/strings.ts` returns ≥ 2 (unchanged shape) - `npm test -w bot` exits 0 with `0 failed` (CS5 still green — the pointer line is not parsed as a command) export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -12 && grep -c "KNOWN-LIMITATIONS.md" bot/src/i18n/strings.ts The limitations document exists with all seven sections and the honest c3 and /cari content, and /help carries one pointer line per locale that the CS5 menu gate still ignores. task 3: the /cari re-arm attempt + the A1/A3 assumption closure, recorded honestly .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md - .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md (the A1 stale-fix leg at line 44 and the A3 local-dev note at line 99, plus the FINAL VERDICT table at line 258) - bot/scripts/register-commands.mjs (the re-arm tool — its scope table) - .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-07 items 2 and 3) Two records, both in `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` (append a new dated section at the END of that file — do not rewrite its history): **A — the /cari re-arm attempt.** Attempt to re-arm the command menu by running the existing tool: ```bash npm run bot:commands ``` This requires `BOT_TOKEN` in `bot/.dev.vars` (gitignored) or the environment. Record the outcome HONESTLY in a new `## 2026-10-25 — Phase 7 debt closure (/cari re-arm + A1/A3)` section: - If the script ran and every scope reported `ok:true`, record the six scope labels it printed and the literal line `all command scopes armed`. - If it could not run (no token, no network, a Telegram API error), record the EXACT error line and mark the re-arm **NOT RUN** — a fabricated "re-armed" claim is forbidden (D-05's honesty law). - In BOTH cases, state the residual risk: Telegram clients cache the command menu; the defect is a Telegram `setMyCommands` scope-resolution behaviour, not a bot bug — and `docs/KNOWN-LIMITATIONS.md` now carries the user-facing statement plus the workaround (typing `/cari`). **B — the A1/A3 assumption closure.** In the same section, close or re-point the two Phase-3 assumptions: - **A1** (the Android stale-fix hypothesis — that `navigator.geolocation.getCurrentPosition()` refreshes the stale native cache so the next `LocationManager.getLocation()` returns fresh coordinates). The Phase-3 device matrix recorded the A1 leg as passed with the note "A1 notes stand in 03-RESEARCH.md" — the walk completed WITHOUT needing the toggle. Record the honest closure: **A1 is NOT RETIRED as a mechanism hypothesis** — it was simply not needed on the tested device and route; the app's chain degrades gracefully to polling when the stale-fix path does not fire. The risk it covers is therefore MITIGATED-BY-DESIGN rather than closed. State that in one sentence and point at the degradation path (`web/src/location/chain.ts`). - **A3** (that `localhost:8787` is a secure context so `navigator.geolocation` works under `wrangler dev` while a LAN-IP dev URL would not). Record the closure: **A3 IS RETIRED as a decision constraint** — the project deploys to a remote origin (`workers.dev`), not to a LAN-IP dev server, so the assumption no longer constrains any open decision. State it in one sentence. Do NOT edit the Phase-3 FINAL VERDICT table (it is the historical record); the new dated section is the closure. - `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` contains the literal `## 2026-10-25 — Phase 7 debt closure (/cari re-arm + A1/A3)` - the new section contains the literal `A1` and the literal `A3` and the literal `/cari` - the new section contains either `all command scopes armed` (the run happened) OR an explicit `NOT RUN` with the exact error line (it did not) — never a fabricated success - `grep -c "FINAL VERDICT" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` returns 1 (the historical table is untouched) export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && grep -c "Phase 7 debt closure" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c "A3 IS RETIRED" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md The /cari re-arm outcome and the A1/A3 closure are recorded in the Phase-3 device matrix, honestly — with the re-arm either armed or explicitly NOT RUN. ## Trust Boundaries | Boundary | Description | |----------|-------------| | The Telegram client → the bot's command menu | The client resolves the menu from the narrowest scope with any commands; a stale scope silently hides a command. | | The initData comparator → the session gate | The 900 s freshness edge is the authentication boundary; a `>=` regression widens the acceptance window by one second. | | The limitations document → the driver | A doc that overstates capability is a false promise; the doc must state only what the measurements support. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-07-08 | Spoofing (Authentication Bypass) | `bot/src/security/initdata.ts` — the 900 s freshness comparator | mitigate | The exact-900s inclusive boundary is pinned by a named test vector (`T4 freshness boundary: age 900 s → ok:true`); a `>=` regression turns that case red. The comparator itself is unchanged — the test is what makes the existing claim a pin. | | T-07-23 | Spoofing | `bot/wrangler.jsonc` — the stale comment telling a future maintainer to re-create the namespace | mitigate | IN-02 replaces the plan-scoped comment with a present-tense record naming the live id's provenance; a maintainer following the comment can no longer create a replacement namespace. | | T-07-24 | Elevation of Privilege | the `/cari` menu gap — a driver cannot find the search command | mitigate | The re-arm tool arms every shadowing scope (default, default+ms, all_private_chats, all_private_chats+ms) and clears the group scopes; the residual Telegram-side caching behaviour is disclosed to drivers with the type-the-command workaround in `docs/KNOWN-LIMITATIONS.md`. The command itself was always functional — the gap is discoverability, not authorization. | | T-07-25 | Information Disclosure | `docs/KNOWN-LIMITATIONS.md` — a doc that names internal identifiers | accept | The doc names the road-graph binary path and the CPU measurement script — both are PUBLIC repository artifacts under GPL-3.0 and are documented for forks by design. No secret, no token, no coordinate appears. | | T-07-26 | Repudiation | the debt closure record | mitigate | The re-arm outcome is recorded either as the printed scope results or as an explicit `NOT RUN` with the verbatim error — the record never asserts an outcome that did not happen (the phase's no-fake protocol). | Run after all tasks: ```bash export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" cd /home/C-Fu/araharah npm test -w bot npm run bot:typecheck ``` Both must exit 0. `bot/test/commands.test.mjs`'s CS5 case must pass — the limitations pointer line must stay invisible to the command-menu parse. - The dead `ParsedInitData` export is gone, the three read helpers carry their intent comments, and the stale wrangler comment states the post-fact truth. - The exact-900s inclusive boundary is pinned by a green test vector. - `docs/KNOWN-LIMITATIONS.md` exists with all seven sections, covering OSM data gaps, the c3 cross-metro degradation with its measurement evidence, the /cari menu defect with its workaround, the beta quality statement, the roadmap pointers, the privacy posture, and the fork-facing audit commands. - `/help` carries exactly one limitations pointer line per locale, in the strings module only, and CS5 still passes. - The /cari re-arm attempt and the A1/A3 closure are recorded in the Phase-3 device matrix honestly. After completion, create `.planning/phases/07-hardening-private-launch/07-05-SUMMARY.md`