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
+41 -7
View File
@@ -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);