Files
Arah2/.planning/phases/03-mini-app-shell-geolocation/03-01-PLAN.md
T
2026-10-23 22:37:55 +08:00

45 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-mini-app-shell-geolocation 01 execute 1
web/package.json
web/tsconfig.json
web/vite.config.ts
web/index.html
web/test/runner.mjs
web/test/harness.mjs
bot/src/kv/location-pin.ts
bot/src/bot/handlers/location-fallback.ts
bot/src/bot/bot.ts
bot/src/i18n/strings.ts
bot/src/webhook.ts
bot/test/location-pin.test.mjs
bot/test/privacy.test.mjs
package.json
true
GEO-02
GEO-03
decisions_implemented truths artifacts key_links
D-01: TypeScript + Vite frontend, no UI framework, MapLibre GL JS v6 bundled — the workspace foundation Phases 4–6 build on
D-02: app source in a `web/` npm workspace; theme consumed as a build-time dependency, not CDN
`web/` exists as an npm workspace with Vite + TypeScript configured and `npm test -w web` runs
A dev-signed initData string can be produced by a test helper (the keystone for every later web test)
`GET /api/location` validates a Bearer session token and returns only that token's own chat pin, or 401
`POST /api/location-fallback` validates the same token and asks the bot to send the location keyboard into THAT claim's chat, in the claim's language
The bot sends a ReplyKeyboardMarkup with request_location:true and records the shared pin in TTL-only KV
Every KV write in the new location-pin module carries an explicit expirationTtl on the same line
path provides
web/package.json @araharah/web workspace manifest with test/typecheck/dev/build scripts
path provides
web/tsconfig.json DOM-targeted strict TS config mirroring bot/tsconfig.json flags (+ resolveJsonModule for the basemap style import)
path provides
web/test/runner.mjs hand-rolled zero-dependency test runner (copied from bot/test/runner.mjs)
path provides
web/test/harness.mjs zero-dependency test registry (copied from bot/test/harness.mjs)
path provides
bot/src/kv/location-pin.ts TTL-only KV location-pin layer (put/get/delete by chat id)
path provides
bot/src/bot/handlers/location-fallback.ts GEO-02 keyboard sender + message.location receiver
path provides
bot/src/webhook.ts GET /api/location relay + POST /api/location-fallback keyboard trigger, both Bearer-gated
from to via pattern
bot/src/webhook.ts bot/src/kv/sessions.ts getClaimByToken(env.chat_sessions, token) — derives chat_id from the CLAIM, never from a client-supplied id getClaimByToken
from to via pattern
bot/src/webhook.ts bot/src/bot/handlers/location-fallback.ts handleLocationFallback calls requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)) — the ONLY caller, so the export is never dead code requestLocationFallback
from to via pattern
bot/src/bot/handlers/location-fallback.ts bot/src/kv/location-pin.ts writePin(kv, chatId, {latitude, longitude}) after message.location arrives writePin
Stand up the `web/` npm workspace shell (D-01, D-02) with the Wave-0 test runner, and build the server half of GEO-02: the TTL-only location-pin KV module, the bot's chat-keyboard fallback handler, and BOTH Worker routes that relay a chat-shared pin to the open Mini App — `GET /api/location` (the poll) and `POST /api/location-fallback` (the app→bot keyboard trigger).

Purpose: GEO-02 is impossible without a chat→WebView relay — Telegram has no push channel from chat to WebView, so the bot must write the pin to KV and the app must poll an authenticated endpoint (RESEARCH §Pattern 3). The trigger direction matters just as much: the denied card's ASK VIA CHAT CTA (plan 03-03) and the Desktop leg of the device matrix (plan 03-04) both need a route that makes the bot send the keyboard. Both directions land here.

Output: web/ workspace + test runner; bot/src/kv/location-pin.ts; bot/src/bot/handlers/location-fallback.ts; GET /api/location + POST /api/location-fallback routes; bot/test/location-pin.test.mjs; extended privacy.test.mjs.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md

Commit discipline: commit after EACH task (one commit per task, atomic). If task 3 overruns its context budget, split it rather than pushing through — this plan touches 14 files across 3 tasks, which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim. A task that needs more than ~7 files should be decomposed, not absorbed. </execution_context>

@AGENTS.md @.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md @.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md @.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md @bot/src/webhook.ts @bot/src/kv/sessions.ts @bot/src/bot/bot.ts @bot/src/bot/commands/start.ts @bot/src/i18n/strings.ts @bot/src/config.ts @bot/test/privacy.test.mjs @bot/test/commands.test.mjs @bot/test/webhook.test.mjs @bot/test/runner.mjs @bot/test/harness.mjs @bot/package.json @bot/tsconfig.json @package.json @theme/package.json From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
export const SESSION_TTL_SECONDS = 900;

export interface SessionClaim {
  user_id: number;
  chat_id: number;
  issued_at: number; // seconds-since-epoch
  language_code: string | null;
}

