63 KiB
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 |
|
|
true |
|
|
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, 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) asks the source for the entry's own neighbourhood — source.search({ text: entry.name, limit: 5 }) — and filters out the entry itself, capping at 4. 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 asweb/src/i18n/strings.tsline 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 areply_markupobject) — that is the location-fallback handler's own surface and it stays out ofcommands/; - ALLOW the INLINE-keyboard shape (
InlineKeyboard+reply_markupwithinline_keyboard) incommands/cari.ts; - document the carve in the suite's own header, in the same style as
location-fallback.tslines 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 recordedsendVenuebody carrieslatitudeandlongitudeas FINITE numbers,title= the entry'sname(non-empty),address= the entry's curatedaddress(non-empty), andreply_markup.inline_keyboardpresent.CR2 the OPEN button's URL carries the plain id and the configured origin— row 0 ofinline_keyboardis a button whoseweb_app.urlis exactly${origin}/?dest=<id>; the origin part equals the configuredMINI_APP_URL(never a query-derived value); thedestvalue matches/^[A-Za-z0-9_-]{1,64}$/.CR3 NO CRYPTO IN THE DEEP-LINK PATH (D-11's structural gate)— readbot/src/bot/commands/cari.tsandbot/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; theinline_keyboardhas 2-4 alternate buttons whosecallback_dataall match/^alt:[a-z0-9-]{1,64}$/.CR5 the callback_data carries no user data— everycallback_datain the recorded keyboard matches^alt:<slug>$; assert NONE of them contains a space, a query, a latitude, or a longitude, and that everycallback_databyte length is <= 64.CR6 a no-match query sends NO venue and NO keyboard— for "Kuching": the recorded requests contain NOsendVenuecall and NOreply_markup; the reply is the locale'ssearchNoMatchtext.CR7 an over-long query is rejected before any search— a 200-character query produces nosendVenueand thesearchQueryTooLongcopy.CR8 an empty query produces no venue card—/cariwith no argument sends the usage copy and nosendVenue.CR9 every callback path answers the callback query— the alternates handler's source containsanswerCallbackQueryin the specific matcher AND in the catch-all; assert the catch-all exists by grepping forbot.on("callback_query:data"in the source.CR10 the query text never reaches a log line (D-15)— readbot/src/bot/commands/cari.ts(comments stripped) and assert noconsole.logline references a query-shaped variable (query,ctx.match,text,q); assert everyconsole.login the file matches/\[search\] rejected: [a-z-]+/.CR11 the copy exists in both locales—strings.en.searchNoMatchandstrings.ms.searchNoMatchare non-empty and DIFFER (not an untranslated copy), and the same forsearchUsageandsearchQueryTooLong;searchOpenButtonandosmAttributionare IDENTICAL across locales by design — assert that.CR12 the helpMenu carries the /cari line in both locales— themenuEntriesparse yields acarientry with a non-empty description in BOTH locales, and the description inmsis not byte-identical to theenone.CR13 the registration script derives the /cari scope from helpMenu—register-commands.mjscontains"cari"in its command list and still issues exactly TWOawait fetch(calls.CR14 the alternates swap produces a new venue card for the tapped entry— the alternates handler, driven with acallback_dataofalt:<id>, sends asendVenuewhosetitleis that entry's name.CR15 a hostile callback id is dropped, not echoed— acallback_dataofalt:../../etc/passwdproduces 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>
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): stringexport function searchUsageText(locale: Locale): stringexport function searchNoMatchText(locale: Locale): stringexport function registerCari(bot: Bot, source: SearchSource, origin: string): void
The handler's flow:
const raw = (ctx.match ?? "").trim();if (raw.length === 0)→ replysearchUsageText(locale)with link previews disabled; log[search] rejected: empty-query; RETURN (no venue card).if (raw.length > MAX_QUERY_CHARS)→ replysearchQueryTooLongwith link previews disabled; log[search] rejected: query-too-long; RETURN (no venue card, no search performed).const hits = await source.search({ text: raw, limit: 5 });if (hits.length === 0)→ replysearchNoMatchText(locale)with link previews disabled; log[search] rejected: no-match; RETURN (no venue card, no keyboard — D-13's honest refusal).- Otherwise: build the alternates from
hits.slice(1)(eachhits[i].entry), thenawait 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).
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.
Exports:
export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): voidexport async function sendVenueCard(ctx: Context, entry: GazetteerEntry, alternates: readonly GazetteerEntry[], origin: string, locale: Locale): Promise<void>— the ONE venue-card builder, shared by the/carihandler and the callback handler so the first card and the swapped card are structurally identical.export function alternatesFor(entry: GazetteerEntry, source: SearchSource): GazetteerEntry[]— the entry's own neighbourhood, filtered to exclude 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);→nullmeans RETURN (honest null — never a fabricated card).- The new card's alternates:
alternatesFor(entry, source). - 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.
bot/test/commands.test.mjs — the FOUR edits per the <commands_test_contract> block:
commandSources(lines 29-32) gains"commands/cari.ts".- CS4 — the keyboard carve: apply the
/keyboard/iban to every command source EXCEPTcommands/cari.ts; incari.tsban 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 thelocation-fallback.tsstyle ("this is the ONE deliberate exception to the plain-text rule" — and here it is the SECOND documented exception). - CS5 — the expected menu list becomes
["help", "privacy", "cari"]; the test's name changes accordingly. Theregister-commandssub-case's future-stub ban becomesassert.doesNotMatch(source, /pergi_ke/, "…")—/pergi_kestays reserved for Phase 5 (D-01) while/cariis now a live command. - 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> |
<success_criteria>
- SRCH-03: typing
/cari KLCCsends ONE native venue card (latitude, longitude, title, curated address) confirming the destination before navigation opens —sendVenue's lack of atextfield 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
sendVenuewith inline buttons;reply_markupcarries aninline_keyboard - D-11 (mechanism corrected per RESEARCH Pitfall 1): the OPEN button is a
web_appbutton whose URL carries the plain id as?dest=<slug>— theWebAppInfotype has exactly one field,url, sostart_paramis NOT a valid carrier for an inline button;initData.start_paramremains 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/searchtwin);/pergi_kestays 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.mjslandmines (CS4's keyboard grep, CS5's menu set + future-stub ban, CS6's alias set, andregister-commands.mjs's hardcoded list) are defused in the SAME change that adds/cari MINI_APP_URLis a configured, documented value — the env table indocs/CONFIGURATION.mdnames it and states that it is a public origin, not a secret </success_criteria>