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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user