diff --git a/package-lock.json b/package-lock.json index 598d62f..d8eeca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3969,6 +3969,7 @@ "web": { "name": "@araharah/web", "dependencies": { + "@araharah/search": "*", "@araharah/theme": "*", "maplibre-gl": "^6.15.0" }, diff --git a/web/package.json b/web/package.json index b792ac2..d6e0ddc 100644 --- a/web/package.json +++ b/web/package.json @@ -12,7 +12,8 @@ }, "dependencies": { "maplibre-gl": "^6.15.0", - "@araharah/theme": "*" + "@araharah/theme": "*", + "@araharah/search": "*" }, "devDependencies": { "vite": "^8.3.5", diff --git a/web/src/i18n/strings.ts b/web/src/i18n/strings.ts index d2edc8e..183f2e3 100644 --- a/web/src/i18n/strings.ts +++ b/web/src/i18n/strings.ts @@ -40,6 +40,11 @@ export interface ShellStrings { stripAwaitingPin: string; attribution: string; ariaRecenter: string; + destinationHeadline: string; + destinationUnavailableHeadline: string; + destinationUnavailableBody: string; + addToHomeScreen: string; + homeScreenAlreadyAdded: string; } export type Locale = "en" | "ms"; @@ -72,6 +77,11 @@ export const strings: Record = { stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Recenter map", + destinationHeadline: "Destination", + destinationUnavailableHeadline: "Destination unavailable", + destinationUnavailableBody: "That place is not in this build. Search again from the bot.", + addToHomeScreen: "ADD TO HOME SCREEN", + homeScreenAlreadyAdded: "Already on your home screen", }, ms: { bootValidatingRegister: "BOOT-01 // SESSION", @@ -100,6 +110,11 @@ export const strings: Record = { stripAwaitingPin: "AWAITING PIN", attribution: "© OpenStreetMap contributors", ariaRecenter: "Tetapkan semula peta", + destinationHeadline: "Destinasi", + destinationUnavailableHeadline: "Destinasi tidak tersedia", + destinationUnavailableBody: "Tempat itu tiada dalam binaan ini. Cari semula dari bot.", + addToHomeScreen: "TAMBAH KE SKRIN UTAMA", + homeScreenAlreadyAdded: "Sudah di skrin utama anda", }, }; diff --git a/web/src/mock/telegram-mock.ts b/web/src/mock/telegram-mock.ts index d5ee2dd..ec890ff 100644 --- a/web/src/mock/telegram-mock.ts +++ b/web/src/mock/telegram-mock.ts @@ -19,8 +19,18 @@ * * This module ships nothing to production: `no-storage.test.mjs` and * the build-artifact gate both assert it is excluded from `web/dist`. + * + * Phase 4 (04-04): the mock satisfies the contract's three new OPTIONAL + * members in the SAME change that introduces them — `startParam` (appended + * to the fake initData as `&start_param=`, the direct-link carrier + * the ONBD-02 harness leg exercises), `homeScreenStatus` (what the mock's + * checkHomeScreenStatus callback delivers; default "unknown", the iOS + * reality), and the addToHomeScreen stub whose `__homeScreenAdded` counter + * tests read (the `__events` / `__backButtonClicks` exposure precedent). + * The mock's initData is HMAC-unvalidated by design — the runner page + * stubs `/api/session`. */ -import type { TelegramWebAppLike } from "../telegram/webapp.ts"; +import type { TelegramWebAppLike, HomeScreenStatus } from "../telegram/webapp.ts"; export interface MockOptions { platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; @@ -40,6 +50,15 @@ export interface MockOptions { telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null; webviewFix?: { lat: number; lng: number; accuracyM?: number } | null; grantLocation?: boolean; + /** The direct-link start parameter (04-04): appended to the mock initData + * as `&start_param=` — the SECONDARY destination carrier's + * harness leg. The URL query carrier needs no mock: page.goto preserves + * `?dest=` in window.location.search. */ + startParam?: string | null; + /** What the mock's checkHomeScreenStatus callback delivers (04-04). + * Defaults to "unknown" — the documented iOS reality, treated by the + * app as "supported but indeterminate", never an error. */ + homeScreenStatus?: HomeScreenStatus; } interface MockLocationManager { @@ -54,6 +73,7 @@ interface MockLocationManager { interface MockWebApp extends TelegramWebAppLike { __events: Map void>>; __backButtonClicks: Array<() => void>; + __homeScreenAdded: number; } /* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */ @@ -131,12 +151,15 @@ let installed: MockWebApp | null = null; * localeFromInitData parses the real wire format instead of falling * back to "en" (locale = options.languageCode, defaulting to "en"). * Untouched when no languageCode is given (the plain-dev fallback mock - * keeps initData "" exactly as before). + * keeps initData "" exactly as before) — unless a startParam arrives + * (04-04), in which case the wire carries it as `&start_param=` + * so the SECONDARY destination carrier is exercisable end-to-end. */ -function buildMockInitData(languageCode?: string): string { - if (languageCode === undefined) return ""; - const user = JSON.stringify({ language_code: languageCode, id: 1, first_name: "Harness" }); - return `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`; +function buildMockInitData(languageCode?: string, startParam?: string | null): string { + if (languageCode === undefined && !startParam) return ""; + const user = JSON.stringify({ language_code: languageCode ?? "en", id: 1, first_name: "Harness" }); + const base = `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=harness`; + return startParam ? `${base}&start_param=${encodeURIComponent(startParam)}` : base; } export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { @@ -178,20 +201,31 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike }; const events = new Map void>>(); const backButtonClicks: Array<() => void> = []; + let homeScreenAddedCount = 0; + const homeScreenStatus: HomeScreenStatus = options.homeScreenStatus ?? "unknown"; const fake: MockWebApp = { - initData: buildMockInitData(options.languageCode), + initData: buildMockInitData(options.languageCode, options.startParam), + startParam: options.startParam ?? undefined, colorScheme: options.colorScheme ?? "light", themeParams: {}, version, platform: options.platform ?? "android", __events: events, __backButtonClicks: backButtonClicks, + __homeScreenAdded: 0, isVersionAtLeast(v: string) { return versionAtLeast(version, v); }, close() { /* the runner page listens to window-event hooks; nothing to close here */ }, + addToHomeScreen() { + homeScreenAddedCount += 1; + fake.__homeScreenAdded = homeScreenAddedCount; + }, + checkHomeScreenStatus(cb: (status: HomeScreenStatus) => void) { + setTimeout(() => cb(homeScreenStatus), 0); + }, onEvent(event: string, cb: () => void) { const list = events.get(event) ?? []; list.push(cb); diff --git a/web/src/search/destination-card.ts b/web/src/search/destination-card.ts new file mode 100644 index 0000000..73cedf1 --- /dev/null +++ b/web/src/search/destination-card.ts @@ -0,0 +1,75 @@ +/* + * destination-card.ts — the ONBD-02 destination surface (D-11). + * ------------------------------------------------------------- + * The webview's visible confirmation of a PRELOADED destination: the entry's + * canonical NAME and its curated ADDRESS (the address VERBATIM from the + * gazetteer — never the normalized form; matching normalizes, display does + * not, MS 2039 display rule / RESEARCH Pitfall 3). The honest counterpart + * renders when an id ARRIVED but does not resolve: "destination unavailable" + * — never a blank map (Phase 3 D-09 anti-blank invariant), never a + * fabricated coordinate (T-04-34). + * + * Built with createElement + textContent ONLY — an id-derived string never + * reaches innerHTML (T-04-32). Composed from the theme's existing component + * classes (.card / .card-header / .card-ref / .text-headline-md / + * .text-body-md / .text-body-sm) exactly as web/src/ui/cards.ts does — no + * new CSS, no off-token styling, no new screen: this is the Phase 3 card + * stack's existing surface carrying a different card. Every string comes + * from the strings module (COPY5 law). + */ +import type { GazetteerEntry } from "@araharah/search/src/types.ts"; +import { strings, type Locale } from "../i18n/strings.ts"; + +/** The destination confirmation card: register line, name headline, the + * curated address verbatim. */ +export function buildDestinationCard(locale: Locale, entry: GazetteerEntry): HTMLElement { + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = strings[locale].destinationHeadline; + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = entry.name; + + const address = document.createElement("p"); + address.className = "text-body-sm"; + address.textContent = entry.address ?? ""; + + card.appendChild(header); + card.appendChild(headline); + card.appendChild(address); + return card; +} + +/** The honest unavailable card: an id arrived and resolved to nothing. + * The boot continues into the normal staged boot around it. */ +export function buildDestinationUnavailableCard(locale: Locale): HTMLElement { + const card = document.createElement("div"); + card.className = "card"; + + const header = document.createElement("div"); + header.className = "card-header"; + const ref = document.createElement("span"); + ref.className = "card-ref"; + ref.textContent = strings[locale].destinationHeadline; + header.appendChild(ref); + + const headline = document.createElement("h2"); + headline.className = "text-headline-md"; + headline.textContent = strings[locale].destinationUnavailableHeadline; + + const body = document.createElement("p"); + body.className = "text-body-md"; + body.textContent = strings[locale].destinationUnavailableBody; + + card.appendChild(header); + card.appendChild(headline); + card.appendChild(body); + return card; +} diff --git a/web/src/search/resolve-destination.ts b/web/src/search/resolve-destination.ts new file mode 100644 index 0000000..e121d0c --- /dev/null +++ b/web/src/search/resolve-destination.ts @@ -0,0 +1,113 @@ +/* + * resolve-destination.ts — the memory-only destination resolver (ONBD-02, D-11). + * ------------------------------------------------------------------------------ + * MECHANISM (corrected per RESEARCH Pitfall 1): the bot's OPEN button is a + * `web_app` inline button whose WebAppInfo carries ONLY a `url` — so the + * destination id arrives on TWO carriers, read here in a fixed order with ONE + * shared validation: + * + * 1. PRIMARY — the URL's own query string (`?dest=`): the inline-button + * path. Telegram opens the button's URL verbatim in the webview. + * 2. SECONDARY — the `start_param` field of the SIGNED initData string: the + * documented direct-link (`t.me/?startapp=`) and home-screen + * shortcut path. + * + * The id is a plain PUBLIC gazetteer id. NO crypto, NO signature, NO token + * anywhere on this path (D-11) — the payload's only threat is a user pranking + * themselves, and the bounded consequence is a different public destination in + * their own session. Only the SIGNED initData string is ever read (the signed + * string is what the server validates); its `start_param` field is treated as + * an UNTRUSTED payload and is used ONLY as an exact lookup key. + * + * WHAT is stored: the resolved destination entry, in module scope (closure + * over `let`). WHAT is never stored: anything else — and NO persistence call + * anywhere in this file (no storage surface of any kind; the grep gates pin + * this — DP9 and the no-storage suite). The destination is user context and + * lives in memory for this session only (GEO-03/D-07). + * + * THE HONEST-NULL LAW: an unknown or malformed id resolves to `null` — the + * caller renders the honest "destination unavailable" surface. Never a blank + * map (the Phase 3 D-09 anti-blank invariant binds here too), never a + * fabricated coordinate. + */ +import { getEntryById } from "@araharah/search/src/index.ts"; +import type { GazetteerEntry } from "@araharah/search/src/types.ts"; + +/** Where the id came from — "none" means no id arrived at all. */ +export type DestinationSource = "url" | "start-param" | "none"; + +export interface ResolvedDestination { + entry: GazetteerEntry; + source: DestinationSource; +} + +/** The shared id validation (BOTH carriers): slug charset, 1..64 chars. */ +const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +/** Strip one leading `dest_` (D-11's literal `dest_` wire form) and + * validate the remainder. Any failure returns null — an id that fails the + * charset check is treated as absent, never partially trusted (T-04-32). */ +function cleanId(raw: string): string | null { + const stripped = raw.startsWith("dest_") ? raw.slice("dest_".length) : raw; + return ID_PATTERN.test(stripped) ? stripped : null; +} + +/** Extract `start_param` from the SIGNED initData string — parsed the way + * bot/src/security/initdata.ts parses fields: split on `&`, single + * decodeURIComponent. `+` stays literal (never URLSearchParams on initData — + * the signed string's encoding differs from a URL query). An undecodable + * percent-escape is treated as absent. */ +function startParamFromInitData(initData: string | null | undefined): string | null { + if (!initData) return null; + for (const pair of initData.split("&")) { + if (!pair.startsWith("start_param=")) continue; + const value = pair.slice("start_param=".length); + try { + return decodeURIComponent(value); + } catch { + return null; + } + } + return null; +} + +/** Read the destination id from BOTH carriers (mechanism corrected per + * RESEARCH Pitfall 1). Primary: the URL's own query string (`?dest=`) — + * the inline-button path. Secondary: initData's start_param — the + * documented direct-link / home-screen path. Both are validated against + * the same charset; a leading `dest_` is stripped. */ +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" }; +} + +/** Resolve an id against the webview's OWN build-time copy of the shared + * gazetteer (`@araharah/search` — the same committed dataset the bot + * bundles, so the id mapping stays in sync by construction, D-08/D-11). + * Returns null for an unknown id — the honest null (never a fabricated + * coordinate, T-04-34). An exact-key match only: never a substring, never + * a path join, never a guess. */ +export function resolveDestination(id: string | null): GazetteerEntry | null { + if (id === null) return null; + return getEntryById(id); +} + +/* The memory-only holder: the resolved destination lives in module scope, + * nowhere else (GEO-03/D-07). Seeded ONCE at boot by main.ts. */ +let destination: GazetteerEntry | null = null; + +export function setDestination(entry: GazetteerEntry | null): void { + destination = entry; +} + +export function getDestination(): GazetteerEntry | null { + return destination; +} diff --git a/web/src/telegram/webapp.ts b/web/src/telegram/webapp.ts index 788719d..c6a3484 100644 --- a/web/src/telegram/webapp.ts +++ b/web/src/telegram/webapp.ts @@ -12,9 +12,27 @@ * that null is the contract the location chain must handle. All * `Telegram.WebApp.*` calls must originate from the app's own document * (Bot API 10.2 origin restriction). + * + * Phase 4 (ONBD-02/ONBD-03) grows the contract by exactly three OPTIONAL + * members: `startParam` (the documented direct-link start parameter — NOT + * populated by an inline web_app button, whose WebAppInfo carries only a + * `url`), `addToHomeScreen` and `checkHomeScreenStatus` (both Bot API 8.0+ + * only; on iOS the status is ALWAYS "unknown"). The hand-rolled contract + * stays the contract — `@types/telegram-web-app` remains deliberately + * NOT a dependency; every test fake and the harness mock satisfy exactly + * these members. */ +export type HomeScreenStatus = + | "unsupported" // the feature is not supported here + | "unknown" // supported, but whether the icon is installed is indeterminate (always iOS) + | "added" // already on the home screen + | "missed"; // not added yet + export interface TelegramWebAppLike { initData: string; + /** The documented direct-link start parameter — NOT populated by an + * inline web_app button (its WebAppInfo carries only a `url`). */ + startParam?: string; colorScheme?: "light" | "dark"; themeParams?: { header_bg_color?: string }; version?: string; @@ -23,6 +41,10 @@ export interface TelegramWebAppLike { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; isVersionAtLeast(version: string): boolean; close(): void; + /** 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; onEvent?(event: string, cb: () => void): void; offEvent?(event: string, cb: () => void): void; BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };