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:
Generated
+1
@@ -3969,6 +3969,7 @@
|
||||
"web": {
|
||||
"name": "@araharah/web",
|
||||
"dependencies": {
|
||||
"@araharah/search": "*",
|
||||
"@araharah/theme": "*",
|
||||
"maplibre-gl": "^6.15.0"
|
||||
},
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^6.15.0",
|
||||
"@araharah/theme": "*"
|
||||
"@araharah/theme": "*",
|
||||
"@araharah/search": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.3.5",
|
||||
|
||||
@@ -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<Locale, ShellStrings> = {
|
||||
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<Locale, ShellStrings> = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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=<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 {
|
||||
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=<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 {
|
||||
@@ -54,6 +73,7 @@ interface MockLocationManager {
|
||||
interface MockWebApp extends TelegramWebAppLike {
|
||||
__events: Map<string, Array<() => 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=<encoded>`
|
||||
* 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<string, Array<() => 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user