interface KvLike {
  get(key: string): Promise<string | null>;
  put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
  delete(key: string): Promise<void>;
}

export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise<string>;
export async function getClaimByToken(kv: MintSessionKv, token: string): PromiseSessionClaim | null;
export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise<string | null>;

From bot/src/config.ts:

export interface Env {
  BOT_TOKEN: string;
  WEBHOOK_SECRET: string;
  chat_sessions: KVNamespace;
}

From bot/src/webhook.ts — the dispatch shape to extend:

export default {
  async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname === "/telegram/webhook") { /* ... */ }
    if (url.pathname === "/api/session") { return handleSession(req, env); }
    return new Response("Not found", { status: 404 });
  },
} satisfies ExportedHandler<Env>;

From bot/test/webhook.test.mjs — the dev-signing helper shape (copy this, do NOT reimplement HMAC):

function dataCheckString(fields) {
  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");
}
function telegramSecretKey(botToken) {
  return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
}
function buildInitData(fields) {
  const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
    .update(dataCheckString(fields), "utf8")
    .digest("hex");
  const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
  return `${wire}&hash=${hash}`;
}
task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14) web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json - `package.json` (root — see current workspaces array and bot:* delegate script block) - `bot/package.json` (the workspace manifest shape to mirror exactly) - `bot/tsconfig.json` (the strictness flags to preserve) - `bot/test/runner.mjs` and `bot/test/harness.mjs` (copy verbatim) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Standard Stack (pinned versions) **Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
{
  "name": "@araharah/web",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node test/runner.mjs",
    "typecheck": "tsc --noEmit",
    "dev": "vite",
    "build": "vite build",
    "capture": "node scripts/capture.mjs"
  },
  "dependencies": {
    "maplibre-gl": "^6.15.0",
    "@araharah/theme": "*"
  },
  "devDependencies": {
    "vite": "^8.3.5",
    "typescript": "^5.9.3",
    "playwright": "^1.50.0"
  }
}

Pinned versions come from RESEARCH §Standard Stack (maplibre-gl 6.15.0, vite 8.3.5, typescript 5.9.3 — do NOT bump to 7.x, playwright ^1.50.0).

Do NOT add @types/telegram-web-app. RESEARCH §Don't Hand-Roll recommends it for the full WebApp surface, but this phase consumes exactly 12 members behind one narrow hand-rolled interface (TelegramWebAppLike in web/src/telegram/webapp.ts, plan 03-02 task 3). A narrow interface is required here because every test fake and every harness mock is a plain object literal matching it — pulling in the 500-member official WebApp type would force each fake to satisfy ~50 unimplemented members. This retires RESEARCH assumption A2 by construction: there is no upstream typing to drift from. Do not add the dependency later "for completeness."

