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
+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;
}