Files

64 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-destination-search-kl-gazetteer 03 execute 2
04-01
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
true
SRCH-03
ONBD-02
decisions_implemented truths artifacts key_links
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
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
path provides contains
bot/src/bot/commands/cari.ts the /cari command handler: query extraction, the SearchSource call, the venue-card send, the refusal copy (D-01, D-02, D-10) registerCari
path provides contains
bot/src/bot/handlers/search-alternate.ts the alternates callback_query handler + the catch-all that always answers (D-02) registerSearchAlternate
path provides
bot/src/i18n/strings.ts 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 provides
bot/test/cari.test.mjs 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 provides
bot/test/commands.test.mjs 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 provides
bot/src/config.ts the Env contract gaining MINI_APP_URL — the deployed Mini App origin used to build the deep link
path provides
bot/scripts/register-commands.mjs the setMyCommands script deriving the /cari scope from helpMenu (the hardcoded command list gains 'cari')
path provides
docs/CONFIGURATION.md the MINI_APP_URL row in the env-var table, with the 'public origin, not a secret' note
from to via pattern
bot/src/bot/commands/cari.ts @araharah/search the handler calls the shared SearchSource — the same module the corpus measured SearchSource
from to via pattern
bot/src/bot/commands/cari.ts bot/src/i18n/strings.ts every user-facing string is imported from the strings module; nothing inline strings[locale]
from to via pattern
bot/src/bot/commands/cari.ts bot/src/config.ts the OPEN button's URL origin comes from env.MINI_APP_URL — never from user input MINI_APP_URL
from to via pattern
bot/src/bot/handlers/search-alternate.ts @araharah/search the callback handler resolves 'alt:<entryId>' through the source's byId() — never by echoing the id into a reply byId
from to via pattern
bot/test/commands.test.mjs bot/src/bot/commands/cari.ts the CS4 gate's documented carve for the venue-card surface (the location-fallback precedent) cari.ts
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.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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:

/** 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.

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:

/** 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:

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.

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:

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.

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 — 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:

  // 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):

  "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>

task 1: the search copy + the /cari command + the venue card + the OPEN deep link 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 - `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) **`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 `` block. The `helpMenu` value in each locale gains the `\n/cari — ` 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. 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 <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> 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.

task 2: the alternates callback handler + the bot factory wiring bot/src/bot/handlers/search-alternate.ts, bot/src/bot/bot.ts - `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) **`bot/src/bot/handlers/search-alternate.ts`** — NEW. Implement EXACTLY per the `` 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:

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):

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. 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 <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> 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.

task 3: the four commands.test.mjs landmines + the new cari.test.mjs gate suite bot/test/commands.test.mjs, bot/test/cari.test.mjs, bot/scripts/register-commands.mjs - `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) **`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:

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. 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' <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> 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.

<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>
- `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

<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>
After completion, create `.planning/phases/04-destination-search-kl-gazetteer/04-03-SUMMARY.md`