Step B — web/tsconfig.json. Copy bot/tsconfig.json verbatim and change exactly THREE keys (everything else stays byte-identical):

  • "lib": ["ES2023", "DOM", "DOM.Iterable"] (was ["ES2023"])
  • "types": ["vite/client"] (was ["@cloudflare/workers-types"])
  • "resolveJsonModule": true (ADDED — not present in bot/tsconfig.json). This third key is pre-authorized and REQUIRED: plan 03-03 task 2 imports @araharah/theme/basemap/liberty-rams.json as a module (import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"), which is a TypeScript error without it. Do NOT add esModuleInterop/allowSyntheticDefaultImports — Vite's bundler resolution + resolveJsonModule covers the default import.

Keep every other flag byte-identical: "target": "ES2023", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, "allowImportingTsExtensions": true, "noEmit": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "include": ["src"]. Do NOT add "jsx" (no framework, D-01).

Step C — web/vite.config.ts.

import { defineConfig } from "vite";

export default defineConfig({
  base: "/",
  build: {
    outDir: "dist",
    emptyOutDir: true,
    target: "es2022",
  },
  server: {
    port: 5173,
    strictPort: true,
  },
});

Step D — web/index.html. Minimal shell entry. Exact viewport meta (viewport-fit=cover is the safe-area enabler, UI-SPEC §Safe-Area Handling):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
  <title>araharah</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

Do NOT add a <script src=".../telegram-web-app.js"> tag — Telegram injects window.Telegram.WebApp automatically inside the WebView (RESEARCH §State of the Art). Do NOT copy the mockups' Tailwind Play CDN or Google Fonts links — D-02/D-14 replace them with build-time @araharah/theme imports (that wiring lands in plan 03).

Step E — web/test/runner.mjs and web/test/harness.mjs. Copy bot/test/runner.mjs and bot/test/harness.mjs verbatim — they are directory-relative and workspace-agnostic. Update only the header comment block of runner.mjs to name the precedent chain: "Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1) and bot/test/ (Phase 2)."

Step F — root package.json. Add "web" to the workspaces array (after "bot"), and add these delegate scripts mirroring the existing bot:* block (D-02 ergonomics):

"web:test": "npm test -w web",
"web:typecheck": "npm run typecheck -w web",
"web:dev": "npm run dev -w web",
"web:build": "npm run build -w web",
"web:capture": "npm run capture -w web"

Add the file header comment line to each new web/src file in later tasks: "filename.ts — one-line purpose (decision IDs)." then a dashed rule, then the contract in prose (PATTERNS §Shared Pattern 1). cd /home/C-Fu/araharah && npm install 2>&1 | tail -5 && node -e "const p=require('./package.json'); if(!p.workspaces.includes('web')) throw new Error('web workspace missing'); ['web:test','web:typecheck','web:dev','web:build','web:capture'].forEach(k=>{if(!p.scripts[k]) throw new Error('missing script '+k)}); console.log('root OK')" && node web/test/runner.mjs <acceptance_criteria> - node -e "require('./package.json').workspaces.includes('web')" exits 0 - grep -c '"web:' package.json returns 5 (test, typecheck, dev, build, capture) - diff <(tail -n +11 bot/test/runner.mjs) <(tail -n +11 web/test/runner.mjs) produces no output (bodies identical past the header) - diff bot/test/harness.mjs web/test/harness.mjs produces no output (verbatim copy) - grep -c 'viewport-fit=cover' web/index.html returns 1 - grep -q 'telegram-web-app.js' web/index.html must FAIL (no script tag for the Telegram SDK) - grep -q '"noUncheckedIndexedAccess": true' web/tsconfig.json exits 0 - grep -q '"resolveJsonModule": true' web/tsconfig.json exits 0 (pre-authorized third key — required by 03-03's style-JSON import) - node -e "const t=require('./web/tsconfig.json'); const keys=Object.keys(t.compilerOptions); console.log(keys.length)" — the file has exactly the three keys changed above plus the inherited set; nothing else was touched - npm test -w web exits 0 with [runner] 0 passed, 0 failed (empty suite is a pass) </acceptance_criteria> web/ workspace is installed, typecheckable, testable, and wired into root delegates; test runner matches repo convention verbatim.

task 2: TTL-only location-pin KV module + privacy gate extension (GEO-03, PRIV-02) bot/src/kv/location-pin.ts, bot/test/privacy.test.mjs - `bot/src/kv/sessions.ts` (the entire contract shape to mirror — KvLike, put-line discipline, defensive read) - `bot/test/privacy.test.mjs` (the PG1/PG2 gate and the storageModules list at line 25) **Create `bot/src/kv/location-pin.ts`.** Copy `bot/src/kv/sessions.ts`'s header-comment style, `KvLike` interface, `KvLike` structural typing, named-TTL-constant discipline, and defensive read pattern verbatim. Concrete content:

Header comment (purpose + D-IDs + the storage contract prose — PATTERNS §Shared Pattern 1):

/*
 * location-pin.ts — TTL-only KV location-pin layer (GEO-02, GEO-03, PRIV-02).
 * ----------------------------------------------------------------------
 * Storage contract (privacy by construction — GEO-03, PRIV-02):
 *   - EVERY write to `chat_sessions` carries an explicit `expirationTtl`
 *     on its call line — no write path in this module can persist
 *     without a TTL. privacy.test.mjs's PG1 gate pins this.
 *   - The record is exactly Telegram's Location fields: latitude,
 *     longitude, horizontal_accuracy (optional). Nothing else is ever
 *     serialized. A 4th property means drift.
 *   - The pin is keyed by chat_id, derived from the SESSION CLAIM
 *     (never from a client-supplied id) — a token resolves to exactly
 *     one chat_id, and the KV key is `loc:<chat_id>`.
 *
 * Key scheme:
 *   loc:<chat_id>   → JSON LocationPin record
 *
 * GEO-03 note: this pin lives only in KV with a TTL. The client never
 * persists a coordinate anywhere (D-07) — this is the ONLY place a
 * coordinate ever exists server-side, and it expires.
 */

Exported constants and types:

export const PIN_TTL_SECONDS = 300;

export interface LocationPin {
  latitude: number;
  longitude: number;
  horizontal_accuracy: number | null;
}

interface KvLike {
  get(key: string): Promise<string | null>;
  put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
  delete(key: string): Promise<void>;
}

export interface LocationPinKv extends KvLike {}

Functions (mirroring sessions.ts shapes):

export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
  await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
}

export async function getPin(kv: LocationPinKv, chatId: number): Promise<LocationPin | null> {
  const raw = await kv.get(`loc:${chatId}`);
  if (raw === null) return null;
  try {
    const parsed: unknown = JSON.parse(raw);
    if (
      parsed &&
      typeof parsed === "object" &&
      "latitude" in parsed &&
      "longitude" in parsed &&
      typeof (parsed as LocationPin).latitude === "number" &&
      typeof (parsed as LocationPin).longitude === "number" &&
      Math.abs((parsed as LocationPin).latitude) <= 90 &&
      Math.abs((parsed as LocationPin).longitude) <= 180
    ) {
      return parsed as LocationPin;
    }
    return null;
  } catch {
    return null;
  }
}

export async function clearPin(kv: LocationPinKv, chatId: number): Promise<void> {
  await kv.delete(`loc:${chatId}`);
}

The Math.abs(...) <= 90 / <= 180 range check is the V5 Input Validation mitigation from RESEARCH §Security Domain — reject absurd coordinates rather than flying the camera to null island.

Extend bot/test/privacy.test.mjs. At line 25, change:

const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));

