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 | 04 | execute | 3 |
|
|
false |
|
|
Purpose: ONBD-02 is the requirement whose success depends on undocumented Telegram client behaviour, and the research's highest-value finding is that a web_app inline button CANNOT populate start_param — so the reader must accept BOTH carriers with the same validation, and the phase must not claim the requirement is met until a real device has demonstrated it. ONBD-03 is deliberately minimal (D-12): the Telegram-native addToHomeScreen() / checkHomeScreenStatus() behind a capability guard, with the iOS "unknown" status treated as "possibly added", never as a failure — and no manifest, no service worker, no beforeinstallprompt (the browser API does not exist in the Telegram webview).
Output: web/src/search/resolve-destination.ts, web/src/search/destination-card.ts, web/src/telegram/homescreen.ts, the extended web/src/telegram/webapp.ts + web/src/mock/telegram-mock.ts + web/src/i18n/strings.ts, the extended web/src/main.ts + web/index.html, three test suites, the extended capture harness, and the blocking device gate.
<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 @web/src/main.ts @web/src/telegram/webapp.ts @web/src/telegram/backbutton.ts @web/src/session/store.ts @web/src/mock/telegram-mock.ts @web/src/i18n/strings.ts @web/src/ui/cards.ts @web/src/ui/strip.ts @web/index.html @web/scripts/capture.mjs @web/test/no-storage.test.mjs @web/test/build-artifact.test.mjs @search/src/index.ts @search/src/types.ts<mechanism_correction_recap>
The bot's OPEN button (plan 04-03) is a web_app inline button whose URL carries ?dest=<slug>. WebAppInfo has exactly ONE field — url — so start_param CANNOT be the carrier for an inline button. The webview therefore reads TWO sources, in this order, with the SAME validation:
- PRIMARY — the URL's own query string:
new URLSearchParams(window.location.search).get("dest"). This is the inline-button path. - SECONDARY —
initData.start_param: the officially documented direct-link path (https://t.me/<bot>?startapp=<id>) and the home-screen shortcut's path. Telegram populatesstart_paramfrom astartattachattachment-menu link or a?startapp=direct link — NOT from an inlineweb_appbutton.
Both are validated against /^[A-Za-z0-9_-]{1,64}$/. The value may carry a dest_ prefix (the D-11 wording's literal dest_<id> form) — the reader strips a leading dest_ before validation, so BOTH ?dest=suria-klcc and ?startapp=dest_suria-klcc resolve to the same entry.
initData is the SIGNED string (Telegram.WebApp.initData), NOT initDataUnsafe — the signed string is what the server validates, and the start_param field is present in it verbatim. Parsing it is a two-line split on & + decodeURIComponent; the repo's bot/src/security/initdata.ts parseFields shows the pattern (and its warning: + stays literal, never URLSearchParams).
NO CRYPTO, NO SIGNATURE, NO TOKEN (D-11): the id is a public place id. An unknown id yields an honest "destination unavailable" state — never a fabricated coordinate, never a blank map. </mechanism_correction_recap>
<resolve_contract>
web/src/search/resolve-destination.ts — the memory-only resolver. Copy web/src/session/store.ts's header law verbatim-in-spirit (lines 3-10: "WHAT is stored … WHAT is never stored … no persistence call anywhere in this file").
Exports:
export type DestinationSource = "url" | "start-param" | "none";
export interface ResolvedDestination {
entry: GazetteerEntry;
source: DestinationSource;
}
/** Read the destination id from BOTH carriers (mechanism corrected per RESEARCH Pitfall 1).
* Primary: the URL's own query string (`?dest=<id>`) — the inline-button path.
* Secondary: initData's `start_param` — the documented direct-link / home-screen path.
* Both are validated against the same charset; a `dest_` prefix is stripped. */
export function readDestinationId(
search: string,
initData: string | null | undefined,
): { id: string | null; source: DestinationSource };
/** Resolve an id against the webview's OWN build-time copy of the shared gazetteer.
* Returns null for an unknown id — the honest null (never a fabricated coordinate). */
export function resolveDestination(id: string | null): GazetteerEntry | null;
/** The memory-only holder: the resolved destination lives in module scope, nowhere else. */
export function setDestination(entry: GazetteerEntry | null): void;
export function getDestination(): GazetteerEntry | null;
The id validation. /^[A-Za-z0-9_-]{1,64}$/ AFTER stripping a leading dest_. An id failing the regex is treated as absent (never partially trusted).
The unknown-id path. resolveDestination returns null — the caller renders the honest "destination unavailable" surface and the boot continues into the normal staged boot. A blank map is FORBIDDEN (the Phase 3 D-09 anti-blank invariant is binding here too).
The memory-only law. The module contains NO storage call. web/test/no-storage.test.mjs already walks EVERY web/src file, so the new module is covered automatically — but the plan adds an explicit case too.
web/src/search/destination-card.ts — the visible confirmation. Copy web/src/ui/cards.ts's construction style (the .card / .card-header / .card-ref / .btn-primary composed classes, never restyled locally) and web/src/ui/strip.ts's "every string comes from the strings module" law. It renders the destination's NAME and its curated ADDRESS (the address verbatim, never the normalized form — RESEARCH Pitfall 3), and it uses the existing theme classes so no off-token styling appears.
The card is NOT a new screen — it is the Phase 3 card stack's existing surface with a different card. The roadmap's Phase 4 UI hint: no means no UI-SPEC round: the destination card composes ONLY classes that already exist in the Phase 3 shell (web/src/shell.css + the theme's component sheet).
The strings. web/src/i18n/strings.ts GAINS:
destinationHeadline— "Destination" / "Destinasi"destinationUnavailableHeadline— "Destination unavailable" / "Destinasi tidak tersedia"destinationUnavailableBody— "That place is not in this build. Search again from the bot." / ms equivalentaddToHomeScreen— "ADD TO HOME SCREEN" / "TAMBAH KE SKRIN UTAMA"homeScreenAlreadyAdded— "Already on your home screen" / "Sudah di skrin utama anda"
The copy-gate suite (web/test/copy-gate.test.mjs) asserts the en/ms key-sets match — the new fields are covered automatically by CS1-style key-set identity.
web/src/main.ts — the boot wiring. The destination read slots adjacent to the locale derivation (stage 12, lines 92-95), because both are "derive from the environment, memory only" steps:
// 13 — the destination (ONBD-02). TWO carriers, ONE validation (mechanism
// corrected per RESEARCH Pitfall 1): the URL's own query string first
// (the inline-button path — a web_app button's WebAppInfo carries only
// a `url`), then initData's start_param (the documented direct-link /
// home-screen path). An unknown id yields the honest unavailable state
// and the boot continues into the normal staged boot — never a blank map.
const destPayload = readDestinationId(window.location.search, wa?.initData ?? null);
const destination = resolveDestination(destPayload.id);
setDestination(destination);
When destination !== null, the destination card is mounted into the existing card stack. When the id was present but unknown, the unavailable card is mounted. When there was no id at all, NOTHING changes — the Phase 3 boot is untouched (D-12's "startapp-less boot landing in the search-ready map"). The boot emits ONE capture signal, UNCONDITIONALLY (outside any map guard, the same shape as the existing ARAHARAH-LIVE signal — a WebGL2-absent device still resolves the race):
console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));
The signal is a STATE TOKEN, never the id (D-15 hygiene: a console token carries no place data). Because a token alone cannot prove WHICH destination resolved, the capture leg pairs it with a DOM check on the rendered card's text (task 3's waitMode block asserts Suria KLCC).
web/src/telegram/webapp.ts — the contract grows. The TelegramWebAppLike interface gains:
/** The documented direct-link start parameter — NOT populated by an inline
* web_app button (its WebAppInfo carries only a `url`). */
startParam?: string;
/** Bot API 8.0+ only. Optional — absent on older clients. */
addToHomeScreen?(): void;
/** Bot API 8.0+ only. On iOS this ALWAYS reports "unknown". */
checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void;
plus:
export type HomeScreenStatus = "unsupported" | "unknown" | "added" | "missed";
Do NOT add @types/telegram-web-app (Phase 3 dropped it deliberately — the hand-rolled contract IS the contract; the official type would force each fake to stub ~50 members). The interface's header comment already states this law and must be extended to name the three new members.
web/src/mock/telegram-mock.ts — the mock grows in the SAME change. The interface's header says "every test fake, the D-11 harness mock, and the production clients satisfy exactly these members" — so the mock MUST gain the three members in this change:
MockOptions.startParam?: string | null— appended to the mock initData as&start_param=<encoded>inbuildMockInitData(the mock's initData is HMAC-unvalidated by design — the runner page stubs/api/session).MockOptions.homeScreenStatus?: HomeScreenStatus— the status the mock'scheckHomeScreenStatuscallback receives.- The mock's
addToHomeScreenandcheckHomeScreenStatusstubs, plus a__homeScreenAddedcounter the tests can read (the mock already exposes__eventsand__backButtonClicksfor exactly this purpose).
web/src/telegram/homescreen.ts — the ONBD-03 wiring. Copy web/src/telegram/backbutton.ts (the whole 20-line file is the template: guard on the optional member, a total-function handler, a header that documents the phase-scoped contract):
export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void
Behaviour:
- The capability guard is BOTH halves: the method may be absent on older clients, AND the platform documents the API as Bot API 8.0+.
if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;— a no-op on an unsupported client. checkHomeScreenStatus— when it reports"added", the affordance is HIDDEN (the icon is already there; do not nag). When it reports"unknown"(always on iOS), the affordance is SHOWN with the neutral prompt — "unknown" means "supported but indeterminate", NEVER an error and NEVER "not added". When it reports"missed", the affordance is shown with the prompt. When it reports"unsupported", the affordance is not shown.addToHomeScreen()is called when the user taps the affordance.wa.onEvent?.("homeScreenAdded", ...)— Telegram fires this after the icon is added; the affordance hides then.- The affordance lives in the EXISTING Phase 3 shell — the status strip's right cluster or the card stack. No new UI furniture is invented (the roadmap's Phase 4
UI hint: no): the affordance is composed from classes that already exist (web/src/shell.css+ the theme component sheet). If a genuinely new surface were needed, that would require a UI-SPEC round — it does not. - NO
beforeinstallprompt, NO manifest, NO service worker.beforeinstallpromptdoes not exist inside the Telegram webview and a second icon the Telegram client does not know about is worse than none.web/test/no-storage.test.mjs's GEO3-2 case already bansserviceWorker.register/navigator.serviceWorker/workboxoutright.
web/index.html — the ?dest= harness profile. The profile block (lines 20-35) reads ?mock= / ?scheme= / ?locale= / ?pin= / ?sessiondelay= / ?fixdelay=. It gains ?dest=<id>:
const dest = q.get("dest") || null;
...
window.__ARAHARAH_MOCK__ = { profile, scheme, locale, dest };
The value must reach the app's reader. Because page.goto preserves the query string, window.location.search ALREADY carries ?dest=<id> in the webview — the reader path needs no stub. But the mock's startParam option needs the value threaded through installMockWebApp({ ... dest }) for the SECONDARY carrier's harness leg.
The whole block stays inside if (__ARAHARAH_HARNESS__) — Vite's define dead-code-eliminates it from production, and BA5/BA6 scan the emitted HTML for installMockWebApp / __ARAHARAH_MOCK__ / /api/session / /api/location-fallback. The ?dest= value must NOT add a new endpoint or a new inline fetch stub to the production build.
web/package.json — add "@araharah/search": "*" to dependencies (the web/package.json line 15 "@araharah/theme": "*" precedent).
web/scripts/capture.mjs — the ONBD-02 capture leg. Copy the chat-pin leg (lines 267-269) and the expected array (lines 361-369):
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
Three bookkeeping edits: the header's output list (lines 22-29), the leg itself, and the expected array. The wait mechanism is the console-signal race — add ARAHARAH-DEST to the SIGNALS list (line 165) and the seen map (line 189). web/src/main.ts emits the STATE-TOKEN signal shown earlier in this contract (next to the boot-wiring paragraph) — ARAHARAH-DEST state=resolved|absent, never the id — UNCONDITIONALLY, outside any map guard, so a WebGL2-absent device still resolves the race.
</resolve_contract>
<build_gate_contract>
web/test/build-artifact.test.mjs — the drift gate. RESEARCH §Runtime State Inventory: "the web/test/build-artifact.test.mjs gate may need a new assertion (e.g. the gazetteer JSON must be in web/dist for the webview copy to work)".
BA7 (new) — the shipped production bundle carries the gazetteer: read every web/dist/assets/*.js, concatenate, and assert that EVERY gazetteer id is present (the ids come from the shared dataset — the suite imports search/src/data/kl-gazetteer.json). This is the WEB half of the bot/web id-set parity gate: if the web bundle ships without the dataset, the webview's resolveDestination can never resolve an id.
BA8 (new) — the production bundle carries NO deep-link crypto: assert the concatenated bundle contains NONE of createHmac, crypto.subtle, HMAC(, sha256 — the deep-link path is a plain id (D-11). (The Telegram SDK script is loaded from https://telegram.org/js/telegram-web-app.js in the HTML — the OFFICIAL script may contain crypto internals. The gate therefore scans the JS ASSETS only, NOT the HTML, and the official script is external (not bundled).)
The bot/web id-set parity gate belongs in the BOT suite (the bot test workspace can read both files); plan 04-03's cari.test.mjs is the natural home, but the bot suite's files_modified in plan 04-03 does not list it — so this plan adds the parity assertion to the WEB suite's BA7 by comparing the web bundle's ids against the committed dataset (the same committed JSON both sides read, so parity holds by construction). The REMAINING risk — a stale web bundle — is what BA7 catches.
The build-artifact suite's existing behaviour is preserved: BA1-BA6 must keep passing, and the new cases scan web/dist ONLY (never web/dist-harness).
</build_gate_contract>
<device_gate_contract> The blocking human checkpoint at the end of this plan (the phase's exit gate for ONBD-02 and ONBD-03). A real device is REQUIRED — RESEARCH §Environment Availability lists it as unavailable in this environment with no fallback, exactly like Phase 3's device matrix. The phase MUST NOT be closed claiming device verification that did not happen.
What the device must demonstrate:
- ONBD-02, Android: send
/cari KLCCin the bot's chat → the venue card appears with the OPEN button → tap OPEN → the Mini App opens WITH the destination preloaded (the destination card is visible, showing Suria KLCC). - ONBD-02, iOS: the same flow on an iPhone.
- ONBD-02, Telegram Desktop: the same flow (Desktop has no geolocation, so the destination card is what is being observed — the location half is Phase 3's separate matrix).
- ONBD-03: add araharah to the phone's home screen (via the app's affordance or Telegram's own menu) → launch from the home-screen icon → the app opens into the SEARCH-READY state (no destination, no crash, no blank map). On iOS,
checkHomeScreenStatuswill report"unknown"— that is expected platform behaviour and MUST NOT be reported as a failure. - The unknown-id honesty: open the Mini App with a deliberately wrong id (e.g.
?dest=not-a-real-place) → the app shows the honest "destination unavailable" surface, NOT a blank map and NOT a fabricated pin.
The record. Create .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md with a row per leg and an explicit PASS / FAIL / UNVERIFIED verdict each. If no device is available, the honest outcome is UNVERIFIED — record the blocker in .planning/STATE.md under "Blockers/Concerns" and recommend acquiring a device or moving ONBD-02's device verification to Phase 7 with an explicit requirement-level note. NEVER close the phase claiming device verification that did not happen.
</device_gate_contract>
web/src/telegram/webapp.ts — extend the TelegramWebAppLike interface with the three members per the <resolve_contract> block, plus the HomeScreenStatus type export. Update the header comment to name them and restate the "no @types/telegram-web-app" law.
web/src/mock/telegram-mock.ts — extend MockOptions with startParam?: string | null and homeScreenStatus?: HomeScreenStatus; append &start_param=<encoded> to buildMockInitData when startParam is set; add the addToHomeScreen / checkHomeScreenStatus stubs and a __homeScreenAdded counter; add startParam to the fake object. Update the header comment to note the new options.
web/src/search/resolve-destination.ts — NEW, exactly per the <resolve_contract> block. Its header comment states the mechanism correction, the two carriers, the shared validation, the memory-only law, and the honest-null law.
The reader:
export function readDestinationId(
search: string,
initData: string | null | undefined,
): { id: string | null; source: DestinationSource } {
const fromUrl = new URLSearchParams(search).get("dest");
const cleaned = fromUrl === null ? null : cleanId(fromUrl);
if (cleaned !== null) return { id: cleaned, source: "url" };
const fromStart = startParamFromInitData(initData);
const cleanedStart = fromStart === null ? null : cleanId(fromStart);
if (cleanedStart !== null) return { id: cleanedStart, source: "start-param" };
return { id: null, source: "none" };
}
cleanId strips a leading dest_ and validates against /^[A-Za-z0-9_-]{1,64}$/, returning null on failure. startParamFromInitData parses the SIGNED initData string the way bot/src/security/initdata.ts's parseFields does — split on &, take the start_param field, decodeURIComponent it, and treat a decode failure as absent. Never use URLSearchParams on the initData string (+ stays literal there) and never read initDataUnsafe.
resolveDestination(id) calls getEntryById from @araharah/search/src/index.ts (the EXPLICIT subpath-with-extension form — the package has no exports map, so a bare @araharah/search specifier resolves to nothing; the @araharah/theme subpath precedent) and returns the entry or null.
web/src/search/destination-card.ts — NEW. Build the destination surface from the EXISTING Phase 3 theme classes (.card, .card-header, .card-ref, .text-headline-md, .text-body-sm — the same classes web/src/ui/cards.ts uses) and the strings module. Two builders: buildDestinationCard(locale, entry) and buildDestinationUnavailableCard(locale). No new CSS; no off-token styling.
web/src/i18n/strings.ts — add the five new fields per the <resolve_contract> block to the ShellStrings interface and to BOTH the en and ms records. The attribution string stays as-is.
web/test/destination-preload.test.mjs — NEW. Cases:
DP1 the URL carrier resolves—readDestinationId("?dest=suria-klcc", "")→{ id: "suria-klcc", source: "url" }.DP2 the start_param carrier resolves—readDestinationId("", "user=%7B%7D&auth_date=1&start_param=suria-klcc&hash=h")→{ id: "suria-klcc", source: "start-param" }.DP3 the URL carrier wins over start_param— with both present, the URL wins (the inline-button path is primary).DP4 the dest_ prefix is stripped—?dest=dest_suria-klccandstart_param=dest_suria-klccboth →suria-klcc(the D-11 wording's literal form still resolves).DP5 no id yields the honest none— both absent →{ id: null, source: "none" }.DP6 a malformed id is rejected—?dest=../../etc/passwd,?dest=<script>,?dest=(empty), and a 200-character id all →{ id: null }.DP7 the resolver resolves a known id to a valid entry—resolveDestination("suria-klcc")returns an entry with finitelat/lngand a non-emptyname.DP8 an unknown id returns null (never a fabricated coordinate)—resolveDestination("not-a-real-place")→null.DP9 the memory-only source gate— readweb/src/search/resolve-destination.ts(comments stripped) and assert it contains none oflocalStorage,sessionStorage,indexedDB,document.cookie,caches.DP10 no crypto in the destination path— the same file contains none ofcreateHmac,crypto.subtle,sha256,signature(D-11).DP11 the id set parity— every id in the committed gazetteer resolves to an entry (the bot and the webview read the SAME dataset, so parity holds by construction — the assertion proves the webview's copy is complete).
Acceptance: npm run web:typecheck exits 0; npm test -w web green with PASS DP1…PASS DP11.
cd /home/C-Fu/araharah && npm install --package-lock-only 2>&1 | tail -3 ; npm run web:typecheck 2>&1 | tail -10 ; npm test -w web 2>&1 | grep -E 'PASS DP|runner' ; grep -c 'startParam' web/src/telegram/webapp.ts web/src/mock/telegram-mock.ts
<acceptance_criteria>
- npm run web:typecheck exits 0
- npm test -w web exits 0 and the output contains PASS DP1 through PASS DP11
- grep -q '"@araharah/search": "\*"' web/package.json exits 0 (the webview imports the shared module)
- grep -q 'export function readDestinationId' web/src/search/resolve-destination.ts exits 0 AND grep -q 'export function resolveDestination' web/src/search/resolve-destination.ts exits 0
- grep -q 'URLSearchParams' web/src/search/resolve-destination.ts exits 0 AND grep -q 'start_param' web/src/search/resolve-destination.ts exits 0 (BOTH carriers are read — the mechanism correction)
- grep -c 'initDataUnsafe' web/src/search/resolve-destination.ts | head -1 returns 0 (only the SIGNED initData is read)
- grep -q 'A-Za-z0-9_' web/src/search/resolve-destination.ts exits 0 (the shared id validation)
- grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/search/resolve-destination.ts | grep -c 'localStorage\|sessionStorage\|indexedDB\|document.cookie\|caches' | head -1 returns 0 (GEO-03 memory-only)
- grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/search/resolve-destination.ts | grep -c 'createHmac\|crypto\.subtle\|sha256' | head -1 returns 0 (D-11 no crypto)
- grep -q 'startParam?' web/src/telegram/webapp.ts exits 0 AND grep -q 'addToHomeScreen?' web/src/telegram/webapp.ts exits 0 AND grep -q 'checkHomeScreenStatus?' web/src/telegram/webapp.ts exits 0 (the narrow contract grew)
- grep -q 'homeScreenStatus' web/src/mock/telegram-mock.ts exits 0 AND grep -q 'startParam' web/src/mock/telegram-mock.ts exits 0 (the mock satisfies the same members in the SAME change)
- grep -q 'destinationUnavailableHeadline' web/src/i18n/strings.ts exits 0 AND grep -q 'destinationHeadline' web/src/i18n/strings.ts exits 0 AND grep -q 'addToHomeScreen' web/src/i18n/strings.ts exits 0 (the new copy fields exist in the strings module)
- node --input-type=module -e "const {strings}=await import('./web/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'); console.log('web strings OK')" exits 0
</acceptance_criteria>
The webview resolves a destination id from BOTH carriers with one validation, against its own build-time copy of the shared gazetteer; an unknown id is an honest null; the resolved destination is memory-only; and the mock satisfies the extended contract in the same change.
- The capability guard is BOTH halves:
typeof wa.addToHomeScreen !== "function"OR!wa.isVersionAtLeast("8.0")→ return (a no-op on an unsupported client). checkHomeScreenStatus—"added"→ the affordance is hidden;"unknown"→ the affordance is SHOWN with the neutral prompt (iOS always reports "unknown"; it is "supported but indeterminate", NEVER an error and NEVER "not added");"missed"→ shown with the prompt;"unsupported"→ not shown.- The affordance calls
wa.addToHomeScreen()on tap. wa.onEvent?.("homeScreenAdded", ...)hides the affordance after the icon is added.- NO
beforeinstallprompt, NO manifest, NO service worker — the browser API does not exist inside the Telegram webview and a second icon Telegram does not know about is worse than none.
web/src/main.ts — the boot wiring. Three edits:
- After the locale derivation (stage 12, lines 92-95), add the destination read per the
<resolve_contract>block — stage 13, with its explanatory comment naming the mechanism correction. - When
destination !== null, mount the destination card into the existing card stack (thestackelement created at lines 87-90). When the id was PRESENT but unknown, mount the "destination unavailable" card. When there was no id, NOTHING changes. - Emit the capture signal UNCONDITIONALLY (outside any map guard), the STATE-TOKEN form from task 1's contract:
console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));— never the id (D-15 hygiene).
Also wire the home screen affordance in the Telegram-bindings block (lines 64-68, next to watchTheme(wa) / wireBackButton(wa, overlayPresent)): wireHomeScreen(wa, locale) — but NOTE the locale is derived at stage 12, AFTER that block. Restructure minimally: move the locale derivation ABOVE the Telegram-bindings block, or wire the home screen later (after the locale is known). The locale derivation depends only on wa, so moving it up is safe — but strip.setLocale(locale) must still run. Keep the change minimal and correct: derive the locale before the bindings block, then call strip.setLocale(locale) where it is today.
web/index.html — the ?dest= profile. Add const dest = q.get("dest") || null; to the harness block, include dest in the window.__ARAHARAH_MOCK__ object, and pass it to installMockWebApp({ ..., dest }) so the mock's startParam can carry it for the secondary carrier's harness leg. The whole block stays inside if (__ARAHARAH_HARNESS__).
web/test/homescreen.test.mjs — NEW. Cases:
HS1 the method being absent is a no-op— a fakewawith noaddToHomeScreen→wireHomeScreenthrows nothing and adds no affordance.HS2 an old client is a no-op—isVersionAtLeast("8.0")returns false → no affordance, no call.HS3 an unsupported status hides the affordance—checkHomeScreenStatusreports"unsupported"→ no affordance.HS4 "added" hides the affordance— the status"added"→ no affordance rendered (do not nag).HS5 "unknown" is treated as indeterminate, NOT as an error— the status"unknown"→ the affordance IS rendered with the neutral prompt, and NO error path is taken.HS6 "missed" shows the affordance— the status"missed"→ the affordance is rendered.HS7 tapping the affordance calls addToHomeScreen— a click on the affordance → the fake'saddToHomeScreenwas called exactly once.HS8 the homeScreenAdded event hides the affordance— firing the event → the affordance is gone.HS9 NO browser install APIs— the homescreen module (comments stripped) contains none ofbeforeinstallprompt,manifest,serviceWorker,navigator.install.
The suite drives the module with injected fakes (the location-chain.test.mjs style: build the fake, call, assert, restore in a finally).
Acceptance: npm run web:typecheck exits 0; npm test -w web green with PASS HS1…PASS HS9.
cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -10 ; npm test -w web 2>&1 | grep -E 'PASS HS|runner' ; grep -c 'ARAHARAH-DEST' web/src/main.ts ; grep -c 'q.get("dest")' web/index.html ; grep -v '^\s**|^\s*//|^/*|^\s*$' web/src/telegram/homescreen.ts | grep -c 'beforeinstallprompt|serviceWorker|manifest' | head -1
<acceptance_criteria>
- npm run web:typecheck exits 0
- npm test -w web exits 0 and the output contains PASS HS1 through PASS HS9
- grep -q 'export function wireHomeScreen' web/src/telegram/homescreen.ts exits 0
- grep -q 'isVersionAtLeast("8.0")' web/src/telegram/homescreen.ts exits 0 (the Bot API 8.0+ capability guard)
- grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/telegram/homescreen.ts | grep -c 'beforeinstallprompt\|serviceWorker\|navigator\.install\|webmanifest' | head -1 returns 0 (D-12: the browser install APIs are forbidden — they do not exist in the Telegram webview)
- grep -c 'unknown' web/src/telegram/homescreen.ts | head -1 returns >= 1 AND grep -q 'homeScreenAdded' web/src/telegram/homescreen.ts exits 0 (the "unknown" branch and the added-event are both handled)
- grep -c 'ARAHARAH-DEST' web/src/main.ts | head -1 returns 1 (the capture signal)
- grep -q 'readDestinationId' web/src/main.ts exits 0 AND grep -q 'setDestination' web/src/main.ts exits 0 (the boot reads the destination and seeds the memory-only holder)
- grep -q 'q.get("dest")' web/index.html exits 0 (the ?dest= harness profile)
- grep -c 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/index.html 2>/dev/null | head -1 returns 0 (the harness block is still dead-code-eliminated from production — BA5/BA6 still hold)
</acceptance_criteria>
ONBD-03's minimal hooks are live behind a Bot API 8.0 capability guard, with the iOS "unknown" status treated as indeterminate and never as an error; the boot resolves a destination from either carrier and renders an honest surface; and no browser install API appears anywhere.
Both cases keep the existing claimBuildFirst() behaviour when web/dist does not exist.
web/test/no-storage.test.mjs — the existing GEO3 gate already walks EVERY web/src file, so web/src/search/* is covered automatically. Add ONE explicit case pinning the new tree's memory-only law:
test("GEO3 web/src/search/ holds no persistence surface and no deep-link crypto", () => {
for (const file of listFiles(path.join(SRC, "search"), [".ts"])) {
const code = codeText(file);
for (const banned of BANNED) {
assert.ok(!code.includes(banned), `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated`);
}
for (const token of ["createHmac", "crypto.subtle", "sha256"]) {
assert.ok(!code.includes(token), `${path.relative(process.cwd(), file)} references ${token} — D-11 forbids crypto in the deep-link path`);
}
}
});
web/scripts/capture.mjs — three bookkeeping edits plus one new leg and one new wait mode, per the <resolve_contract> block:
- The header's output list (lines 22-29) gains
shell-dest-preload.png the Mini App booted WITH a preloaded destination (?dest=<id>). - The
SIGNALSarray (line 165) gains'ARAHARAH-DEST'; theseenmap (line 189) gains'ARAHARAH-DEST': false; thesignalDiagnosticsstring names it. - The new wait mode, added to
captureLeg's wait chain (after thelivebranch at line 218 and before thedeniedbranch):
} else if (waitMode === 'dest') {
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-DEST'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(`ARAHARAH-DEST race timed out after ${SIGNAL_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
}
/* The destination must actually be the RESOLVED one — a signal that fired
* with state=absent means the payload never reached the resolver (T-04-42).
* The signal is a STATE TOKEN (no id — D-15 hygiene), so the leg then
* proves WHICH destination resolved via the rendered card's text. */
const destLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-DEST')).pop() ?? '';
if (!destLine.includes('state=resolved')) {
throw new Error(`destination did not resolve: "${destLine}" — expected state=resolved`);
}
const bodyText = await page.textContent('body');
if (!bodyText || !bodyText.includes('Suria KLCC')) {
throw new Error('the resolved destination card does not show "Suria KLCC" — the wrong entry or no entry rendered');
}
await page.waitForTimeout(500);
}
- The new leg, copied from the chat-pin leg (line 269):
/* 8 — ONBD-02: the destination-preload boot. ?dest=<id> carries the plain
* gazetteer id in the URL's own query string (the inline-button carrier —
* RESEARCH Pitfall 1). The ARAHARAH-DEST signal resolves the race. */
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
- The
expectedarray (lines 361-369) gains'shell-dest-preload.png', and the final success line becomesCAPTURE OK — 8/8 non-blank captures under web/screenshots/.
Acceptance: npm run web:typecheck exits 0; npm test -w web green (BA1-BA8 + DP + HS + the existing suites); npm run capture -w web exits 0 with 8 non-blank captures including the destination-preload one.
cd /home/C-Fu/araharah && npm run build -w theme 2>&1 | tail -2 && npm run build -w web 2>&1 | tail -3 ; npm test -w web 2>&1 | grep -E 'PASS BA|runner' ; npm run capture -w web 2>&1 | tail -8 ; ls -la web/screenshots/shell-dest-preload.png 2>/dev/null
<acceptance_criteria>
- npm run build -w theme && npm run build -w web exits 0
- npm test -w web exits 0 and the output contains PASS BA1 through PASS BA8
- grep -q 'BA7' web/test/build-artifact.test.mjs exits 0 AND grep -q 'BA8' web/test/build-artifact.test.mjs exits 0 (the drift gate and the no-crypto gate exist)
- grep -rl 'suria-klcc' web/dist/assets/*.js | wc -l returns >= 1 (the gazetteer IS in the shipped web bundle — the webview's id lookup can resolve)
- grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/dist/assets/*.js | grep -c 'createHmac\|crypto\.subtle\|sha256' | head -1 returns 0 (no crypto in the shipped bundle — D-11)
- npm run capture -w web exits 0 and the report line reads CAPTURE OK — 8/8 non-blank captures under web/screenshots/
- test -f web/screenshots/shell-dest-preload.png exits 0 AND find web/screenshots/shell-dest-preload.png -size +10240c | wc -l returns 1 (the destination-preload capture exists and is not blank)
- grep -q 'shell-dest-preload.png' web/scripts/capture.mjs exits 0 AND grep -q "waitMode: 'dest'" web/scripts/capture.mjs exits 0 AND grep -q 'ARAHARAH-DEST' web/scripts/capture.mjs exits 0 (the leg, its wait mode, and its signal are wired)
- grep -q 'web/src/search' web/test/no-storage.test.mjs exits 0 (the new tree's memory-only law is pinned explicitly)
</acceptance_criteria>
The shipped web bundle provably carries the whole gazetteer and no crypto; the capture harness produces a destination-preload screenshot that is verified to have resolved the specific id; and the existing build-artifact and no-storage gates keep holding over the new tree.
Leg 1 — ONBD-02, Android (the carrier that matters). In Telegram on an Android phone: send /cari KLCC to the bot → a native venue card for Suria KLCC appears, with an "OPEN IN ARAHARAH" button and 2-4 alternates → tap OPEN → the Mini App opens AND the destination card shows Suria KLCC with its address. Expected: the destination is PRELOADED. A blank map, an empty card stack, or "Destination unavailable" is a FAIL for this leg.
Leg 2 — ONBD-02, iOS. The same flow on an iPhone. Same expectation: the destination is preloaded.
Leg 3 — ONBD-02, Telegram Desktop. The same flow on Desktop. Desktop has no geolocation (Phase 3's issue #77), so observe the DESTINATION card specifically; the location half is Phase 3's separate matrix and is not this leg.
Leg 4 — ONBD-03, home screen. With the app open on the phone, use the app's "ADD TO HOME SCREEN" affordance (or Telegram's own "Add to Home Screen" menu item) → the icon appears on the home screen → close the app → tap the icon → the app opens into the SEARCH-READY state: the map renders, the status strip shows its normal boot state, and there is NO destination and NO crash. On iOS, checkHomeScreenStatus reports "unknown" — that is the platform's documented limitation and MUST NOT be treated as a failure.
Leg 5 — the honest unknown-id state. Open the Mini App with a deliberately wrong id (append ?dest=not-a-real-place to the deployed URL in a browser, or send a crafted link). Expected: the app shows the "Destination unavailable" surface — NOT a blank map, NOT a fabricated pin, NOT a crash.
Leg 6 — the honest refusal. Send /cari Kuching in the chat. Expected: a plain-text reply naming the v1 area, with NO venue card, NO inline keyboard, and NO pin.
Record every leg's result in .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md with an explicit PASS / FAIL / UNVERIFIED verdict, and note the client version and OS for each leg.
Type "device gate passed" if legs 1-3 show the destination preloaded and leg 4 opens into the search-ready state (leg 4 passes on iOS even when the status is "unknown").
Otherwise describe which leg failed and the exact symptom. If no physical device is available, say so — the honest outcome is UNVERIFIED, and the executor records the blocker in .planning/STATE.md under "Blockers/Concerns" and recommends acquiring a device or deferring ONBD-02's device verification to Phase 7 with an explicit requirement-level note. NEVER close the phase claiming device verification that did not happen.
cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm run search:corpus 2>&1 | tail -5 && test -f .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md && grep -c 'PASS|FAIL|UNVERIFIED' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md
<acceptance_criteria>
- npm test -w bot, npm test -w web, and npm run search:corpus all exit 0 (the automated half is green BEFORE any device is touched)
- test -f .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0
- grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md | head -1 returns >= 6 (all six legs carry an explicit verdict)
- grep -q 'Android' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0 AND grep -q 'iOS' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0 AND grep -q 'Desktop' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0 AND grep -q 'home screen' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0 (the legs are recorded, not implied)
- grep -q 'MINI_APP_URL\|BotFather' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md exits 0 (the origin prerequisite was checked — a wrong origin is the first thing that would break every leg)
- If any leg is UNVERIFIED, .planning/STATE.md contains the blocker under "Blockers/Concerns"
</acceptance_criteria>
The ONBD-02 deep-link carrier and the ONBD-03 home-screen launch are verified on real hardware and recorded, OR the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Telegram client → Mini App webview | The ?dest= query param and the start_param field are both attacker-influenced strings. The webview MUST NOT trust either. |
| Webview → the shared gazetteer | The id is resolved against a local, committed dataset — the lookup is a pure function, and an unknown id must produce an honest state, never a guess. |
| Webview → storage | The resolved destination is user context; GEO-03 forbids it from reaching any persistence surface. |
| Telegram client capability → the home-screen affordance | addToHomeScreen / checkHomeScreenStatus exist only on Bot API 8.0+ clients, and iOS always reports "unknown". The affordance must degrade silently. |
| Harness build → production bundle | The ?dest= harness profile must stay inside the dead-code-eliminated block — a leak would ship the mock into production. |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-32 | Tampering | A hostile dest query param — a path traversal, an XSS payload, or a 10 KB string — reaching a lookup or a render |
mitigate | The id is validated against /^[A-Za-z0-9_-]{1,64}$/ BEFORE any lookup (DP6 asserts a traversal-shaped, an XSS-shaped, an empty, and a 200-char id all resolve to null). The lookup is an exact key match against a committed map (getEntryById), never a substring or a path join. The id is never interpolated into HTML — the destination card is built with document.createElement + textContent (the web/src/ui/cards.ts pattern), never innerHTML. |
| T-04-33 | Tampering | A malicious start_param being treated as an identity or a capability |
mitigate | initData is the SIGNED string, not initDataUnsafe; the start_param field inside it is treated as an UNTRUSTED payload (Telegram's own docs call initDataUnsafe untrusted, and the signed string's individual fields are still just data). The value is used ONLY to look up a public place id in the local gazetteer. It is never an identity, never a URL, never a path. An unknown id yields the honest unavailable state. |
| T-04-34 | Spoofing | The webview resolving an id the bot never sent — a user crafting ?dest=some-other-place |
mitigate | Accepted by design (D-11: "the payload's only threat is a user pranking themselves"). The consequence is bounded: the user gets a DIFFERENT public destination preloaded in their OWN session. There is no privilege attached to an id — it names a public place, grants no capability, and leaks nothing about another user. The honesty requirement is the real mitigation: an id that does not resolve produces "destination unavailable", never a fabricated coordinate. |
| T-04-35 | Information Disclosure | The resolved destination (or the raw id) reaching a persistence surface (GEO-03/D-07) | mitigate | The resolver holds the destination in module scope only — the web/src/session/store.ts pattern. web/test/no-storage.test.mjs's GEO3 gate walks EVERY web/src file (including the new web/src/search/ tree) and bans localStorage/sessionStorage/indexedDB/document.cookie/caches; a new explicit case pins the new tree and the no-crypto law. The capture harness's GEO-03 runtime probe still asserts empty storage after boot-to-live. |
| T-04-36 | Denial of Service | A client without addToHomeScreen (pre-Bot-API-8.0) throwing on the call, breaking the boot |
mitigate | The guard is BOTH halves — the method's presence AND isVersionAtLeast("8.0") — and it returns BEFORE any call. HS1/HS2 assert the no-op on a missing method and on an old client. The boot never awaits the affordance; a rejected addToHomeScreen() is caught and ignored (the affordance is best-effort). |
| T-04-37 | Repudiation | The home-screen affordance claiming success on iOS, where the platform ALWAYS reports "unknown" |
mitigate | The status string is NEVER treated as a failure and never as "not added". "unknown" means "supported but indeterminate" and the affordance shows the neutral prompt. "added" is the ONLY status that hides the affordance. HS4/HS5 assert both branches. A failed addToHomeScreen() is not reported as an error either — the affordance stays available for a retry. |
| T-04-38 | Spoofing | A second, Telegram-unaware app icon being installed by a browser install API | mitigate | The implementation uses ONLY Telegram.WebApp.addToHomeScreen(). HS9 asserts the module contains no beforeinstallprompt, no manifest, no serviceWorker, no navigator.install — the browser API does not exist in the Telegram webview, and a second icon Telegram does not know about is worse than none. web/test/no-storage.test.mjs's GEO3-2 case independently bans serviceWorker.register / navigator.serviceWorker / workbox across all of web/src. |
| T-04-39 | Tampering | The web bundle shipping a STALE gazetteer that cannot resolve an id the bot just sent (RESEARCH Pitfall 9) | mitigate | The ids are never renamed, only added. BA7 asserts the shipped bundle contains EVERY id from the committed dataset. The bot and the webview read the SAME committed JSON in the same npm run build chain, so the id sets are identical by construction; the residual risk (a stale deployed bundle) is exactly what BA7 catches at build time. |
| T-04-40 | Information Disclosure | The shipped web bundle carrying a crypto/HMAC implementation in the deep-link path — a signed-token scheme introduced where D-11 forbids one | mitigate | DP10 asserts the resolver source contains no createHmac/crypto.subtle/sha256; BA8 asserts the SHIPPED BUNDLE contains none either (JS assets only — the official external Telegram SDK script is not bundled). The deep link is a plain public id; a signed-token scheme would be security theatre against a self-inflicted threat (RESEARCH §Security V6: "no new crypto — by design"). |
| T-04-41 | Tampering | The harness profile block (?dest=) leaking into the production bundle, shipping the Mock-Telegram fake (which fakes location AND the session gate) |
mitigate | The whole profile block stays inside if (__ARAHARAH_HARNESS__), which Vite's define renders as false in the production build — dead-code-eliminated. The existing BA5/BA6 cases scan EVERY emitted .js AND .html under web/dist for installMockWebApp, __ARAHARAH_MOCK__, /api/session, and /api/location-fallback; the ?dest= addition introduces NO new endpoint and NO new inline fetch stub, so the gate's coverage is unchanged. The capture harness asserts the inverse (installer PRESENT in web/dist-harness), so the gate cannot pass by the harness build being empty. |
| T-04-42 | Denial of Service | The capture leg racing on the wrong signal — resolving on ARAHARAH-LIVE (the LOCATION signal) and photographing a boot that never resolved the destination |
mitigate | The new waitMode: 'dest' waits on ARAHARAH-DEST specifically, asserts the line CONTAINS state=resolved (a state=absent firing fails the leg with a named diagnostic), and then asserts the rendered card's text carries Suria KLCC — so a leg that resolved the WRONG entry also fails. The signal itself is a STATE TOKEN carrying no place data (D-15 hygiene). This is the anti-fake pattern the Phase 3 harness already uses for the theme-slot check. |
| </threat_model> |
<success_criteria>
- ONBD-02: tapping OPEN on the venue card launches the Mini App with the destination PRELOADED — the id travels as
?dest=<slug>in theweb_appbutton's URL (the mechanism correction), withinitData.start_paramas the secondary carrier, and NO crypto anywhere in the path - ONBD-02 (honesty): an unknown or malformed id yields the "destination unavailable" surface — never a blank map, never a fabricated coordinate
- ONBD-03: the app can be added to the phone's home screen and launching from there lands in the SEARCH-READY state —
addToHomeScreenbehind a Bot API 8.0 capability guard, with the iOS"unknown"status treated as indeterminate - D-12: the startapp-less boot is UNCHANGED — the Phase 3 staged boot still lands in the search-ready map
- GEO-03: the resolved destination is memory-only; the storage surfaces stay empty (source gate + runtime probe)
- The shipped web bundle carries the whole gazetteer (the bot/web id-set parity gate) and no crypto
- The device gate is executed on real Android, iOS, and Desktop, with the home-screen launch verified — or the phase is explicitly held open with the blocker documented </success_criteria>