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

824 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 03-mini-app-shell-geolocation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements: [GEO-02, GEO-03]
must_haves:
decisions_implemented:
- "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"
truths:
- "`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"
artifacts:
- path: "web/package.json"
provides: "@araharah/web workspace manifest with test/typecheck/dev/build scripts"
- path: "web/tsconfig.json"
provides: "DOM-targeted strict TS config mirroring bot/tsconfig.json flags (+ resolveJsonModule for the basemap style import)"
- path: "web/test/runner.mjs"
provides: "hand-rolled zero-dependency test runner (copied from bot/test/runner.mjs)"
- path: "web/test/harness.mjs"
provides: "zero-dependency test registry (copied from bot/test/harness.mjs)"
- path: "bot/src/kv/location-pin.ts"
provides: "TTL-only KV location-pin layer (put/get/delete by chat id)"
- path: "bot/src/bot/handlers/location-fallback.ts"
provides: "GEO-02 keyboard sender + message.location receiver"
- path: "bot/src/webhook.ts"
provides: "GET /api/location relay + POST /api/location-fallback keyboard trigger, both Bearer-gated"
key_links:
- from: "bot/src/webhook.ts"
to: "bot/src/kv/sessions.ts"
via: "getClaimByToken(env.chat_sessions, token) — derives chat_id from the CLAIM, never from a client-supplied id"
pattern: "getClaimByToken"
- from: "bot/src/webhook.ts"
to: "bot/src/bot/handlers/location-fallback.ts"
via: "handleLocationFallback calls requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)) — the ONLY caller, so the export is never dead code"
pattern: "requestLocationFallback"
- from: "bot/src/bot/handlers/location-fallback.ts"
to: "bot/src/kv/location-pin.ts"
via: "writePin(kv, chatId, {latitude, longitude}) after message.location arrives"
pattern: "writePin"
---
<objective>
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`.
</objective>
<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>
<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
<interfaces>
From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
```typescript
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`:
```typescript
export interface Env {
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
chat_sessions: KVNamespace;
}
```
From `bot/src/webhook.ts` — the dispatch shape to extend:
```typescript
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):
```javascript
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}`;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14)</name>
<files>web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json</files>
<read_first>
- `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)
</read_first>
<action>
**Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
```json
{
"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`.**
```typescript
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):
```html
<!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):
```json
"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).
</action>
<verify>
<automated>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</automated>
</verify>
<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>
<done>web/ workspace is installed, typecheckable, testable, and wired into root delegates; test runner matches repo convention verbatim.</done>
</task>
<task type="auto">
<name>task 2: TTL-only location-pin KV module + privacy gate extension (GEO-03, PRIV-02)</name>
<files>bot/src/kv/location-pin.ts, bot/test/privacy.test.mjs</files>
<read_first>
- `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)
</read_first>
<action>
**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:
```typescript
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):
```typescript
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:
```javascript
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
```
to:
```javascript
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).
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -5 && grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs</automated>
</verify>
<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>
<done>location-pin.ts is TTL-only by construction, range-validated on read, and covered by the existing privacy gate.</done>
</task>
<task type="auto">
<name>task 3: GEO-02 bot half — chat keyboard fallback + `GET /api/location` route + tests</name>
<files>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</files>
<read_first>
- `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)
</read_first>
<action>
**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.
```typescript
/*
* 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`):
```typescript
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):
```typescript
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):
```typescript
/** 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:
```typescript
/** 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:
```javascript
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:
```javascript
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`. </action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -20 && npm run typecheck -w bot</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-01-SUMMARY.md`
</output>