to:

const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"].map((rel) => path.join(SRC, ...rel.split("/")));

That single edit brings the new module under the existing PG1 (same-line expirationTtl, named constant) and PG2 (no persistence-shaped exports) gates automatically.

Test-fake note for the later test file: the KV fake in bot/test/webhook.test.mjs already logs expirationTtl per put — reuse that shape verbatim in bot/test/location-pin.test.mjs (task 3). cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -5 && grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs <acceptance_criteria> - grep -v '^ \*\|^/\*\|^$' bot/src/kv/location-pin.ts | grep -c '\.put(' returns 1 and that line contains expirationTtl: PIN_TTL_SECONDS - grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs exits 0 (module is in the scanned list) - grep -q 'export const PIN_TTL_SECONDS = 300;' bot/src/kv/location-pin.ts exits 0 - npm test -w bot exits 0 and reports [runner] N passed, 0 failed with N >= 15 (Phase 2's suite plus the extended gate) - node -e "const s=require('fs').readFileSync('bot/src/kv/location-pin.ts','utf8'); if(!/location\s*:\s*\loc:/.test(s)) throw new Error('key scheme wrong'); if(!/Math.abs/.test(s)) throw new Error('range check missing')" ` exits 0 </acceptance_criteria> location-pin.ts is TTL-only by construction, range-validated on read, and covered by the existing privacy gate.

task 3: GEO-02 bot half — chat keyboard fallback + `GET /api/location` route + tests bot/src/bot/handlers/location-fallback.ts, bot/src/bot/bot.ts, bot/src/i18n/strings.ts, bot/src/webhook.ts, bot/test/location-pin.test.mjs - `bot/src/bot/bot.ts` (the registration pattern) - `bot/src/bot/commands/start.ts` (handler + i18n import + register pattern) - `bot/src/i18n/strings.ts` (BotStrings interface + en/ms key-set parity) - `bot/src/webhook.ts` (dispatch pattern lines 39-63, method gate line 84, auth+service+response pattern lines 99-113) - `bot/src/kv/sessions.ts` (`getClaimByToken`) - `bot/src/kv/location-pin.ts` (written in task 2 — read your own output) - `bot/test/commands.test.mjs` (CS4 keyboard gate — you must decide whether to extend it) - `bot/test/webhook.test.mjs` (fake KV + makeEnv + handler import pattern) **Step A — `bot/src/i18n/strings.ts`.** Add two keys to the `BotStrings` interface (after `helpMenu`), both with doc-comments: ```typescript /** GEO-02: chat-keyboard fallback prompt — bot tells the user to share a pin. */ awaitingPin: string; /** GEO-02: the reply-keyboard button label (request_location: true). */ shareLocationButton: string; ``` Add to `strings.en`: ```typescript awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.", shareLocationButton: "Share my location", ``` Add to `strings.ms`: ```typescript awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.", shareLocationButton: "Kongsi lokasi saya", ``` CS1 (en/ms key-set parity) will verify these stay in step.

Step B — bot/src/bot/handlers/location-fallback.ts (create the directory). Two exported functions plus one private handler.

/*
 * location-fallback.ts — GEO-02 chat-keyboard location fallback.
 * -------------------------------------------------------------------
 * When in-app location fails (denied permission, or Desktop Telegram
 * which has NO geolocation at all — Telegram-Mini-Apps issue #77), the
 * bot sends a ReplyKeyboardMarkup with request_location: true. The
 * user taps it, Telegram's native location picker opens, and the bot
 * receives the shared pin as a normal Message with a `location` field.
 *
 * That pin is written to TTL-only KV (kv/location-pin.ts) keyed by
 * chat_id. The open Mini App polls GET /api/location (webhook.ts) to
 * pick it up — Telegram has NO push channel from chat to WebView, so
 * the relay is server-mediated by necessity.
 *
 * D-02 structural note: this handler is the ONE deliberate exception
 * to the Phase-2 "plain text only" command-surface rule. The GEO-02
 * keyboard is required by the platform; it lives in handlers/ and is
 * NOT part of commandSources in commands.test.mjs's CS4 gate (which
 * scans only bot.ts + commands/*). See the CS4 decision recorded below.
 */
import type { Bot, Context } from "grammy";
import { pickLocale, strings } from "../../i18n/strings.ts";
import type { Locale } from "../../i18n/strings.ts";
import { writePin, type LocationPinKv } from "../../kv/location-pin.ts";

/** Send the awaiting-pin prompt + location keyboard (GEO-02). */
export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise<void> {
  await bot.chat.sendMessage(chatId, strings[locale].awaitingPin, {
    reply_markup: {
      keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
      resize_keyboard: true,
      one_time_keyboard: true,
    },
  });
}

/** Persist a user-shared pin to TTL-only KV (GEO-03, PRIV-02). */
export async function recordSharedPin(kv: LocationPinKv, chatId: number, loc: { latitude: number; longitude: number; horizontal_accuracy?: number }): Promise<void> {
  await writePin(kv, chatId, {
    latitude: loc.latitude,
    longitude: loc.longitude,
    horizontal_accuracy: loc.horizontal_accuracy ?? null,
  });
}

/** Register the GEO-02 message handler. Filters on ctx.message?.location. */
export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void {
  bot.on("message", async (ctx: Context) => {
    const loc = ctx.message?.location;
    if (!loc) return;
    const chatId = ctx.chat?.id;
    if (chatId === undefined) return;
    await recordSharedPin(kv, chatId, loc);
    await ctx.reply("Got it — your starting point is set.", { link_preview_options: { is_disabled: true } });
  });
}

CS4 decision (explicit, per PATTERNS §bot-strings note): the new handler lives in bot/src/bot/handlers/, which is NOT in commandSources (that array is ["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"]). CS4 therefore keeps passing untouched — do NOT add handlers/ to that array, and do NOT delete the gate. Record the rationale in the handler's header comment (done above).

Step C — bot/src/bot/bot.ts. Update the import block and createBot to thread KV through. Use this exact shape (the optional opts.baseFetch seam exists so bot/test/location-pin.test.mjs LP12/LP13 can observe the outgoing sendMessage):

import { registerLocationFallback } from "./handlers/location-fallback.ts";
// ...
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
  const bot = new Bot(env.BOT_TOKEN, opts.baseFetch ? { client: { baseFetch: opts.baseFetch } } : {});
  registerStart(bot); // /start + /mula (D-04)
  registerHelp(bot); // /help
  registerPrivacy(bot); // /privacy + /privasi (D-04)
  registerLocationFallback(bot, env.chat_sessions); // GEO-02 chat-keyboard fallback
  return bot;
}

