feat(04-04): the destination resolver — two carriers, one validation, memory only

- resolve-destination.ts: reads ?dest= (URL, primary — the inline
  web_app-button carrier) then initData's start_param (the signed
  string, secondary — the direct-link/home-screen carrier), ONE
  slug validation, dest_ prefix stripped; resolves against the
  webview's own build-time @araharah/search copy; honest null for
  unknown ids; module-scope memory-only holder (GEO-03/D-07)
- destination-card.ts: the confirmation + honest-unavailable cards,
  createElement/textContent only, existing theme classes (T-04-32)
- webapp.ts: TelegramWebAppLike gains startParam, addToHomeScreen?,
  checkHomeScreenStatus? + HomeScreenStatus (Bot API 8.0+ members)
- telegram-mock.ts: satisfies the extended contract in the same
  change (startParam initData wiring, homeScreenStatus, __homeScreenAdded)
- strings.ts: 5 new copy fields in en+ms; web depends on @araharah/search
- DP1-DP12 green (45/45 web suite), web:typecheck green
This commit is contained in:
araharah-executor
2026-10-24 01:33:56 +08:00
parent 3e3d1917e6
commit 4f958411f3
7 changed files with 269 additions and 8 deletions
+1
View File
@@ -3969,6 +3969,7 @@
"web": { "web": {
"name": "@araharah/web", "name": "@araharah/web",
"dependencies": { "dependencies": {
"@araharah/search": "*",
"@araharah/theme": "*", "@araharah/theme": "*",
"maplibre-gl": "^6.15.0" "maplibre-gl": "^6.15.0"
}, },
+2 -1
View File
@@ -12,7 +12,8 @@
}, },
"dependencies": { "dependencies": {
"maplibre-gl": "^6.15.0", "maplibre-gl": "^6.15.0",
"@araharah/theme": "*" "@araharah/theme": "*",
"@araharah/search": "*"
}, },
"devDependencies": { "devDependencies": {
"vite": "^8.3.5", "vite": "^8.3.5",
+15
View File
@@ -40,6 +40,11 @@ export interface ShellStrings {
stripAwaitingPin: string; stripAwaitingPin: string;
attribution: string; attribution: string;
ariaRecenter: string; ariaRecenter: string;
destinationHeadline: string;
destinationUnavailableHeadline: string;
destinationUnavailableBody: string;
addToHomeScreen: string;
homeScreenAlreadyAdded: string;
} }
export type Locale = "en" | "ms"; export type Locale = "en" | "ms";
@@ -72,6 +77,11 @@ export const strings: Record<Locale, ShellStrings> = {
stripAwaitingPin: "AWAITING PIN", stripAwaitingPin: "AWAITING PIN",
attribution: "© OpenStreetMap contributors", attribution: "© OpenStreetMap contributors",
ariaRecenter: "Recenter map", 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: { ms: {
bootValidatingRegister: "BOOT-01 // SESSION", bootValidatingRegister: "BOOT-01 // SESSION",
@@ -100,6 +110,11 @@ export const strings: Record<Locale, ShellStrings> = {
stripAwaitingPin: "AWAITING PIN", stripAwaitingPin: "AWAITING PIN",
attribution: "© OpenStreetMap contributors", attribution: "© OpenStreetMap contributors",
ariaRecenter: "Tetapkan semula peta", 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",
}, },
}; };
+41 -7
View File
@@ -19,8 +19,18 @@
* *
* This module ships nothing to production: `no-storage.test.mjs` and * This module ships nothing to production: `no-storage.test.mjs` and
* the build-artifact gate both assert it is excluded from `web/dist`. * 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=<encoded>`, 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 { export interface MockOptions {
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; 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; telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null; webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
grantLocation?: boolean; grantLocation?: boolean;
/** The direct-link start parameter (04-04): appended to the mock initData
* as `&start_param=<encoded>` — 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 { interface MockLocationManager {
@@ -54,6 +73,7 @@ interface MockLocationManager {
interface MockWebApp extends TelegramWebAppLike { interface MockWebApp extends TelegramWebAppLike {
__events: Map<string, Array<() => void>>; __events: Map<string, Array<() => void>>;
__backButtonClicks: Array<() => void>; __backButtonClicks: Array<() => void>;
__homeScreenAdded: number;
} }
/* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */ /* ————— 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 * localeFromInitData parses the real wire format instead of falling
* back to "en" (locale = options.languageCode, defaulting to "en"). * back to "en" (locale = options.languageCode, defaulting to "en").
* Untouched when no languageCode is given (the plain-dev fallback mock * 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=<encoded>`
* so the SECONDARY destination carrier is exercisable end-to-end.
*/ */
function buildMockInitData(languageCode?: string): string { function buildMockInitData(languageCode?: string, startParam?: string | null): string {
if (languageCode === undefined) return ""; if (languageCode === undefined && !startParam) return "";
const user = JSON.stringify({ language_code: languageCode, id: 1, first_name: "Harness" }); const user = JSON.stringify({ language_code: languageCode ?? "en", id: 1, first_name: "Harness" });
return `user=${encodeURIComponent(user)}&auth_date=1&query_id=h&hash=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 { export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike {
@@ -178,20 +201,31 @@ export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike
}; };
const events = new Map<string, Array<() => void>>(); const events = new Map<string, Array<() => void>>();
const backButtonClicks: Array<() => void> = []; const backButtonClicks: Array<() => void> = [];
let homeScreenAddedCount = 0;
const homeScreenStatus: HomeScreenStatus = options.homeScreenStatus ?? "unknown";
const fake: MockWebApp = { const fake: MockWebApp = {
initData: buildMockInitData(options.languageCode), initData: buildMockInitData(options.languageCode, options.startParam),
startParam: options.startParam ?? undefined,
colorScheme: options.colorScheme ?? "light", colorScheme: options.colorScheme ?? "light",
themeParams: {}, themeParams: {},
version, version,
platform: options.platform ?? "android", platform: options.platform ?? "android",
__events: events, __events: events,
__backButtonClicks: backButtonClicks, __backButtonClicks: backButtonClicks,
__homeScreenAdded: 0,
isVersionAtLeast(v: string) { isVersionAtLeast(v: string) {
return versionAtLeast(version, v); return versionAtLeast(version, v);
}, },
close() { close() {
/* the runner page listens to window-event hooks; nothing to close here */ /* 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) { onEvent(event: string, cb: () => void) {
const list = events.get(event) ?? []; const list = events.get(event) ?? [];
list.push(cb); list.push(cb);
+75
View File
@@ -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;
}
+113
View File
@@ -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=<id>`): 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/<bot>?startapp=<id>`) 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_<id>` 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=<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 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;
}
+22
View File
@@ -12,9 +12,27 @@
* that null is the contract the location chain must handle. All * that null is the contract the location chain must handle. All
* `Telegram.WebApp.*` calls must originate from the app's own document * `Telegram.WebApp.*` calls must originate from the app's own document
* (Bot API 10.2 origin restriction). * (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 { export interface TelegramWebAppLike {
initData: string; 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"; colorScheme?: "light" | "dark";
themeParams?: { header_bg_color?: string }; themeParams?: { header_bg_color?: string };
version?: string; version?: string;
@@ -23,6 +41,10 @@ export interface TelegramWebAppLike {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
isVersionAtLeast(version: string): boolean; isVersionAtLeast(version: string): boolean;
close(): void; 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; onEvent?(event: string, cb: () => void): void;
offEvent?(event: string, cb: () => void): void; offEvent?(event: string, cb: () => void): void;
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void }; BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };