Files

669 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 04-destination-search-kl-gazetteer
plan: 03
type: execute
wave: 2
depends_on: [04-01]
files_modified:
- bot/src/bot/commands/cari.ts
- bot/src/bot/handlers/search-alternate.ts
- bot/src/bot/bot.ts
- bot/src/i18n/strings.ts
- bot/src/config.ts
- bot/scripts/register-commands.mjs
- bot/test/commands.test.mjs
- bot/test/cari.test.mjs
- bot/package.json
- bot/.dev.vars.example
- bot/wrangler.jsonc
- docs/CONFIGURATION.md
autonomous: true
requirements: [SRCH-03, ONBD-02]
must_haves:
decisions_implemented:
- "D-01: search entry is the `/cari` command in chat; `/pergi_ke` stays RESERVED for Phase 5 and must NOT be registered; no free-text-to-bot, no inline mode, no in-webview search UI this phase"
- "D-02: the best result ships immediately as the venue card with its OPEN button; 2-4 alternates ride as inline buttons on the SAME message; one tap to proceed, one tap to switch"
- "D-03: the search backend is the pluggable `SearchSource` interface; the in-Worker gazetteer is backend #1 — no Photon, no VPS work in this phase"
- "D-10: the venue card is Bot API `sendVenue` (latitude, longitude, title, address) carrying inline buttons"
- "D-11 + RESEARCH Pitfall 1 (MECHANISM CORRECTED): the deep link carries a PLAIN gazetteer id with NO crypto. The `web_app` inline button's `WebAppInfo` has EXACTLY ONE FIELD — `url` — so the id travels as a URL query param `?dest=<id>` read from `location.search` in the webview. `initData.start_param` is the SECONDARY path (the documented direct-link / home-screen route). The locked intent — venue card -> one tap -> Mini App preloaded with a plain id, no crypto — is UNCHANGED; only the carrier field is corrected"
- "D-15: search queries are user payload — category-only logging; the raw query never appears in a log line"
truths:
- "Typing '/cari KLCC' in Telegram produces ONE message: a native venue card for Suria KLCC (pin, name, address) carrying an OPEN IN ARAHARAH button plus 2-4 inline alternates"
- "The OPEN button's URL is the configured deployed origin plus '?dest=<id>' — the id is a plain slug, never a signed token, and the origin never comes from user input"
- "Tapping an alternate swaps the venue card to that entry, in the same chat, without a second user round"
- "Typing '/cari Kuching' produces an honest 'not in v1 area' reply with NO venue card, NO inline keyboard, and NO fabricated pin"
- "An over-long query (over 128 characters) is rejected before any search work, with no venue card"
- "No log line anywhere in the bot source can contain the user's raw query text"
- "The alternates' callback_data carries only 'alt:<entryId>' — never a name, a query, or a coordinate — and stays within Telegram's 64-byte limit"
- "Every callback query is answered within Telegram's 30-second window, including unknown and malformed ones"
- "The '/cari' line appears in the bot's command menu and in /help in BOTH locales"
- "The Mini App origin is a documented, configured value — `docs/CONFIGURATION.md` names MINI_APP_URL and says what it is for"
artifacts:
- path: "bot/src/bot/commands/cari.ts"
provides: "the /cari command handler: query extraction, the SearchSource call, the venue-card send, the refusal copy (D-01, D-02, D-10)"
contains: "registerCari"
- path: "bot/src/bot/handlers/search-alternate.ts"
provides: "the alternates callback_query handler + the catch-all that always answers (D-02)"
contains: "registerSearchAlternate"
- path: "bot/src/i18n/strings.ts"
provides: "the search copy in en + ms: the OPEN label, the honest refusal, the usage line, the over-cap line, the OSM attribution, the /cari helpMenu line (D-05 copy law)"
- path: "bot/test/cari.test.mjs"
provides: "the payload-shape gates: venue fields, keyboard contents, callback_data format/length/no-leakage, answerCallbackQuery on every path, the no-match refusal, the no-crypto structural gate"
- path: "bot/test/commands.test.mjs"
provides: "the EXTENDED command-surface gates (CS4 carve for the inline keyboard, CS5's three-command menu, CS6's alias set, the narrowed future-stub ban)"
- path: "bot/src/config.ts"
provides: "the Env contract gaining MINI_APP_URL — the deployed Mini App origin used to build the deep link"
- path: "bot/scripts/register-commands.mjs"
provides: "the setMyCommands script deriving the /cari scope from helpMenu (the hardcoded command list gains 'cari')"
- path: "docs/CONFIGURATION.md"
provides: "the MINI_APP_URL row in the env-var table, with the 'public origin, not a secret' note"
key_links:
- from: "bot/src/bot/commands/cari.ts"
to: "@araharah/search"
via: "the handler calls the shared SearchSource — the same module the corpus measured"
pattern: "SearchSource"
- from: "bot/src/bot/commands/cari.ts"
to: "bot/src/i18n/strings.ts"
via: "every user-facing string is imported from the strings module; nothing inline"
pattern: "strings\\[locale\\]"
- from: "bot/src/bot/commands/cari.ts"
to: "bot/src/config.ts"
via: "the OPEN button's URL origin comes from env.MINI_APP_URL — never from user input"
pattern: "MINI_APP_URL"
- from: "bot/src/bot/handlers/search-alternate.ts"
to: "@araharah/search"
via: "the callback handler resolves 'alt:<entryId>' through the source's byId() — never by echoing the id into a reply"
pattern: "byId"
- from: "bot/test/commands.test.mjs"
to: "bot/src/bot/commands/cari.ts"
via: "the CS4 gate's documented carve for the venue-card surface (the location-fallback precedent)"
pattern: "cari.ts"
---
<objective>
Deliver the chat-side search surface: the `/cari` command (D-01), the single-message venue card with 2–4 inline alternates (D-02, D-10), the honest out-of-area refusal (D-13/D-14), and the OPEN button whose deep link carries a plain gazetteer id with no crypto (D-11, mechanism corrected per RESEARCH Pitfall 1) — plus the bilingual copy, the command-menu registration, the `MINI_APP_URL` configuration, and the four `commands.test.mjs` landmines that must land in the SAME change.
Purpose: this is the phase's user-facing surface and the half that ONBD-02 depends on. The research's single highest-value finding is that a `web_app` inline button CANNOT populate `start_param` — `WebAppInfo` has exactly one field, `url` (verified against the installed grammY typings this session). Building the OPEN button as a `web_app` button whose URL carries `?dest=<id>` and reading it from `location.search` in the webview is the carrier that actually works; `initData.start_param` remains the secondary path. The locked D-11 intent — a plain gazetteer id, no crypto, one tap from the venue card into a preloaded Mini App — is preserved exactly.
Output: `bot/src/bot/commands/cari.ts`, `bot/src/bot/handlers/search-alternate.ts`, the extended `bot/src/i18n/strings.ts`, the Env contract's `MINI_APP_URL` + the `docs/CONFIGURATION.md` row, the updated `register-commands.mjs`, the extended `bot/test/commands.test.mjs`, and a new `bot/test/cari.test.mjs`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@AGENTS.md
@.planning/phases/04-destination-search-kl-gazetteer/04-CONTEXT.md
@.planning/phases/04-destination-search-kl-gazetteer/04-RESEARCH.md
@.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md
@bot/src/bot/bot.ts
@bot/src/bot/commands/start.ts
@bot/src/bot/handlers/location-fallback.ts
@bot/src/i18n/strings.ts
@bot/src/config.ts
@bot/scripts/register-commands.mjs
@bot/test/commands.test.mjs
@bot/test/webhook.test.mjs
@docs/CONFIGURATION.md
@search/src/index.ts
@search/src/types.ts
<mechanism_correction>
**RESEARCH Pitfall 1 — the deep-link carrier, corrected. The D-10/D-11 intent is unchanged; only the carrier field is.**
D-11 as written reads `?startapp=dest_<id>`. The installed grammY typings prove the `web_app` inline button CANNOT carry that field:
```
node_modules/@grammyjs/types/markup.d.ts:219-222
export interface WebAppInfo {
/** An HTTPS URL of a Web App to be opened with additional data ... */
url: string;
}
```
`WebAppInfo` has EXACTLY ONE field — `url`. There is NO `start_param`. Telegram's official `messages.requestWebView` documents `start_param` as populated ONLY from an attachment-menu `startattach` deep link; the sibling `messages.requestAppWebView` populates it from a `?startapp=` DIRECT LINK (a `t.me` link) — a different launch surface from an inline keyboard button. So an OPEN button built as `web_app: { url: "<origin>/?startapp=dest_klcc" }` with the webview reading `initDataUnsafe.start_param` would open the Mini App with NO destination, and ONBD-02 would fail silently.
**The corrected carrier:**
| Piece | Value |
|---|---|
| Button type | `InlineKeyboard.webApp(label, url)` — a `web_app` button (D-10's shape is preserved: it is still an inline button on the venue card) |
| URL | `${MINI_APP_URL}/?dest=${entryId}` — the id in the URL's OWN query string |
| Primary reader (webview) | `new URLSearchParams(window.location.search).get("dest")` |
| Secondary reader (webview) | `initData.start_param` (the documented direct-link path, and the home-screen shortcut's path) |
| Payload | the PLAIN slug id (`suria-klcc`) — validated against `/^[A-Za-z0-9_-]{1,64}$/` |
| Crypto | NONE (D-11). A structural gate asserts no HMAC/signature/token code exists in the deep-link path. |
The locked intent — "venue card -> one tap -> Mini App preloaded with a plain gazetteer id, no crypto" — is delivered UNCHANGED. Only the mechanism (which Telegram field carries the id) is corrected. The `miniAppUrlFor(origin, id)` builder stays a single function so the carrier is a one-line swap if a device test fails.
**`MINI_APP_URL` — where the origin comes from.** The button's URL origin MUST be a CONFIGURED value, never derived from user input (RESEARCH §Security: "Open redirect / unvalidated Mini App URL — Spoofing"). The origin cannot come from `WEBHOOK_URL` reliably (that env var holds the webhook target including the `/telegram/webhook` path, and `docs/CONFIGURATION.md` line 34 records that the Worker never reads it), so a dedicated env var is the right call (RESEARCH Assumption A6 lists this as a cheap decision). It is a PUBLIC origin, not a secret — it lives in `bot/wrangler.jsonc`'s `vars`, never in a secret.
**Fallback when `MINI_APP_URL` is unset:** the OPEN button is still rendered, but the handler must not crash and must not guess an origin. It sends the venue card with the alternates only, and logs the category-only line `[search] rejected: mini-app-origin-missing`. The card still confirms the destination (SRCH-03) and the alternates still work (D-02).
</mechanism_correction>
<venue_card_contract>
`bot/src/bot/commands/cari.ts` — the handler. Structure copies `bot/src/bot/commands/start.ts` exactly (pure text builder → handler → register), with two additions: a command ARGUMENT (no existing handler takes one) and a keyboard surface.
**Query extraction.** grammY's standard route: `ctx.match` is the text after the command name. For `/cari KLCC`, `ctx.match === "KLCC"`.
**The length cap (V5 input validation — RESEARCH §Security V5 (a)).** The query is bounded BEFORE any processing:
```typescript
/** V5: the query is untrusted input — bounded before ANY processing. */
export const MAX_QUERY_CHARS = 128;
```
Over-cap queries are rejected with the honest "query too long" copy and NO venue card. This closes the hostile-query DoS surface (T-04-21).
**The empty-query case.** `/cari` with no argument replies with a short usage line (the locale's own copy) and sends NO venue card.
**The venue card.** `sendVenue` has NO `text` parameter (RESEARCH Pitfall 2 — confirmed against the Bot API method page) — its parameters are `latitude`, `longitude`, `title`, `address` (all four required) plus optional `reply_markup`. So the confirming line IS the card: `title` = the entry's canonical `name`, `address` = the entry's CURATED address string (verbatim, never the normalized form — RESEARCH Pitfall 3). D-02's "one message" shape holds: exactly ONE `sendVenue` call, no preceding `sendMessage`.
```typescript
await ctx.replyWithVenue(
entry.lat,
entry.lng,
entry.name,
entry.address ?? "",
{ reply_markup: keyboard },
);
```
**The keyboard.** One `InlineKeyboard`:
- Row 0: the OPEN button — `new InlineKeyboard().webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id))`.
- Row 1 (or row 2 for 4 alternates): 2-4 alternates, each `.text(alternate.name, "alt:" + alternate.id)`.
The alternates are the NEXT best hits after the best one, capped at 4. When the search produced only 1 hit, the keyboard carries the OPEN button alone (no fake alternates). When the search produced 2 hits, ONE alternate rides along (there is nothing else to show — never a fabricated row).
**The OPEN button URL builder** — one function, one place:
```typescript
/** The Mini App deep link for a gazetteer entry (D-11).
* Carrier corrected per RESEARCH Pitfall 1: a `web_app` inline button's
* WebAppInfo carries ONLY a `url` — there is no `start_param` field, and
* Telegram populates start_param only from an attachment-menu deep link.
* The id therefore travels in the URL's own query string and the webview
* reads it from location.search. start_param remains the secondary path
* (the documented direct-link mechanism, used by the home-screen shortcut).
* No crypto: the payload is a public place id (D-11). */
export function miniAppUrlFor(origin: string, id: string): string {
return `${origin.replace(/\/+$/, "")}/?dest=${encodeURIComponent(id)}`;
}
```
**The refusal.** An empty search result produces NO venue card and NO keyboard. The reply is the locale's honest "not in v1 area" copy — clinical, one line, naming the v1 area (RESEARCH Open Question 2's "not in v1 area" copy is OpenCode's discretion; the copy must never promise a pin it does not have).
**Logging (D-15).** Category-only. The handler's only permitted log lines:
```typescript
console.log("[search] rejected: no-match"); // no query text
console.log("[search] rejected: query-too-long"); // no query text
console.log("[search] rejected: empty-query"); // no query text
console.log("[search] rejected: mini-app-origin-missing");
```
NEVER the query, the results, the entry id, the chat id, or a timestamp. A source-introspection gate enforces this.
**The register function.**
```typescript
export function registerCari(bot: Bot, source: SearchSource, origin: string): void {
bot.command("cari", async (ctx) => { ... });
}
```
**Alias decision.** D-01 names `/cari` (the Malay "search"). Phase 2's alias scheme (D-04) paired `/start`↔`/mula`, `/privacy`↔`/privasi`. `/cari` is already the Malay word; its English twin would be `/search`. **This plan registers BOTH `/cari` and `/search`** — the bilingual alias precedent, and it keeps the CS6 alias-completeness gate honest. (`/pergi_ke` stays RESERVED for Phase 5 and must NOT be registered — CS5's future-stub ban keeps `/pergi_ke` banned and now permits only `/cari` + `/search`.)
**CS4 carve — the second deliberate exception to the "plain text only" command rule.** `bot/src/bot/handlers/location-fallback.ts` (lines 15-20) documents the FIRST exception (the GEO-02 reply keyboard). The venue card + InlineKeyboard is the SECOND. Two options exist: (a) put the keyboard surface in `handlers/` (outside CS4's scan list, which scans only `bot.ts` + `commands/*`), or (b) amend CS4 with a documented carve. **This plan takes option (b)** — the venue card IS a command's output, and hiding it from the gate would be the more dangerous choice: the gate is what keeps the keyboard surface honest. The carve is a documented exception in the CS4 test's own header, mirroring the location-fallback precedent, and it must distinguish an INLINE keyboard (allowed here) from a REPLY keyboard (still banned — the reply keyboard is the location-fallback handler's own surface).
**The `reply_markup` string problem.** CS4 greps `/keyboard/i` over the command sources. `InlineKeyboard` and `reply_markup` both trip it. The amended CS4 must therefore ban the REPLY-keyboard shape specifically, not the word "keyboard".
</venue_card_contract>
<alternates_contract>
`bot/src/bot/handlers/search-alternate.ts` — the alternates switching surface.
**Callback data format (RESEARCH Pitfall 10).** `callback_data` is `"alt:<entryId>"` — ONLY the entry id. Never a name, never a query, never coordinates. It is 1-64 bytes; the slug charset keeps it far below the limit. The entry id is a PUBLIC place id, so the payload carries no user data (D-15 posture).
**The shared card builder.** The venue card is built in ONE place so the card the user first sees and the card they get after tapping an alternate are structurally identical. `sendVenueCard` lives in `search-alternate.ts` and is imported by `cari.ts`:
```typescript
export async function sendVenueCard(
ctx: Context,
entry: GazetteerEntry,
alternates: readonly GazetteerEntry[],
origin: string,
locale: Locale,
): Promise<void>
```
`cari.ts` calls it with `hits.slice(1)`; the callback handler calls it with the alternates it resolved for the tapped entry.
**The handler.**
```typescript
export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): void {
bot.callbackQuery(/^alt:(.+)$/, async (ctx) => {
await ctx.answerCallbackQuery(); // must happen within 30 s
const id = ctx.match[1] ?? "";
if (!/^[a-z0-9-]{1,64}$/.test(id)) return; // hostile id → answer, then drop
const entry = source.byId(id);
if (entry === null) return; // honest null — never a fabricated card
await sendVenueCard(ctx, entry, await alternatesFor(entry, source), origin, locale);
});
// Always answer everything else so Telegram doesn't show a 60-second spinner.
bot.on("callback_query:data", async (ctx) => {
const data = ctx.callbackQuery?.data ?? "";
if (/^alt:/.test(data)) return; // the specific matcher owns this path
await ctx.answerCallbackQuery();
});
}
```
**The swap semantics.** Tapping an alternate sends a NEW `sendVenue` for that entry (with that entry's own alternates). `editMessageText` is FORBIDDEN on a venue message — it would replace the venue payload and destroy the card (RESEARCH §Anti-Patterns; A8 lists `editMessageReplyMarkup` as the alternative, but a fresh card matches D-02's "one tap to switch" and is the safe path). The OLD card stays in the chat as history — that is Telegram-native and honest.
**`answerCallbackQuery` on EVERY path** — the 30-second rule (RESEARCH §Security: "`answerCallbackQuery` not called — Denial of Service (UX spinner)"). The catch-all `bot.on("callback_query:data", ...)` is grammY's documented recommendation ("Always answer all other callback queries … Otherwise, some clients may display a loading animation for up to a minute").
**The alternates for a swapped card.** `alternatesFor(entry, source)` is ASYNC — `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise<GazetteerEntry[]>` — because the `SearchSource` interface's `search()` returns `Promise<SearchHit[]>` (only `byId()` is synchronous). It awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, filters out the entry itself, capping at 4. The callback caller is already async, so it awaits the result. If that yields nothing, the card carries the OPEN button alone.
</alternates_contract>
<strings_contract>
`bot/src/i18n/strings.ts` — the `BotStrings` interface GAINS these fields (both locales, identical key-sets — CS1 asserts this):
- `searchUsage` — the empty-query usage line: "/cari <place> — search for a destination in the KL metro." / ms equivalent.
- `searchNoMatch` — the honest refusal: names the v1 area and promises nothing. en: "Not in the v1 KL area. araharah only knows Kuala Lumpur and the Klang Valley for now." ms: "Bukan dalam kawasan KL v1. araharah hanya tahu Kuala Lumpur dan Lembah Klang buat masa ini."
- `searchQueryTooLong` — the over-cap refusal: "That query is too long. Send just the place name." / ms equivalent.
- `searchOpenButton` — the OPEN label: "OPEN IN ARAHARAH" (identical in both locales — it is a product affordance, not prose).
- `osmAttribution` — "© OpenStreetMap contributors" (IDENTICAL in both locales — the same string as `web/src/i18n/strings.ts` line 73).
**The alternates' button labels are NOT copy** — each alternate's label IS the entry's own `name` (public place data from the gazetteer), so no string field is needed for them.
**`helpMenu` gains the /cari line** in BOTH locales, and the line format is LOAD-BEARING — `commands.test.mjs`'s `menuEntries` parser (lines 102-111) requires each line to start with `/` and carry the " — " marker (space-em-space), and `register-commands.mjs`'s `menuFor` (lines 59-69) finds lines via `` const marker = `/${command} — ` ``. The existing en value is:
```
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores"
```
It becomes:
```
"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"
```
and the ms value gains the matching `\n/cari — <ms description>` line. The `/cari` line's description must be in the locale's own language.
**`register-commands.mjs` — the hardcoded command list.** Line 61 is `return ["help", "privacy"].map((command) => {` — it must become `return ["help", "privacy", "cari"].map((command) => {`. The script's `menuFor` THROWS when a `/cari` line is missing from `helpMenu` (lines 64-66), so the strings change and this change MUST land together. The script's TWO-fetch-call count stays at 2 (CS5 asserts exactly two `await fetch(` calls — the scopes, not the commands).
</strings_contract>
<commands_test_contract>
`bot/test/commands.test.mjs` — FOUR edits, all in the same change as the `/cari` landing. The suite is currently GREEN and goes RED the moment `cari.ts` + the strings land.
**CS4 (lines 78-87) — the keyboard carve.** The current gate greps `/keyboard/i` over `commandSources`. `cari.ts` contains `InlineKeyboard` and `reply_markup`. The amended gate must:
- keep the ban on the REPLY-keyboard shape (a `keyboard:` property inside a `reply_markup` object) — that is the location-fallback handler's own surface and it stays out of `commands/`;
- ALLOW the INLINE-keyboard shape (`InlineKeyboard` + `reply_markup` with `inline_keyboard`) in `commands/cari.ts`;
- document the carve in the suite's own header, in the same style as `location-fallback.ts` lines 15-20.
Implementation: keep `/keyboard/i` as the ban but apply it to every command source EXCEPT `commands/cari.ts`; in `cari.ts`, ban the reply-keyboard shape specifically with a pattern like `/reply_markup:\s*\{\s*keyboard/` and `/ReplyKeyboardMarkup/`.
**CS4b (lines 89-97) — the link-preview rule.** The current gate asserts that any file calling `ctx.reply(` carries `link_preview_options: { is_disabled: true }`. `cari.ts` calls `ctx.replyWithVenue(` (not `ctx.reply(`) for the card, and `ctx.reply(` for the refusal lines — those MUST carry `link_preview_options: { is_disabled: true }` so a query echoed into a refusal message is not turned into a link preview. (The refusal copy must NOT echo the query text — it names the area, not the query.)
**CS5 (lines 113-122) — the menu expectation.** `["help", "privacy"]` becomes `["help", "privacy", "cari"]` — the order is the helpMenu line order, which `menuEntries` preserves. The test name must change accordingly.
**CS5b (lines 136-145) — the future-stub ban.** The assertion `assert.doesNotMatch(source, /cari|pergi_ke/, "no future-command stubs …")` currently asserts the ABSENCE of `cari`. It must be inverted: `/cari` is now ALLOWED (it is a live command), but `/pergi_ke` must STAY BANNED (D-01 reserves it for Phase 5). The assertion becomes `assert.doesNotMatch(source, /pergi_ke/, "…")`.
**CS6 (lines 150-163) — the alias set.** `["help", "mula", "privasi", "privacy", "start"]` becomes `["cari", "help", "mula", "privasi", "privacy", "search", "start"]` (sorted). The grep `bot\.command\("([^"]+)"` scans `commandSources` — so `commands/cari.ts` MUST be added to that list (lines 30-32), otherwise CS6 never sees the new commands and the expected set would not match.
**The `commandSources` list (lines 30-32)** gains `"commands/cari.ts"`.
**CS1 (lines 36-41)** needs no change — the key-set identity assertion automatically covers the new string fields.
</commands_test_contract>
<cari_test_contract>
`bot/test/cari.test.mjs` — the NEW payload-shape suite. It uses the repo's two established gate styles:
**Style 1 — fake-transport payload capture.** `bot/src/bot/bot.ts` lines 29-31 already expose the seam: `createBot(env, { baseFetch })` — grammY's `client.fetch` override. `bot/test/webhook.test.mjs` lines 72-100 show the in-memory harness shape (`makeKV()` + undici `Request`/`Response`). The suite creates a fake `fetch` that records the outgoing request method, URL, and JSON body, then drives the bot with a synthetic Telegram update.
The suite drives the `/cari` handler by importing `registerCari` and registering it against a `Bot` whose `baseFetch` records the `sendVenue` request. Assert on the RECORDED request body, which is exactly what Telegram would receive.
**Style 2 — source-introspection structural gates** (the `commands.test.mjs` CS3/CS4 style) for the claims that are about structure, not behaviour.
Cases:
- `CR1 the venue payload is structurally correct` — the recorded `sendVenue` body carries `latitude` and `longitude` as FINITE numbers, `title` = the entry's `name` (non-empty), `address` = the entry's curated `address` (non-empty), and `reply_markup.inline_keyboard` present.
- `CR2 the OPEN button's URL carries the plain id and the configured origin` — row 0 of `inline_keyboard` is a button whose `web_app.url` is exactly `${origin}/?dest=<id>`; the origin part equals the configured `MINI_APP_URL` (never a query-derived value); the `dest` value matches `/^[A-Za-z0-9_-]{1,64}$/`.
- `CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate)` — read `bot/src/bot/commands/cari.ts` and `bot/src/bot/handlers/search-alternate.ts` (comments stripped) and assert they contain NONE of: `createHmac`, `crypto.subtle`, `sign(`, `hmac`, `signature`, `sha256`, `secret`.
- `CR4 the alternates ride on the SAME message` — the recorded request count for a query with >= 3 hits is exactly 1; the `inline_keyboard` has 2-4 alternate buttons whose `callback_data` all match `/^alt:[a-z0-9-]{1,64}$/`.
- `CR5 the callback_data carries no user data` — every `callback_data` in the recorded keyboard matches `^alt:<slug>$`; assert NONE of them contains a space, a query, a latitude, or a longitude, and that every `callback_data` byte length is <= 64.
- `CR6 a no-match query sends NO venue and NO keyboard` — for "Kuching": the recorded requests contain NO `sendVenue` call and NO `reply_markup`; the reply is the locale's `searchNoMatch` text.
- `CR7 an over-long query is rejected before any search` — a 200-character query produces no `sendVenue` and the `searchQueryTooLong` copy.
- `CR8 an empty query produces no venue card` — `/cari` with no argument sends the usage copy and no `sendVenue`.
- `CR9 every callback path answers the callback query` — the alternates handler's source contains `answerCallbackQuery` in the specific matcher AND in the catch-all; assert the catch-all exists by grepping for `bot.on("callback_query:data"` in the source.
- `CR10 the query text never reaches a log line (D-15)` — read `bot/src/bot/commands/cari.ts` (comments stripped) and assert no `console.log` line references a query-shaped variable (`query`, `ctx.match`, `text`, `q`); assert every `console.log` in the file matches `/\[search\] rejected: [a-z-]+/`.
- `CR11 the copy exists in both locales` — `strings.en.searchNoMatch` and `strings.ms.searchNoMatch` are non-empty and DIFFER (not an untranslated copy), and the same for `searchUsage` and `searchQueryTooLong`; `searchOpenButton` and `osmAttribution` are IDENTICAL across locales by design — assert that.
- `CR12 the helpMenu carries the /cari line in both locales` — the `menuEntries` parse yields a `cari` entry with a non-empty description in BOTH locales, and the description in `ms` is not byte-identical to the `en` one.
- `CR13 the registration script derives the /cari scope from helpMenu` — `register-commands.mjs` contains `"cari"` in its command list and still issues exactly TWO `await fetch(` calls.
- `CR14 the alternates swap produces a new venue card for the tapped entry` — the alternates handler, driven with a `callback_data` of `alt:<id>`, sends a `sendVenue` whose `title` is that entry's name.
- `CR15 a hostile callback id is dropped, not echoed` — a `callback_data` of `alt:../../etc/passwd` produces NO venue card (the id fails the slug validation).
</cari_test_contract>
<env_contract>
`bot/src/config.ts` — the `Env` interface GAINS:
```typescript
// The deployed Mini App ORIGIN (public, not a secret) — used to build the
// OPEN button's deep link. NOT derived from user input: the deep-link
// origin is a configured value (RESEARCH §Security V4 — an unvalidated
// origin would be an open redirect).
MINI_APP_URL: string;
```
`bot/wrangler.jsonc` — add a `vars` block (NOT a secret — it is a public origin):
```jsonc
"vars": { "MINI_APP_URL": "https://araharah-bot.<account>.workers.dev" },
```
The executor MUST replace the placeholder with the actual deployed origin (the same origin @BotFather has registered as the Mini App URL — the Bot API 10.2 origin restriction makes these the same value). If the origin is not yet known, use a clearly-marked placeholder (`"https://example.invalid"`) and note it in the SUMMARY — the OPEN button will then be built from that placeholder, which is exactly what the missing-origin fallback path is for.
`bot/.dev.vars.example` — add a commented `MINI_APP_URL=` line (the real `.dev.vars` is gitignored).
`docs/CONFIGURATION.md` — the env-var table (line 25, "## Environment variables") GAINS a row:
```
| `MINI_APP_URL` | **Required for the deep link** | none | Public origin of the deployed Mini App (e.g. `https://araharah-bot.<account>.workers.dev`) — NOT a secret, so it lives in `bot/wrangler.jsonc`'s `vars`, not in a secret. The `/cari` venue card's OPEN button is built from it: `<MINI_APP_URL>/?dest=<gazetteer id>`. It must equal the Mini App URL registered in @BotFather (Bot API 10.2 enforces the origin). The Worker never derives it from user input — an unvalidated origin would be an open redirect. |
```
Also update the `## Worker configuration file (bot/wrangler.jsonc)` section (line 129) to mention the `vars` block, and the `## Required vs optional settings` section (line 103) to name `MINI_APP_URL` among the non-secret configuration values.
`bot/package.json` — add `"@araharah/search": "*"` to `dependencies` (the `web/package.json` line 15 `"@araharah/theme": "*"` precedent) so the bot can import the shared module.
</env_contract>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: the search copy + the /cari command + the venue card + the OPEN deep link</name>
<files>bot/src/i18n/strings.ts, bot/src/bot/commands/cari.ts, bot/src/config.ts, bot/wrangler.jsonc, bot/.dev.vars.example, bot/package.json, docs/CONFIGURATION.md</files>
<read_first>
- `bot/src/i18n/strings.ts` (the whole file — the interface, the en/ms records, the `pickLocale` rule; every new string goes HERE and nowhere else)
- `bot/src/bot/commands/start.ts` (the whole file — the pure-builder -> handler -> register structure the new handler copies)
- `bot/src/bot/handlers/location-fallback.ts` (lines 15-20 — the header-comment style for documenting a deliberate exception to the text-only rule)
- `bot/src/config.ts` (the Env contract)
- `bot/wrangler.jsonc` (the current config — keep name, main, compatibility_date, workers_dev, assets, run_worker_first, kv_namespaces)
- `bot/package.json` (the dependencies block — the `"@araharah/theme": "*"` precedent lives in web/package.json)
- `docs/CONFIGURATION.md` (lines 25-35 the env-var table, line 103 "Required vs optional settings", line 129 the wrangler section)
- `search/src/index.ts` + `search/src/types.ts` (plan 04-01's output — the `SearchSource` the handler consumes)
</read_first>
<action>
**`bot/src/i18n/strings.ts`.** Add the six new `BotStrings` fields to the interface (each with a one-line doc comment citing its decision ID, matching the file's existing style) and the values to BOTH the `en` and `ms` records, EXACTLY per the `<strings_contract>` block. The `helpMenu` value in each locale gains the `\n/cari — <description>` line in the locale's own language. The `/cari` line's description:
- en: `Search for a destination in the KL metro`
- ms: `Cari destinasi di kawasan KL`
`osmAttribution` is `© OpenStreetMap contributors` in BOTH locales (identical — it is a legal attribution, not prose). `searchOpenButton` is `OPEN IN ARAHARAH` in BOTH locales (identical — a product affordance).
**`bot/src/config.ts`.** Add the `MINI_APP_URL` member per the `<env_contract>` block, with its explanatory comment.
**`bot/wrangler.jsonc`.** Add the `vars` block per the `<env_contract>` block. Keep every existing key byte-identical. The `MINI_APP_URL` value must be the same origin registered in @BotFather as the Mini App URL.
**`bot/.dev.vars.example`.** Add a commented line documenting `MINI_APP_URL` (public origin — it is a `vars` entry in `wrangler.jsonc`, not a secret, but the example file is where a maintainer looks).
**`bot/package.json`.** Add `"@araharah/search": "*"` to `dependencies`.
**`docs/CONFIGURATION.md`.** Add the `MINI_APP_URL` row to the env-var table per the `<env_contract>` block, and update the `## Required vs optional settings` and `## Worker configuration file` sections to name it.
**`bot/src/bot/commands/cari.ts`** — NEW. Implement EXACTLY per the `<venue_card_contract>` block. The file's header comment must carry the mechanism correction in full (the `<mechanism_correction>` block's content, condensed to the essential paragraph) and the D-02 exception carve (mirroring `location-fallback.ts` lines 15-20's wording). Type imports come from the shared module via the EXPLICIT SUBPATH-WITH-EXTENSION form — `import type { SearchSource, GazetteerEntry } from "@araharah/search/src/index.ts";` — the package has NO `exports` map (the `@araharah/theme` precedent), so a bare `@araharah/search` specifier resolves to nothing. Exports:
- `export const MAX_QUERY_CHARS = 128;`
- `export function miniAppUrlFor(origin: string, id: string): string`
- `export function searchUsageText(locale: Locale): string`
- `export function searchNoMatchText(locale: Locale): string`
- `export function registerCari(bot: Bot, source: SearchSource, origin: string): void`
The handler's flow:
1. `const raw = (ctx.match ?? "").trim();`
2. `if (raw.length === 0)` → reply `searchUsageText(locale)` with link previews disabled; log `[search] rejected: empty-query`; RETURN (no venue card).
3. `if (raw.length > MAX_QUERY_CHARS)` → reply `searchQueryTooLong` with link previews disabled; log `[search] rejected: query-too-long`; RETURN (no venue card, no search performed).
4. `const hits = await source.search({ text: raw, limit: 5 });`
5. `if (hits.length === 0)` → reply `searchNoMatchText(locale)` with link previews disabled; log `[search] rejected: no-match`; RETURN (no venue card, no keyboard — D-13's honest refusal).
6. Otherwise: build the alternates from `hits.slice(1)` (each `hits[i].entry`), then `await sendVenueCard(ctx, hits[0].entry, alternates, origin, locale)` — exactly ONE message (D-02). NO log line on the success path (there is nothing category-worthy to say, and every log line is a privacy surface).
**Task sequencing note:** at THIS task the card is a single inline `replyWithVenue` in `cari.ts` (this task's acceptance gate counts exactly one `replyWithVenue` here, and `npm run bot:typecheck` must pass without `search-alternate.ts` existing); task 2 extracts it into `sendVenueCard` (search-alternate.ts) and `cari.ts` imports it.
The OPEN button is `new InlineKeyboard().webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id))`.
When `origin` is empty or blank: skip the OPEN button, send the card with the alternates only, and log `[search] rejected: mini-app-origin-missing`. The card still confirms the destination (SRCH-03 holds).
**Register BOTH `/cari` and `/search`** (the bilingual alias precedent — Phase 2's D-04 paired `/start`↔`/mula`, `/privacy`↔`/privasi`). `/pergi_ke` is NOT registered (D-01 reserves it for Phase 5).
**Acceptance:** `npm run bot:typecheck` exits 0; the file compiles against grammY 1.46's `replyWithVenue` and `InlineKeyboard.webApp` signatures.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm install --package-lock-only 2>&1 | tail -3 ; npm run bot:typecheck 2>&1 | tail -10 ; grep -c 'replyWithVenue' bot/src/bot/commands/cari.ts ; grep -c 'MAX_QUERY_CHARS = 128' bot/src/bot/commands/cari.ts ; grep -c 'MINI_APP_URL' bot/src/config.ts bot/wrangler.jsonc docs/CONFIGURATION.md</automated>
</verify>
<acceptance_criteria>
- `npm run bot:typecheck` exits 0
- `grep -q 'searchNoMatch' bot/src/i18n/strings.ts` exits 0 AND `grep -q 'searchOpenButton' bot/src/i18n/strings.ts` exits 0 AND `grep -q 'osmAttribution' bot/src/i18n/strings.ts` exits 0 AND `grep -q 'searchUsage' bot/src/i18n/strings.ts` exits 0 AND `grep -q 'searchQueryTooLong' bot/src/i18n/strings.ts` exits 0 (all five new string fields exist)
- `grep -c '/cari — ' bot/src/i18n/strings.ts | head -1` returns 2 (the /cari helpMenu line in BOTH locales — the " — " marker is load-bearing for `menuEntries` AND `menuFor`)
- `node --input-type=module -e "const {strings}=await import('./bot/src/i18n/strings.ts'); const en=Object.keys(strings.en).sort(), ms=Object.keys(strings.ms).sort(); if(JSON.stringify(en)!==JSON.stringify(ms)) throw new Error('locale key-sets differ'); if(!strings.ms.searchNoMatch||strings.ms.searchNoMatch===strings.en.searchNoMatch) throw new Error('ms refusal copy untranslated'); if(strings.en.osmAttribution!=='© OpenStreetMap contributors') throw new Error('attribution'); console.log('strings OK')"` exits 0 (CS1's locale parity holds with the new fields)
- `grep -q 'export function registerCari' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("cari"' bot/src/bot/commands/cari.ts` exits 0 AND `grep -q 'bot.command("search"' bot/src/bot/commands/cari.ts` exits 0 (D-01's /cari plus its bilingual twin)
- `grep -c 'bot\.command("pergi_ke"' bot/src/bot/commands/cari.ts | head -1` returns 0 (`/pergi_ke` stays UNREGISTERED — reserved for Phase 5, D-01; the header comment may still document the reservation, so the ban is on the registration shape, not the token)
- `grep -q 'replyWithVenue' bot/src/bot/commands/cari.ts` exits 0 AND `grep -c 'replyWithVenue' bot/src/bot/commands/cari.ts | head -1` returns 1 (exactly ONE venue send builds the card) AND `grep -c 'ctx.reply(' bot/src/bot/commands/cari.ts | head -1` returns 3 (the THREE plain-reply sites: empty-query usage, over-cap, no-match)
- `grep -q 'MAX_QUERY_CHARS = 128' bot/src/bot/commands/cari.ts` exits 0 (V5's length cap)
- `grep -q 'MINI_APP_URL' bot/src/config.ts` exits 0 AND `grep -q 'MINI_APP_URL' bot/wrangler.jsonc` exits 0 AND `grep -q 'MINI_APP_URL' bot/.dev.vars.example` exits 0 AND `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 (the public origin is configured, documented in code, in the example file, and in the configuration docs)
- `grep -q '"vars"' bot/wrangler.jsonc` exits 0 (the origin is a var, NOT a secret)
- `grep -q '"@araharah/search": "\*"' bot/package.json` exits 0 (the bot imports the shared module)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' bot/src/bot/commands/cari.ts | grep -c 'console\.log' | head -1` returns >= 1 AND `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' bot/src/bot/commands/cari.ts | grep -o 'console\.log("[^"]*"' | grep -vc '\[search\] rejected: [a-z-]*"'` returns 0 (every log line is a category-only literal — D-15)
</acceptance_criteria>
<done>The `/cari` command exists with its bilingual twin, the venue card is a single `replyWithVenue` carrying the OPEN deep link (`<origin>/?dest=<slug>`, no crypto), the query is capped at 128 chars before any search, every log line is a category-only literal, and the Mini App origin is configured and documented in `docs/CONFIGURATION.md`.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: the alternates callback handler + the bot factory wiring</name>
<files>bot/src/bot/handlers/search-alternate.ts, bot/src/bot/bot.ts</files>
<read_first>
- `bot/src/bot/handlers/location-fallback.ts` (the whole file — the handlers/ directory's conventions: the header contract, the register function, the honest-null handling)
- `bot/src/bot/bot.ts` (the whole file — the factory and its registration order; the `opts.baseFetch` seam the test suite drives)
- `bot/src/bot/commands/cari.ts` (task 1's output — the keyboard builder the alternates handler must mirror)
- `search/src/types.ts` (plan 04-01's output — `SearchSource.byId`, the synchronous id lookup the handler uses)
</read_first>
<action>
**`bot/src/bot/handlers/search-alternate.ts`** — NEW. Implement EXACTLY per the `<alternates_contract>` block.
Exports:
- `export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): void`
- `export async function sendVenueCard(ctx: Context, entry: GazetteerEntry, alternates: readonly GazetteerEntry[], origin: string, locale: Locale): Promise<void>` — the ONE venue-card builder, shared by the `/cari` handler and the callback handler so the first card and the swapped card are structurally identical.
- `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise<GazetteerEntry[]>` — ASYNC because the `SearchSource` interface's `search()` returns `Promise<SearchHit[]>`: it awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, excludes the entry itself, capped at 4.
The card builder:
```typescript
export async function sendVenueCard(
ctx: Context,
entry: GazetteerEntry,
alternates: readonly GazetteerEntry[],
origin: string,
locale: Locale,
): Promise<void> {
const keyboard = new InlineKeyboard();
if (origin.trim() !== "") {
keyboard.webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id)).row();
}
for (const alternate of alternates.slice(0, 4)) {
keyboard.text(alternate.name, `alt:${alternate.id}`);
}
await ctx.replyWithVenue(entry.lat, entry.lng, entry.name, entry.address ?? "", {
reply_markup: keyboard,
});
}
```
**The `callback_query` matcher:** `bot.callbackQuery(/^alt:(.+)$/, async (ctx) => { ... })`.
- `await ctx.answerCallbackQuery();` FIRST (the 30-second rule).
- The id is validated against `/^[a-z0-9-]{1,64}$/` BEFORE any lookup — a hostile id is dropped silently (after the answer).
- `const entry = source.byId(id);` → `null` means RETURN (honest null — never a fabricated card).
- The new card's alternates: `await alternatesFor(entry, source)` (async — the awaited result feeds `sendVenueCard`).
- The locale comes from `pickLocale(ctx.from?.language_code)` — the same rule the other handlers use.
**The catch-all:** `bot.on("callback_query:data", async (ctx) => { const data = ctx.callbackQuery?.data ?? ""; if (/^alt:/.test(data)) return; await ctx.answerCallbackQuery(); })` — the specific matcher owns the `alt:` path exclusively, so the catch-all answers everything else (grammY's documented recommendation) and never double-answers.
**`bot/src/bot/bot.ts`** — wire both new surfaces. Create ONE source instance and share it between the two registrations (a single memoized index):
```typescript
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
const bot = opts.baseFetch
? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } })
: new Bot(env.BOT_TOKEN);
registerStart(bot); // /start + /mula (D-04)
registerHelp(bot); // /help
registerPrivacy(bot); // /privacy + /privasi (D-04)
registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback
const source = createGazetteerSource(); // D-03: the in-Worker gazetteer (backend #1)
const origin = env.MINI_APP_URL ?? ""; // the public Mini App origin (D-11)
registerCari(bot, source, origin); // /cari + /search (D-01/D-02/D-10)
registerSearchAlternate(bot, source, origin); // the alternates switcher (D-02)
return bot;
}
```
Update the file's header comment to name the two new surfaces and D-01/D-02/D-10/D-11, and to record that the venue card + inline keyboard is the SECOND documented exception to the D-02 plain-text rule (the first being the GEO-02 reply keyboard in `handlers/location-fallback.ts`).
**Acceptance:** `npm run bot:typecheck` exits 0. `npm test -w bot` will still FAIL on CS4/CS5/CS6 until task 3 updates them — that is expected and is task 3's job.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run bot:typecheck 2>&1 | tail -10 ; grep -c 'answerCallbackQuery' bot/src/bot/handlers/search-alternate.ts ; grep -c 'registerCari\|registerSearchAlternate' bot/src/bot/bot.ts ; npm test -w bot 2>&1 | tail -6 ; cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun 2>&1 | tail -3 ; grep -rl 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ | wc -l</automated>
</verify>
<acceptance_criteria>
- `npm run bot:typecheck` exits 0
- `grep -q 'export function registerSearchAlternate' bot/src/bot/handlers/search-alternate.ts` exits 0
- `grep -c 'answerCallbackQuery' bot/src/bot/handlers/search-alternate.ts | head -1` returns >= 2 (the specific matcher AND the catch-all both answer — the 30-second rule)
- `grep -q 'bot.on("callback_query:data"' bot/src/bot/handlers/search-alternate.ts` exits 0 (the catch-all that stops Telegram's 60-second spinner)
- `grep -q 'callbackQuery(/\^alt:' bot/src/bot/handlers/search-alternate.ts` exits 0 (the callback matcher is scoped to the `alt:` prefix)
- `grep -q '\^\[a-z0-9-\]{1,64}\$' bot/src/bot/handlers/search-alternate.ts` exits 0 (the id is regex-validated BEFORE the lookup — RESEARCH §Security V5 (b))
- `grep -q 'source.byId' bot/src/bot/handlers/search-alternate.ts` exits 0 (the id → entry lookup goes through the source, never an echo)
- `grep -q 'export async function sendVenueCard' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -q 'sendVenueCard' bot/src/bot/commands/cari.ts` exits 0 (ONE card builder, two callers — the first card and the swapped card are structurally identical)
- `grep -q 'registerCari' bot/src/bot/bot.ts` exits 0 AND `grep -q 'registerSearchAlternate' bot/src/bot/bot.ts` exits 0 (both surfaces are wired into the factory)
- `grep -c 'createGazetteerSource()' bot/src/bot/bot.ts | head -1` returns 1 (ONE source instance shared by both registrations — a single memoized index)
- `cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/araharah-worker-dryrun` exits 0 AND `grep -rl 'suria-klcc' /tmp/opencode/araharah-worker-dryrun/ | wc -l` returns >= 1 (the Worker bundle INLINES the gazetteer now that `bot.ts` imports `createGazetteerSource()` — this completes the L3 bundle-marker half deferred from plan 04-01, where no bot source imported the module yet)
- `grep -q 'replyWithVenue' bot/src/bot/handlers/search-alternate.ts` exits 0 AND `grep -c 'editMessageText' bot/src/bot/handlers/search-alternate.ts | head -1` returns 0 (the swap sends a NEW card; `editMessageText` would destroy the venue payload)
</acceptance_criteria>
<done>The alternates surface is live: a scoped `alt:` callback matcher that answers within 30 s, validates the id before lookup, swaps the card with a new venue message through the ONE shared card builder, and a catch-all that answers every other callback so Telegram never shows a spinner.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: the four commands.test.mjs landmines + the new cari.test.mjs gate suite</name>
<files>bot/test/commands.test.mjs, bot/test/cari.test.mjs, bot/scripts/register-commands.mjs</files>
<read_first>
- `bot/test/commands.test.mjs` (the WHOLE file — CS1 through CS6, the `commandSources` list, the `menuEntries` parser, the exact assertions that must change)
- `bot/src/bot/handlers/location-fallback.ts` (lines 15-20 — the precedent for documenting a deliberate exception to the text-only rule IN THE SOURCE that breaks it)
- `bot/test/webhook.test.mjs` (lines 72-100 — the in-memory KV + Request/Response harness and the `baseFetch` seam style the new suite uses)
- `bot/test/privacy.test.mjs` (the source-introspection gate style: read the shipped source, strip comments, assert on code text only)
- `bot/src/bot/commands/cari.ts` + `bot/src/bot/handlers/search-alternate.ts` (tasks 1-2's output — the code the new gates pin)
- `bot/scripts/register-commands.mjs` (the hardcoded command list at line 61)
</read_first>
<action>
**`bot/scripts/register-commands.mjs`.** Line 61's hardcoded list `["help", "privacy"]` becomes `["help", "privacy", "cari"]`. The `menuFor` function is otherwise unchanged (it derives each description from the `helpMenu` line, so the copy stays single-sourced). The TWO-fetch-call count is unchanged.
**`bot/test/commands.test.mjs`** — the FOUR edits per the `<commands_test_contract>` block:
1. `commandSources` (lines 29-32) gains `"commands/cari.ts"`.
2. **CS4** — the keyboard carve: apply the `/keyboard/i` ban to every command source EXCEPT `commands/cari.ts`; in `cari.ts` ban the REPLY-keyboard shape specifically (`/reply_markup:\s*\{\s*keyboard/` and `/ReplyKeyboardMarkup/`). Update the CS4 test's name and its header documentation to record the carve in the `location-fallback.ts` style ("this is the ONE deliberate exception to the plain-text rule" — and here it is the SECOND documented exception).
3. **CS5** — the expected menu list becomes `["help", "privacy", "cari"]`; the test's name changes accordingly. The `register-commands` sub-case's future-stub ban becomes `assert.doesNotMatch(source, /pergi_ke/, "…")` — `/pergi_ke` stays reserved for Phase 5 (D-01) while `/cari` is now a live command.
4. **CS6** — the expected alias set becomes `["cari", "help", "mula", "privasi", "privacy", "search", "start"]` (sorted).
Also update the suite's HEADER comment block to describe CS4's new carve and CS5/CS6's new expectations — the header is the suite's contract and it must not lie about what it enforces.
**`bot/test/cari.test.mjs`** — NEW. Implement all fifteen cases `CR1`…`CR15` from the `<cari_test_contract>` block. Copy `bot/test/webhook.test.mjs`'s harness conventions (the in-memory `makeKV`, the undici Request/Response shapes, the `createBot(env, { baseFetch })` seam).
The fake-transport capture: build a `fetch` that records `{ method, url, body }` for every outgoing Telegram API call and returns a synthetic `{ ok: true, result: { message_id: 1 } }` response. Drive the bot by registering `registerCari` / `registerSearchAlternate` against a `Bot` constructed with the fake `baseFetch`, then invoking the registered handler with a minimal fake `Context` (`match`, `from`, `replyWithVenue`, `reply`, `answerCallbackQuery`, `callbackQuery`).
The suite must NOT hit the live Telegram API — the fake `fetch` intercepts every call.
**The comment-strip helper.** Several cases read a source file and must ignore prose. Copy `web/test/no-storage.test.mjs`'s `stripComments` helper (lines 38-42) into this suite — it is the repo's established comment-stripping implementation:
```javascript
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
}
```
**Acceptance:** `npm test -w bot` exits 0 with every suite green (including the four updated CS gates and the fifteen new CR gates); `npm run bot:typecheck` exits 0.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -30 ; npm run bot:typecheck 2>&1 | tail -5 ; npm test -w bot 2>&1 | grep -c 'PASS CR' ; npm test -w bot 2>&1 | grep -c 'PASS CS'</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 and the runner output contains `[runner] N passed, 0 failed`
- `npm test -w bot` output contains `PASS CR1` through `PASS CR15`
- `npm test -w bot` output contains `PASS CS1` through `PASS CS6`
- `grep -q '"commands/cari.ts"' bot/test/commands.test.mjs` exits 0 (the command source is in CS4's/CS6's scan list)
- `grep -q '\["help", "privacy", "cari"\]' bot/test/commands.test.mjs` exits 0 (CS5's three-command menu expectation)
- `grep -q 'pergi_ke' bot/test/commands.test.mjs` exits 0 AND `grep -c 'cari|pergi_ke' bot/test/commands.test.mjs | head -1` returns 0 (the future-stub ban was NARROWED to `/pergi_ke` only — `/cari` is now live)
- `grep -q '"cari"' bot/scripts/register-commands.mjs` exits 0 AND `grep -c 'await fetch(' bot/scripts/register-commands.mjs | head -1` returns 2 (the hardcoded list gained cari; the two-fetch-call count is unchanged)
- `grep -q 'createHmac\|crypto.subtle' bot/test/cari.test.mjs` exits 0 AND `grep -q 'CR3' bot/test/cari.test.mjs` exits 0 (the D-11 NO-CRYPTO structural gate exists and is asserted)
- `grep -q 'stripComments' bot/test/cari.test.mjs` exits 0 (the comment-stripping helper the source-introspection cases use)
- `npm run bot:typecheck` exits 0
- `npm test -w web` exits 0 (this plan touched no web source — the web suites must be unaffected)
</acceptance_criteria>
<done>All four `commands.test.mjs` landmines are defused in the same change that adds `/cari`, and the new `cari.test.mjs` pins the venue payload, the keyboard shape, the no-crypto deep-link law, the honest refusal, the query cap, the callback-data discipline, the always-answer rule, and the category-only logging law.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram chat → `/cari` handler | The raw command argument is untrusted, arbitrary-length user text; it is the ONLY user-controlled input in this plan. |
| Search results → the venue card | Search hits carry entry names, addresses, and coordinates into a Telegram message. They come from a committed, reviewed dataset — but a bad entry would be broadcast to a user's chat. |
| `callback_data` → the alternates handler | A Telegram callback payload is client-visible and can be forged; the id inside it is untrusted until validated. |
| Bot process → Telegram API | Every outgoing request carries the bot token; the deep-link URL is a configured origin that must never be influenced by user input. |
| Bot process → logs | The query text is user payload; the log surface must carry categories only. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-21 | Denial of Service | A hostile `/cari` argument — megabytes of text, or pathological repetition — forcing unbounded normalization/scoring work inside the Worker (whose Free-tier budget is 10 ms CPU/request) | mitigate | `MAX_QUERY_CHARS = 128` bounds the raw argument BEFORE any search work; the over-cap path returns the `searchQueryTooLong` copy and logs a category only. Inside the shared module the normalized query is compared against a pre-built alias index, so per-query work is bounded by the index size, not by the query length (T-04-03/T-04-05 in plan 04-01). |
| T-04-22 | Information Disclosure | The user's raw query reaching a log line (D-15 / V7) | mitigate | Every log statement in `cari.ts` is a category-only literal (`[search] rejected: no-match`, `: query-too-long`, `: empty-query`, `: mini-app-origin-missing`). `CR10` reads the shipped source, strips comments, and asserts every `console.log` matches `/\[search\] rejected: [a-z-]+/` — a log line carrying a query-shaped variable fails the suite. The success path logs NOTHING at all. |
| T-04-23 | Tampering | `callback_data` injection — a forged or hostile payload naming an entry, carrying a name/query/coordinate, or attempting to smuggle a path | mitigate | `callback_data` is ONLY `alt:<entryId>` (never a name, a query, or coordinates — Pitfall 10). The handler validates the id against `/^[a-z0-9-]{1,64}$/` BEFORE the lookup, resolves it through `source.byId()` (an exact id lookup, never a substring or echo), and drops an unknown id silently. `CR5` asserts the format, the byte length (<= 64), and the absence of user data; `CR15` asserts a traversal-shaped id produces no venue card. |
| T-04-24 | Spoofing | The deep-link origin being influenced by user input — an open redirect carrying a user to a hostile Mini App | mitigate | The OPEN button's URL is built from `env.MINI_APP_URL` — a CONFIGURED public origin declared in `wrangler.jsonc`'s `vars` — never from the query, the entry, or anything else derived from user input. `miniAppUrlFor(origin, id)` takes the origin as an argument, so the origin cannot be computed from the id. `CR2` asserts the URL's origin equals the configured origin exactly. The id is additionally constrained to the slug charset, so it cannot carry a second origin or a path. |
| T-04-25 | Tampering | The deep-link payload being treated as an identity or a credential — a signed/HMAC'd token introduced to "protect" the destination id | mitigate | D-11 says no crypto, and the gate enforces it: `CR3` reads `cari.ts` and `search-alternate.ts` (comments stripped) and asserts NO `createHmac`, `crypto.subtle`, `sign(`, `hmac`, `signature`, `sha256`, or `secret`. The payload is a PUBLIC place id re-resolved against the local gazetteer; its only threat is a user pranking themselves, and the webview treats an unknown id as an honest "destination unavailable" state (plan 04-04), never a fabricated coordinate. |
| T-04-26 | Repudiation | An out-of-area query answered with the nearest KL pin — a fabricated location presented as a real one (D-13/D-14; an honesty failure as much as a correctness one) | mitigate | The empty result produces NO `sendVenue` and NO keyboard — the handler replies with the locale's honest `searchNoMatch` copy naming the v1 area. `CR6` asserts the recorded Telegram request set contains no `sendVenue` call and no `reply_markup` for a "Kuching" query. The shared module's refusal floor (plan 04-01) is the upstream half of the same guarantee. |
| T-04-27 | Denial of Service | A callback query left unanswered — Telegram's client showing a spinner for up to 60 seconds (RESEARCH §Security: "`answerCallbackQuery` not called — Denial of Service (UX spinner)") | mitigate | `answerCallbackQuery()` is the FIRST statement of the `alt:` matcher, and a catch-all `bot.on("callback_query:data", …)` answers every other callback. `CR9` asserts both call sites exist in the shipped source. The catch-all returns early for `alt:` data so the specific matcher owns that path exclusively and never double-answers. |
| T-04-28 | Tampering | `editMessageText` destroying the venue card's payload when an alternate is tapped | mitigate | The swap sends a NEW `sendVenue` — `editMessageText` is never called on a venue message (RESEARCH §Anti-Patterns: it replaces the venue payload and destroys the card). `CR14` asserts the tapped alternate produces a `sendVenue` whose `title` is that entry's name, and the acceptance criteria assert zero `editMessageText` occurrences in the handler. |
| T-04-29 | Information Disclosure | The venue card leaking the user's query or identity into a chat-visible payload | mitigate | The venue card carries only public place data: the entry's name, curated address, coordinates, and the OPEN button's URL (a public origin plus a public slug id). No user id, no chat id, no query text, no session token. The `alt:` callback data is likewise a public place id. Nothing in the card is derived from the user. |
| T-04-30 | Spoofing | A stale or unconfigured `MINI_APP_URL` sending the user to a wrong origin | mitigate | The origin is a single configured value shared by `wrangler.jsonc`'s `vars`, the Worker env, and `docs/CONFIGURATION.md`; there is no fallback that guesses. When it is blank, the OPEN button is OMITTED (rather than pointing somewhere wrong), the card still confirms the destination, and the handler logs the category-only `[search] rejected: mini-app-origin-missing`. The origin must equal the @BotFather Mini App URL — the Bot API 10.2 origin restriction makes a mismatch a hard failure at launch, caught in the phase's device gate. |
| T-04-31 | Elevation of Privilege | The inline `web_app` button being used to reach a privileged Mini App surface | mitigate | The `web_app` button's `WebAppInfo.url` points at the app's own origin with only a `dest` query param; the Mini App's session gate (Phase 2's HMAC validation + `POST /api/session`) is unchanged and still required for the app to function. The destination id grants no capability of its own — it names a public place. |
</threat_model>
<verification>
- `npm test -w bot` green — including the four updated CS gates and the fifteen new CR gates
- `npm run bot:typecheck` green
- `npm test -w web` green (unaffected by this plan)
- `grep -rl 'createHmac\|crypto\.subtle' bot/src/bot/commands/cari.ts bot/src/bot/handlers/search-alternate.ts` returns NOTHING — the deep-link path carries no crypto (D-11)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' bot/src/bot/commands/cari.ts | grep -o 'console\.log("[^"]*"' | grep -v '\[search\] rejected: [a-z-]*"' | wc -l` returns 0 — every log line is a category-only literal (D-15)
- `grep -c 'bot\.command("pergi_ke"' bot/src/bot/commands/cari.ts | head -1` returns 0 AND `grep -c 'pergi_ke' bot/scripts/register-commands.mjs | head -1` returns 0 — the Phase-5 command stays unregistered (D-01; cari.ts's ban is on the registration shape, since its header comment documents the reservation)
- `grep -q 'MINI_APP_URL' docs/CONFIGURATION.md` exits 0 — the new configuration value is documented where a maintainer looks
</verification>
<success_criteria>
- SRCH-03: typing `/cari KLCC` sends ONE native venue card (latitude, longitude, title, curated address) confirming the destination before navigation opens — `sendVenue`'s lack of a `text` field is honoured by letting the card BE the confirmation
- D-02: the best result ships immediately and 2-4 alternates ride as inline buttons on the SAME message; tapping one swaps the card in one tap, with no second user round
- D-10: the card is `sendVenue` with inline buttons; `reply_markup` carries an `inline_keyboard`
- D-11 (mechanism corrected per RESEARCH Pitfall 1): the OPEN button is a `web_app` button whose URL carries the plain id as `?dest=<slug>` — the `WebAppInfo` type has exactly one field, `url`, so `start_param` is NOT a valid carrier for an inline button; `initData.start_param` remains the secondary path for the home-screen/direct-link route; the payload is a plain public id with NO crypto, asserted by CR3
- D-01: search enters through `/cari` (plus its `/search` twin); `/pergi_ke` stays reserved for Phase 5; no free-text-to-bot, no inline mode, no in-webview search UI
- D-03: the handler consumes the pluggable `SearchSource`; no Photon, no VPS work appears in this phase
- D-15: no log line can carry the query; the card carries only public place data
- The four `commands.test.mjs` landmines (CS4's keyboard grep, CS5's menu set + future-stub ban, CS6's alias set, and `register-commands.mjs`'s hardcoded list) are defused in the SAME change that adds `/cari`
- `MINI_APP_URL` is a configured, documented value — the env table in `docs/CONFIGURATION.md` names it and states that it is a public origin, not a secret
</success_criteria>
<output>
After completion, create `.planning/phases/04-destination-search-kl-gazetteer/04-03-SUMMARY.md`
</output>