Update the header comment to note the GEO-02 handler's keyboard is the one structural exception to the D-02 text-only rule, and that requestLocationFallback has exactly ONE caller — handleLocationFallback in webhook.ts (Step D). If a second caller appears later, that is a design change, not a refactor.

Step D — bot/src/webhook.ts. Add BOTH route branches and handlers. The second route is the app→bot keyboard trigger (GEO-02) — without it, requestLocationFallback in Step B is dead code with no caller, and the ASK VIA CHAT CTA in plan 03-03 and the Desktop leg in plan 03-04 have nothing to POST to.

Dispatch (insert after the /api/session branch, before the 404):

    if (url.pathname === "/api/location") {
      return handleLocation(req, env);
    }
    if (url.pathname === "/api/location-fallback") {
      return handleLocationFallback(req, env);
    }

First handler — the relay (as previously specified):

/** GEO-02 relay: the Mini App polls here with its memory-held Bearer token
 *  to pick up a pin the user shared in chat. V4 Access Control (RESEARCH
 *  §Security Domain): the bearer token is resolved via getClaimByToken; a
 *  missing/expired token is a 401. The KV key is derived from the CLAIM's
 *  chat_id — NEVER from a client-supplied chat id, so one user's token
 *  can never read another user's pin. */
async function handleLocation(req: Request, env: Env): Promise<Response> {
  if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
  const header = req.headers.get("authorization") ?? "";
  const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
  if (!match || !match[1]) {
    console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
    return new Response(JSON.stringify({ error: "unauthorized" }), {
      status: 401,
      headers: { "content-type": "application/json" },
    });
  }
  const token = match[1];
  const claim = await getClaimByToken(env.chat_sessions, token);
  if (claim === null) {
    console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
    return new Response(JSON.stringify({ error: "unauthorized" }), {
      status: 401,
      headers: { "content-type": "application/json" },
    });
  }
  const pin = await getPin(env.chat_sessions, claim.chat_id);
  if (pin === null) {
    return new Response(JSON.stringify({ pin: null }), {
      status: 200,
      headers: { "content-type": "application/json" },
    });
  }
  return new Response(JSON.stringify({ pin }), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}

Second handler — the keyboard trigger:

/** GEO-02 trigger: the Mini App's denied card taps ASK VIA CHAT and POSTs
 *  here; the Worker asks the BOT to send the reply keyboard in the user's
 *  own chat. Same V4 access control as handleLocation — the target chat_id
 *  comes from the resolved CLAIM, never from the request body, so one
 *  user's token can never push a keyboard into another user's chat.
 *
 *  The locale comes from `claim.language_code`, which mintSession already
 *  recorded from the validated initData — the Worker does NOT re-derive it
 *  from client input, and no language is accepted from the request body. */
async function handleLocationFallback(req: Request, env: Env): Promise<Response> {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const header = req.headers.get("authorization") ?? "";
  const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
  const token = match && match[1] ? match[1] : null;
  const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
  if (claim === null) {
    console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14)
    return new Response(JSON.stringify({ error: "unauthorized" }), {
      status: 401,
      headers: { "content-type": "application/json" },
    });
  }
  try {
    await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code));
    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { "content-type": "application/json" },
    });
  } catch {
    // Telegram rejected the send (user blocked the bot, chat not found, rate
    // limit). Category only — never the chat id, never the error text (D-14).
    console.log("[location-fallback] failed: telegram-send");
    return new Response(JSON.stringify({ error: "unavailable" }), {
      status: 502,
      headers: { "content-type": "application/json" },
    });
  }
}

