/* * telegram-mock.ts — the D-11 Mock-Telegram harness. * ------------------------------------------------- * Injects a fake `window.Telegram.WebApp` into the SAME window the app * runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects * Mini App method calls from any origin other than the registered Mini * App domain, so an iframe harness would silently fail (RESEARCH * Pitfall 6). * * Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts * (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it * unmodified against `bot/.dev.vars`. Changes nothing when the token * is real, and never logs it. * * Signing runs on the Web Crypto API (crypto.subtle.importKey + sign, * HMAC-SHA-256, TextEncoder) — the only crypto available in a browser * — so `makeDevInitData` is async. The test suites use their own * test-side signer instead. * * 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`. * * The ?drive= replay knob (06-08, D-16's browser leg): `?drive=` makes * THIS mock replace navigator.geolocation with a replay source that streams * a committed drive fixture (web/test/fixtures/drives/*.json — the same JSON * the Node replays prove the brain with) through the LIVE shell — the real * pump, real builders, real camera consume the fixes via the real * createGuidanceWatch. The replay clock starts lazily at the FIRST * watchPosition arm (the route draw — the pump's ensureWatch), so no fix is * ever emitted before something consumes it, and plays REAL-TIME: the * machine's grace/storm clocks read wall time (route.ts's now()), so a * compressed playback would desync them. window.__ARAHARAH_DRIVE.pause()/ * resume() freeze/resume emission mid-stream — the capture script's freeze * for stable scripted taps (confirm/tripend legs) and the REROUTING leg's * window. A pause stops EMISSION only; the shell's wall clock keeps running * (grace windows inflate across a pause — legs that pause close before any * machine timing matters). Console emits NOTHING per fix; an unknown drive * name is the knob's one honest token (`ARAHARAH-DRIVE state=unknown`) and * falls back to the default geolocation — never a crash, never replayed * arbitrary data (the fixture/kernel mismatch mitigation, T-06-08). */ import type { TelegramWebAppLike, HomeScreenStatus } from "../telegram/webapp.ts"; export interface MockOptions { platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web"; version?: string; colorScheme?: "light" | "dark"; /** Telegram language_code carried in the fake initData's user JSON — * lets main.ts's localeFromInitData exercise its real parsing path * and lets the runner page switch locales via ?locale=. */ languageCode?: string; /** Artificial delivery delay (ms) for LocationManager.init()/getLocation() * callbacks — the runner page's ?fixdelay= knob. At localhost speeds the * whole three-source chain resolves in a few ms, so the staged boot cards * (BOOT-01 validating / BOOT-02 acquiring) cannot otherwise be photographed; * a delayed fix delivery holds the earlier stage on screen (03-04 capture * harness only — defaults to 0, the fallback dev install is unchanged). */ deliveryDelayMs?: number; 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 { isInited: boolean; isLocationAvailable: boolean; isAccessRequested: boolean; isAccessGranted: boolean; init(cb: () => void): void; getLocation(cb: (data: unknown | null) => void): void; } interface MockWebApp extends TelegramWebAppLike { __events: Map void>>; __backButtonClicks: Array<() => void>; __homeScreenAdded: number; __fullscreenRequested: number; __fullscreenExited: number; __orientationLocked: number; __orientationUnlocked: number; __isFullscreen: boolean; } /* ————— Web-Crypto dev signer (mirror of bot/src/security/initdata.ts's HMAC path) ————— */ function utf8Bytes(value: string): Uint8Array { return new TextEncoder().encode(value); } async function hmacBytes(message: Uint8Array, key: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey( "raw", key as unknown as ArrayBuffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", cryptoKey, message as unknown as ArrayBuffer); return new Uint8Array(sig); } function bytesToHex(bytes: Uint8Array): string { return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); } /** dataCheckString = fields minus hash/signature, sorted by key, `key=value`, joined with "\n". */ function dataCheckString(fields: Array<[string, string]>): string { return fields .filter(([key]) => key !== "hash" && key !== "signature") .slice() .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); } /** `POST /api/session` computes the same two-step HMAC: key = HMAC(botToken, salt). */ export async function makeDevInitData( botToken: string, user: { id: number; first_name?: string; language_code?: string }, ): Promise { const fields: Array<[string, string]> = [ ["user", JSON.stringify(user)], ["auth_date", String(Math.floor(Date.now() / 1000))], ]; const dcs = dataCheckString(fields); // Step 1: secret key = HMAC(message = botToken bytes, key = the platform salt constant). const secretKey = await hmacBytes(utf8Bytes(botToken), utf8Bytes("WebAppData")); // Step 2: hash = HMAC(message = dataCheckString bytes, key = that secret key). const hash = bytesToHex(await hmacBytes(utf8Bytes(dcs), secretKey)); const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&"); return `${wire}&hash=${hash}`; } /* ————— install / uninstall ————— */ /** Dotted-numeric Bot API version compare: "8.0" >= "8.0", "9.1" >= "8.0". */ function versionAtLeast(have: string, want: string): boolean { const a = have.split(".").map((n) => Number.parseInt(n, 10) || 0); const b = want.split(".").map((n) => Number.parseInt(n, 10) || 0); for (let i = 0; i < Math.max(a.length, b.length); i++) { const av = a[i] ?? 0; const bv = b[i] ?? 0; if (av > bv) return true; if (av < bv) return false; } return true; } let installed: MockWebApp | null = null; /** * Harness initData: a non-empty, correctly-SHAPED Telegram wire string * (`user=&auth_date=1&query_id=h&hash=harness`). The * capture runner stubs POST /api/session OUTSIDE any dead-code strip, so * this never reaches an HMAC validator — it exists so main.ts's * 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) — 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, 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; } /* ————— the ?drive= fixture-replay source (06-08, D-16's browser leg) ————— */ /** A committed drive fixture's structural minimum (the 06-02 replay shape: * { _notice, pairId, maneuverIdx, kinds, fixes[] } with Fix-shaped fixes). */ interface DriveFixture { fixes: Array<{ lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null; ts: number; source?: string }>; } /** The pause/resume surface the capture script drives (window.__ARAHARAH_DRIVE). */ export interface DriveReplayControls { /** Freeze the replay clock mid-stream — emission stops until resume(). */ pause(): void; /** Continue mid-stream: unpaused wall time resumes driving the schedule. */ resume(): void; readonly name: string; } declare global { interface Window { /** Installed ONLY when ?drive= names a committed fixture — the capture * script's freeze surface (and its diagnostics: .name). */ __ARAHARAH_DRIVE?: DriveReplayControls; } } /** Drive names are slugs (the pairId/destinationId pattern — bounds the * lookup against hostile query strings before the fixture record is read). */ const DRIVE_NAME_PATTERN = /^[a-z0-9-]{1,64}$/; /** The committed D-16 drive replays, inlined into the HARNESS bundle at build * time (eager glob, the route-profiles BA-exclusion posture: this module is * imported by main.ts's dead harness branch and tree-shaken from production — * BA5 asserts the absence). The pattern is ROOT-ABSOLUTE (/test/... — Vite 8's * importGlob matches no relative pattern that escapes src/; verified by the * 06-08 dev-server probe). */ const DRIVE_FIXTURES = import.meta.glob("/test/fixtures/drives/*.json", { eager: true, import: "default", }); /** Defensive shape check (the fixture-source isUsablePair posture): a * malformed drive degrades to the honest unknown-fallback, never a crash * and never a partial replay (T-06-08's Tampering mitigation). */ function isDriveFixture(value: unknown): value is DriveFixture { if (value === null || typeof value !== "object") return false; const fixes = (value as { fixes?: unknown }).fixes; if (!Array.isArray(fixes) || fixes.length < 2) return false; return fixes.every((f) => { if (f === null || typeof f !== "object") return false; const p = f as { lat?: unknown; lng?: unknown; ts?: unknown }; return ( typeof p.lat === "number" && Number.isFinite(p.lat) && typeof p.lng === "number" && Number.isFinite(p.lng) && typeof p.ts === "number" && Number.isFinite(p.ts) ); }); } function driveFixtureByName(name: string): DriveFixture | null { if (!DRIVE_NAME_PATTERN.test(name)) return null; const hit = DRIVE_FIXTURES[`/test/fixtures/drives/${name}.json`]; return isDriveFixture(hit) ? hit : null; } /** Position assembled from a fixture fix at a replayed wall timestamp. The * watch (createGuidanceWatch) normalizes exactly the four consumed fields; * the GeolocationPosition type's remaining members are cast — the harness * source is the fixture itself, not a lying platform. */ function replayedPosition(f: DriveFixture["fixes"][number], timestamp: number): GeolocationPosition { return { coords: { latitude: f.lat, longitude: f.lng, accuracy: f.accuracyM ?? undefined, altitude: null, altitudeAccuracy: null, altitudeAngle: null, heading: f.headingDeg ?? undefined, speed: null, }, timestamp, } as unknown as GeolocationPosition; } /** * Replace navigator.geolocation with the drive replay (harness-only): the * REAL pump arms its REAL watch against this object, so the shell consumes * the committed stream through the unmodified D-15 throttle — replay proves * the shell, never bypassing it (the trust boundary's law). * * Clock semantics: the schedule is real-time (offset = fix.ts − fixes[0].ts; * a 200ms tick delivers every fix whose offset has elapsed). pos.timestamp * is t0 + offset so the machine's now()-anchored windows stay aligned with * the fixes that drive them. Exhaustion stops the timer; a later re-arm * delivers nothing (the drive is over). */ function installDriveReplay(name: string, fixture: DriveFixture): DriveReplayControls { const fixes = fixture.fixes; const first = fixes[0]!; const offsets = fixes.map((f) => Math.max(0, f.ts - first.ts)); let clockStart: number | null = null; // performance.now() at the first watch arm let wallT0 = 0; // Date.now() at the same instant — pos.timestamp = wallT0 + offset let pausedAt: number | null = null; let pausedTotal = 0; let delivered = 0; let nextId = 0; let timer: ReturnType | null = null; const sinks = new Map void>(); const elapsedMs = (): number => { if (clockStart === null) return -1; const nowP = performance.now(); return (nowP - clockStart) - pausedTotal - (pausedAt !== null ? nowP - pausedAt : 0); }; const stopTimer = (): void => { if (timer !== null) { clearInterval(timer); timer = null; } }; const tick = (): void => { const e = elapsedMs(); if (e < 0) return; while (delivered < fixes.length) { const offset = offsets[delivered]!; if (offset > e) break; const fix = fixes[delivered]!; const pos = replayedPosition(fix, wallT0 + offset); for (const sink of sinks.values()) sink(pos); delivered += 1; } if (delivered >= fixes.length) stopTimer(); // fully replayed — the drive is over }; const ensureTimer = (): void => { if (timer === null && delivered < fixes.length) timer = setInterval(tick, 200); }; const fakeGeo = { getCurrentPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): void { // the chain's LINK-2 shape (unused while the Telegram mock serves LINK 1): // the drive's FIRST fix, at wall-now, like a real one-shot read const f = first; setTimeout(() => success(replayedPosition(f, Date.now())), 0); }, watchPosition(success: PositionCallback, _error?: PositionErrorCallback | null, _opts?: PositionOptions): number { const id = ++nextId; sinks.set(id, success); if (clockStart === null) { clockStart = performance.now(); wallT0 = Date.now(); } ensureTimer(); // the first arm starts the clock; re-arms resume delivery return id; }, clearWatch(id: number): void { sinks.delete(id); if (sinks.size === 0) stopTimer(); // the pump stopped the watch — the replay stops with it }, }; Object.defineProperty(navigator, "geolocation", { value: fakeGeo, configurable: true }); const controls: DriveReplayControls = { get name(): string { return name; }, pause(): void { if (pausedAt === null) pausedAt = performance.now(); }, resume(): void { if (pausedAt !== null) { pausedTotal += performance.now() - pausedAt; pausedAt = null; } ensureTimer(); }, }; window.__ARAHARAH_DRIVE = controls; return controls; } export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { const version = options.version ?? "8.0"; const telegramFix = options.telegramFix ?? null; const delayMs = options.deliveryDelayMs ?? 0; const lm: MockLocationManager = { isInited: false, isLocationAvailable: options.grantLocation !== false, isAccessRequested: options.grantLocation !== false, isAccessGranted: options.grantLocation !== false, init(cb: () => void) { setTimeout(() => { lm.isInited = true; cb(); }, delayMs); }, getLocation(cb: (data: unknown | null) => void) { // grantLocation true → delivers the configured fix on a macrotask so the // callback is asynchronous (a wedged client is cb(null) or silence). // The DELIVERY SHAPE is the Telegram LocationManager wire format — // { latitude, longitude, horizontal_accuracy, course } — because the // consumer is requestTelegramFix, which maps exactly those keys. The // MockOptions.telegramFix keys (lat/lng/accuracyM/headingDeg) are the // authoring shape; delivering them verbatim maps to undefined → a NaN // LngLat pageerror in marker.update (found by the 03-04 capture harness, // the first end-to-end consumer of this callback). if (options.grantLocation !== false && telegramFix !== null) { setTimeout(() => cb({ latitude: telegramFix.lat, longitude: telegramFix.lng, horizontal_accuracy: telegramFix.accuracyM ?? undefined, course: telegramFix.headingDeg ?? undefined, }), delayMs); } else { setTimeout(() => cb(null), 0); // the denial signal — synchronous null would hide async bugs } }, }; 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, options.startParam), startParam: options.startParam ?? undefined, colorScheme: options.colorScheme ?? "light", themeParams: {}, version, platform: options.platform ?? "android", __events: events, __backButtonClicks: backButtonClicks, __homeScreenAdded: 0, __fullscreenRequested: 0, __fullscreenExited: 0, __orientationLocked: 0, __orientationUnlocked: 0, __isFullscreen: false, 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); }, // PX-04/D-03 (07-01): the fullscreen members ship with the contract — // the mock satisfies the grown contract in the SAME change ("contract + // fake + test together"). The ?mock= profile's `version` below 8.0 makes // the fullscreen path a silent no-op via the version guard — never // hard-coded, the mock's own versionAtLeast drives the guard. requestFullscreen() { fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1; fake.__isFullscreen = true; }, exitFullscreen() { fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1; fake.__isFullscreen = false; }, lockOrientation() { fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1; }, unlockOrientation() { fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1; }, onEvent(event: string, cb: () => void) { const list = events.get(event) ?? []; list.push(cb); events.set(event, list); }, offEvent(event: string, cb: () => void) { events.set(event, (events.get(event) ?? []).filter((f) => f !== cb)); }, BackButton: { show() {}, hide() {}, onClick(cb: () => void) { backButtonClicks.push(cb); }, offClick(cb: () => void) { const i = backButtonClicks.indexOf(cb); if (i >= 0) backButtonClicks.splice(i, 1); }, }, LocationManager: lm, }; installed = fake; const w = window as unknown as { Telegram?: unknown }; w.Telegram = { WebApp: fake }; // The ?drive= replay knob (06-08): read from location.search HERE — the // mock module is the harness surface, so the knob rides every install path // (the runner page's profile install AND main.ts's plain-dev fallback) // without a new carrier. Unknown name → one honest token + the default // geolocation stays; never a crash, never replayed arbitrary data. let driveName: string | null = null; try { driveName = new URLSearchParams(window.location?.search ?? "").get("drive"); } catch { driveName = null; } if (driveName !== null) { const fixture = driveFixtureByName(driveName); if (fixture === null) console.log("ARAHARAH-DRIVE state=unknown"); else installDriveReplay(driveName, fixture); } return fake; } export function uninstallMockWebApp(): void { installed = null; const w = window as unknown as { Telegram?: unknown }; delete w.Telegram; } /** Fire the registered themeChanged listeners — theme.test.mjs PX-02 case 4. */ export function emitThemeChanged(): void { const fake = installed; if (!fake) throw new Error("installMockWebApp must run before emitThemeChanged"); for (const cb of fake.__events.get("themeChanged") ?? []) cb(); }