Imports to add at the top of bot/src/webhook.ts: getClaimByToken joins the existing sessions.ts import; getPin comes from a new import { getPin } from "./kv/location-pin.ts"; requestLocationFallback and createBot come from the bot module (createBot from ./bot/bot.ts, requestLocationFallback from ./bot/handlers/location-fallback.ts); pickLocale joins the i18n/strings.ts import. Check for a circular-import hazard: bot/src/bot/bot.ts must not import from webhook.ts (it does not — createBot is the leaf dependency, so webhook.ts depends on it, not the reverse). Verify with npm run typecheck -w bot.

Update the file header comment's route list to document all four routes (/telegram/webhook, /api/session, /api/location, /api/location-fallback).

Step E — bot/test/location-pin.test.mjs (create). Copy bot/test/runner.mjs-style header, import { test } from ./harness.mjs, strict as assert from node:assert, and copy the makeKV() fake from bot/test/webhook.test.mjs verbatim. Cases:

test("LP1 writePin writes exactly one TTL-carrying key loc:<chat_id>", async () => {
  const kv = makeKV();
  await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
  assert.equal(kv.ttlLog.length, 1);
  assert.equal(kv.ttlLog[0].key, "loc:777");
  assert.strictEqual(kv.ttlLog[0].expirationTtl, 300);
  assert.ok(!kv.ttlLog[0].key.includes("user"), "pin key is chat-scoped, not user-id scoped");
});

test("LP2 getPin round-trips a valid record and returns null for absent", async () => {
  const kv = makeKV();
  assert.equal(await getPin(kv, 777), null);
  await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
  const pin = await getPin(kv, 777);
  assert.equal(pin.latitude, 3.1513);
  assert.equal(pin.longitude, 101.6958);
  assert.strictEqual(pin.horizontal_accuracy, 12);
});

test("LP3 getPin rejects out-of-range coordinates (null island guard)", async () => {
  const kv = makeKV();
  await kv.put("loc:1", JSON.stringify({ latitude: 999, longitude: 101, horizontal_accuracy: null }), { expirationTtl: 300 });
  assert.equal(await getPin(kv, 1), null, "absurd latitude must be rejected on read");
  await kv.put("loc:2", JSON.stringify({ latitude: 3, longitude: -999, horizontal_accuracy: null }), { expirationTtl: 300 });
  assert.equal(await getPin(kv, 2), null, "absurd longitude must be rejected on read");
});

test("LP4 getPin returns null on malformed JSON (defensive read)", async () => {
  const kv = makeKV();
  await kv.put("loc:3", "{not json", { expirationTtl: 300 });
  assert.equal(await getPin(kv, 3), null);
});

test("LP5 GET /api/location rejects a missing bearer token with 401", async () => {
  const kv = makeKV();
  const req = new Request("https://host/api/location", { method: "GET" });
  const res = await handler(req, makeEnv(kv));
  assert.strictEqual(res.status, 401);
  const body = await res.json();
  assert.strictEqual(body.error, "unauthorized");
});

test("LP6 GET /api/location rejects an unknown token with 401", async () => {
  const kv = makeKV();
  const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: "Bearer deadbeef-1234" } });
  const res = await handler(req, makeEnv(kv));
  assert.strictEqual(res.status, 401);
});

test("LP7 GET /api/location returns {pin:null} for a valid token with no pin yet", async () => {
  const kv = makeKV();
  await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
  const token = await getTokenForChat(kv, 777);
  const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token}` } });
  const res = await handler(req, makeEnv(kv));
  assert.strictEqual(res.status, 200);
  const body = await res.json();
  assert.strictEqual(body.pin, null);
});

test("LP8 GET /api/location returns the pin for the token's OWN chat only", async () => {
  const kv = makeKV();
  await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
  await mintSession(kv, { user_id: 99, chat_id: 888, issued_at: 1700000000, language_code: "en" });
  const token777 = await getTokenForChat(kv, 777);
  const token888 = await getTokenForChat(kv, 888);
  await writePin(kv, 888, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 });
  const reqA = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token777}` } });
  const bodyA = await (await handler(reqA, makeEnv(kv))).json();
  assert.strictEqual(bodyA.pin, null, "user 42's token must NOT read user 99's pin (V4 access control)");
  const reqB = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token888}` } });
  const bodyB = await (await handler(reqB, makeEnv(kv))).json();
  assert.strictEqual(bodyB.pin.latitude, 3.1513);
});

test("LP9 GET /api/location with a non-GET method returns 405", async () => {
  const res = await handler(new Request("https://host/api/location", { method: "POST" }), makeEnv(makeKV()));
  assert.strictEqual(res.status, 405);
});

test("LP10 POST /api/location-fallback with a missing bearer token returns 401", async () => {
  const res = await handler(new Request("https://host/api/location-fallback", { method: "POST" }), makeEnv(makeKV()));
  assert.strictEqual(res.status, 401);
  assert.strictEqual((await res.json()).error, "unauthorized");
});

test("LP11 POST /api/location-fallback with an unknown token returns 401", async () => {
  const req = new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: "Bearer deadbeef-1234" } });
  assert.strictEqual((await handler(req, makeEnv(makeKV()))).status, 401);
});

test("LP12 POST /api/location-fallback sends the location keyboard to the token's OWN chat", async () => {
  const kv = makeKV();
  await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
  const token = await getTokenForChat(kv, 777);
  const calls = [];
  const realFetch = globalThis.fetch;
  globalThis.fetch = async (_url, init) => {
    calls.push(JSON.parse(String(init.body)));
    return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } });
  };
  try {
    const res = await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${token}` } }), makeEnv(kv));
    assert.strictEqual(res.status, 200);
    assert.strictEqual(calls.length, 1);
    assert.strictEqual(calls[0].chat_id, 777, "keyboard must go to the CLAIM's chat, never a client-supplied id (V4)");
    assert.strictEqual(calls[0].reply_markup.keyboard[0][0].request_location, true);
  } finally { globalThis.fetch = realFetch; }
});

test("LP13 POST /api/location-fallback honours claim.language_code", async () => {
  const kv = makeKV();
  await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
  await mintSession(kv, { user_id: 7, chat_id: 555, issued_at: 1700000000, language_code: "en" });
  const calls = [];
  const realFetch = globalThis.fetch;
  globalThis.fetch = async (_url, init) => { calls.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } }); };
  try {
    const tokMs = await getTokenForChat(kv, 777);
    await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokMs}` } }), makeEnv(kv));
    const tokEn = await getTokenForChat(kv, 555);
    await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokEn}` } }), makeEnv(kv));
  } finally { globalThis.fetch = realFetch; }
  assert.strictEqual(calls[0].text, strings.ms.awaitingPin, "ms claim must receive ms copy");
  assert.strictEqual(calls[1].text, strings.en.awaitingPin, "en claim must receive en copy");
});

test("LP14 POST /api/location-fallback with a non-POST method returns 405", async () => {
  assert.strictEqual((await handler(new Request("https://host/api/location-fallback", { method: "GET" }), makeEnv(makeKV()))).status, 405);
});

Imports needed at the top of the test file:

const { writePin, getPin } = await import("../src/kv/location-pin.ts");
const { mintSession, getTokenForChat } = await import("../src/kv/sessions.ts");
const { strings } = await import("../src/i18n/strings.ts");
const handler = await import("../src/webhook.ts").then((m) => m.default.fetch);

Copy makeEnv from bot/test/webhook.test.mjs verbatim: function makeEnv(kv) { return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; } with const TEST_TOKEN = "12345:TESTTOKEN"; at module top.

LP12/LP13 observe the outgoing Telegram call by stubbing globalThis.fetch (grammY's default client calls through it) for the duration of the case, returning { ok: true, result: { message_id: 1 } }; restore it in a finally. cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -20 && npm run typecheck -w bot <acceptance_criteria> - npm test -w bot exits 0 and reports 14 passed for the LP prefix (LP1–LP14) - npm run typecheck -w bot exits 0 - grep -v '^ \*\|^/\*\|^$' bot/src/bot/handlers/location-fallback.ts | grep -c 'request_location: true' returns 1 - grep -q 'registerLocationFallback(bot, env.chat_sessions)' bot/src/bot/bot.ts exits 0 - grep -q 'url.pathname === "/api/location"' bot/src/webhook.ts exits 0 - grep -q 'url.pathname === "/api/location-fallback"' bot/src/webhook.ts exits 0 (the app→bot keyboard trigger exists — requestLocationFallback is NOT dead code) - grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'getClaimByToken(env.chat_sessions' | head -1 returns 2 (both routes resolve chat_id from the CLAIM, never client input) - grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code))' | head -1 returns 1 (locale from the claim, never from the request body) - grep -v '^ \*\|^/\*\|^$' bot/src/bot/bot.ts | grep -c 'opts.baseFetch' | head -1 returns >= 1 (the test transport seam exists; production calls createBot(env) with one arg) - grep -q 'commandSources = \["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"\]' bot/test/commands.test.mjs exits 0 (CS4 scope unchanged — handlers/ not scanned) - grep -c 'awaitingPin' bot/src/i18n/strings.ts returns 3 (interface + en + ms) </acceptance_criteria> The bot can request a shared pin and record it in TTL-only KV, and BOTH server routes the Mini App needs exist and are proven: GET /api/location (claim-scoped relay) and POST /api/location-fallback (claim-scoped keyboard trigger with locale from the claim). 14 LP tests cover the TTL contract, range guard, cross-user isolation on both routes, and the en/ms locale split.

<threat_model>

Trust Boundaries

Boundary Description
Telegram client → Worker webhook Untrusted update payloads arrive; the secret-token compare is the gate (Phase 2).
Mini App (browser) → GET /api/location Bearer session token in a header; the KV key is derived from the resolved claim.
Mini App (browser) → POST /api/location-fallback Bearer session token in a header; the target chat_id and locale are derived from the resolved claim, never from the request body.
Bot update → KV pin write A message's location payload becomes a stored coordinate; range validation gates it.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-03-01 Information Disclosure GET /api/location — another user's pin read mitigate Resolve Authorization: Bearer <token> through getClaimByToken(env.chat_sessions, token); derive the KV key loc:<chat_id> from the claim, never from a request parameter. Missing/expired token → 401 {error:"unauthorized"}. Pinned by test LP8 (user 42's token cannot read user 99's pin).
T-03-02 Information Disclosure Coordinate persisted beyond TTL mitigate Every .put( in location-pin.ts carries expirationTtl: PIN_TTL_SECONDS (300) on the same line; privacy.test.mjs PG1 gate re-scans the module automatically after the storageModules edit. No profile/history/trip store exists (PG2).
T-03-03 Tampering Malicious webhook update injecting a fabricated pin mitigate Only Telegram-authenticated updates reach grammY — the X-Telegram-Bot-Api-Secret-Token constant-time compare runs BEFORE any bot processing (Phase 2, WR-02). The handler filters on ctx.message?.location only.
T-03-04 Tampering / DoS Out-of-range coordinates in a shared pin mitigate getPin rejects |latitude| > 90 or |longitude| > 180 and non-numeric values on read (V5 Input Validation), so a corrupt record can never fly the client camera to null island. Test LP3.
T-03-05 Information Disclosure Category-only logging violated mitigate The two 401 paths log the literal string "[location] rejected: unauthorized" — never the token, the chat id, or any coordinate.
T-03-06 Repudiation A chat pin served long after it was shared mitigate 300s TTL bounds exposure; clearPin exists for an explicit sweep and the Mini App deletes its copy from memory as soon as it consumes the pin.
T-03-26 Elevation of Privilege One user's session token pushing a location keyboard into ANOTHER user's chat via POST /api/location-fallback mitigate The handler resolves the token through getClaimByToken and passes claim.chat_id to requestLocationFallback; the request body is never read, so there is no client-supplied chat id to tamper with. Missing/expired token → 401. Pinned by test LP10/LP11 (401) and LP12 (the send goes to the claim's chat, asserting the sendMessage body's chat_id).
T-03-27 Spoofing Client-supplied language overriding the claim's locale mitigate pickLocale(claim.language_code) reads only the server-recorded claim value that mintSession captured from the HMAC-validated initData. The handler accepts no lang/locale field from the request. Pinned by test LP13 (ms claim → ms copy, en claim → en copy).
T-03-28 Denial of Service Unauthenticated flood of the keyboard trigger mitigate The route is POST-only (405 otherwise) and every request that reaches requestLocationFallback has already resolved a live, unexpired session claim via getClaimByToken, so an anonymous flood cannot reach Telegram's API at all. A Telegram-side failure (blocked bot, chat not found, rate limit) is caught and returned as a category-only 502 — the error text is never surfaced to the client or the log.
</threat_model>
- `npm test -w bot` green (all Phase-2 cases plus the 14 new LP cases) - `npm run typecheck -w bot` clean — in particular, no circular import between `webhook.ts` and `bot/bot.ts` - `npm test -w web` green (empty suite passes — later plans add cases) - `npm install` resolves the `web` workspace with no peer conflicts

<success_criteria>

  • GEO-02 server half complete in BOTH directions: the bot requests a pin via reply keyboard and records it TTL-only, the app can trigger the keyboard via POST /api/location-fallback, and it can poll the result via GET /api/location — both routes claim-scoped
  • GEO-03 server half complete: the only coordinate store is TTL-expiring KV, covered by an automated same-line TTL gate
  • The web/ workspace exists with the repo-convention hand-rolled test runner, so every later plan has a place to put tests </success_criteria>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-01-SUMMARY.md`