diff --git a/bot/src/bot/bot.ts b/bot/src/bot/bot.ts index ab878f4..76ae404 100644 --- a/bot/src/bot/bot.ts +++ b/bot/src/bot/bot.ts @@ -10,17 +10,28 @@ * text replies only — no reply menus, no inline button payloads, no * callback routes, no menu augmentation per request. The persistent menu * is registered once by scripts/register-commands.mjs (D-03). + * + * GEO-02 (structural): createBot threads env.chat_sessions into the + * location-fallback handler (handlers/location-fallback.ts) — the ONE + * deliberate exception to the D-02 text-only rule (its Telegram send + * attaches a reply-markup location request). Also: requestLocationFallback + * has exactly ONE caller — handleLocationFallback in src/webhook.ts. If a + * second caller ever appears, that is a design change, not a refactor. */ import { Bot } from "grammy"; import type { Env } from "../config.ts"; +import { registerLocationFallback } from "./handlers/location-fallback.ts"; import { registerHelp } from "./commands/help.ts"; import { registerPrivacy } from "./commands/privacy.ts"; import { registerStart } from "./commands/start.ts"; -export function createBot(env: Env): Bot { - const bot = new Bot(env.BOT_TOKEN); +export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot { + const bot = opts.baseFetch + ? new Bot(env.BOT_TOKEN, { client: { fetch: opts.baseFetch } }) + : new Bot(env.BOT_TOKEN); registerStart(bot); // /start + /mula (D-04) registerHelp(bot); // /help registerPrivacy(bot); // /privacy + /privasi (D-04) + registerLocationFallback(bot, env.chat_sessions); // GEO-02 location reply fallback return bot; } diff --git a/bot/src/bot/handlers/location-fallback.ts b/bot/src/bot/handlers/location-fallback.ts new file mode 100644 index 0000000..c10e59f --- /dev/null +++ b/bot/src/bot/handlers/location-fallback.ts @@ -0,0 +1,72 @@ +/* + * 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 + * reply 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 in the + * plan. + * + * All copy comes from src/i18n/strings.ts — nothing inline here. + */ +import type { Bot, Context } from "grammy"; +import { pickLocale, strings } from "../../i18n/strings.ts"; +import type { Locale } from "../../i18n/strings.ts"; +import { writePin } from "../../kv/location-pin.ts"; +import type { LocationPinKv } from "../../kv/location-pin.ts"; + +/** Send the awaiting-pin prompt + location keyboard (GEO-02). + * + * grammY note: the plan's original shape called `bot.chat.sendMessage`, + * but grammY 1.46 exposes no `bot.chat` proxy — `bot.api.sendMessage` is + * the same request over the same API surface. The Mini App's + * POST /api/location-fallback route (webhook.ts) is the ONLY caller. */ +export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise { + await bot.api.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 { + 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 + * — Telegram-authenticated updates only (T-03-03), so a fabricated pin + * can never enter KV. */ +export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void { + bot.on("message", async (ctx: Context) => { + const location = ctx.message?.location; + if (!location) return; + const chatId = ctx.chat?.id; + if (chatId === undefined) return; + await recordSharedPin(kv, chatId, location); + const locale = pickLocale(ctx.from?.language_code); + await ctx.reply(strings[locale].pinReceived, { link_preview_options: { is_disabled: true } }); + }); +} diff --git a/bot/src/i18n/strings.ts b/bot/src/i18n/strings.ts index ff9790d..1f19434 100644 --- a/bot/src/i18n/strings.ts +++ b/bot/src/i18n/strings.ts @@ -28,6 +28,13 @@ export interface BotStrings { /** D-03: lists only commands that exist in this phase — derived lines * ("cmd — description") feed the setMyCommands registration script. */ helpMenu: string; + /** 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; + /** GEO-02: confirmation after the user's shared pin has been recorded + * in the TTL-only location-pin KV (GEO-03). */ + pinReceived: string; } export type Locale = "en" | "ms"; @@ -42,6 +49,9 @@ export const strings: Record = { "What araharah never keeps: your location, your routes, your trip history, any profile about you.", helpMenu: "Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores", + awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.", + shareLocationButton: "Share my location", + pinReceived: "Got it — your starting point is set.", }, ms: { startIntro: @@ -52,6 +62,9 @@ export const strings: Record = { "Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda.", helpMenu: "Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan", + awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.", + shareLocationButton: "Kongsi lokasi saya", + pinReceived: "Baik — titik mula anda sudah ditetapkan.", }, }; diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts index 3345966..c416069 100644 --- a/bot/src/webhook.ts +++ b/bot/src/webhook.ts @@ -21,6 +21,18 @@ * own invariant (D-07 — best-effort under KV eventual consistency, see * sessions.ts) — this endpoint adds no extra branch and no extra write * path (T-02-06). + * GET /api/location → GEO-02 relay (T-03-01): the Mini App polls + * here with its memory-held Bearer token; the token resolves through + * getClaimByToken and the KV key loc: derives from the + * CLAIM — never from a client-supplied id — so one user's token can + * never read another user's pin. No pin or expired token → honest + * nulls / 401, nothing fabricated. + * POST /api/location-fallback → GEO-02 keyboard trigger (T-03-26): the + * app's denied card asks the BOT to send the request_location reply + * keyboard into the user's own chat. The target chat_id AND the + * locale come from the resolved claim (T-03-27) — the request body + * is never read, and no chat id or language is accepted from the + * client. A rejected Telegram send is a category-only 502 (T-03-28). * Anything else → 404. * * D-14: the ONLY log statements are category-only — malformed/tampered/stale @@ -33,8 +45,11 @@ import { webhookCallback } from "grammy"; import type { Env } from "./config.ts"; import { createBot } from "./bot/bot.ts"; -import { mintSession } from "./kv/sessions.ts"; +import { requestLocationFallback } from "./bot/handlers/location-fallback.ts"; +import { mintSession, getClaimByToken } from "./kv/sessions.ts"; +import { getPin } from "./kv/location-pin.ts"; import { validateInitData } from "./security/initdata.ts"; +import { pickLocale } from "./i18n/strings.ts"; export default { async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { @@ -58,6 +73,12 @@ export default { if (url.pathname === "/api/session") { return handleSession(req, env); } + if (url.pathname === "/api/location") { + return handleLocation(req, env); + } + if (url.pathname === "/api/location-fallback") { + return handleLocationFallback(req, env); + } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; @@ -112,3 +133,70 @@ async function handleSession(req: Request, env: Env): Promise { }); return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } }); } + +/** GEO-02 relay (T-03-01): 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 { + 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-]+)$/); + const token = match !== null && match[1] ? match[1] : null; + const claim = token === null ? null : 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); + return new Response(JSON.stringify({ pin }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** GEO-02 trigger (T-03-26): the Mini App's denied card taps ASK VIA CHAT + * and POSTs here; the Worker asks the BOT to send the request_location + * 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 + * (T-03-27). */ +async function handleLocationFallback(req: Request, env: Env): Promise { + 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 !== null && 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, T-03-28). + console.log("[location-fallback] failed: telegram-send"); + return new Response(JSON.stringify({ error: "unavailable" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/bot/test/location-pin.test.mjs b/bot/test/location-pin.test.mjs new file mode 100644 index 0000000..90c3714 --- /dev/null +++ b/bot/test/location-pin.test.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/* + * location-pin.test.mjs — the TTL-only location-pin KV layer + both + * Worker routes that relay a chat-shared pin to the Mini App (GEO-02/03). + * ---------------------------------------------------------------------- + * Cases: + * LP1–LP4 kv/location-pin.ts: single TTL-carrying chat-scoped write, + * round-trip, range guard (null-island), malformed-JSON + * defensive read + * LP5–LP9 GET /api/location: 401s (missing/unknown token), honest + * {pin:null}, CLAIM-scoped pin read (V4 cross-user + * isolation), 405 + * LP10–LP14 POST /api/location-fallback: 401s (missing/unknown + * token), request_location keyboard to the CLAIM's chat, + * locale from claim.language_code, 405 + * + * Transport note: grammY's node shim sends through node-fetch@2 (its own + * require cache), NOT globalThis.fetch — LP12/LP13 patch node-fetch's + * cached `default` export (the object grammy's shim getter reads at every + * Bot construction) for the duration of the case, then restore it. + */ +import { strict as assert } from "node:assert"; +import { createRequire } from "node:module"; +import { test } from "./harness.mjs"; + +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); + +const TEST_TOKEN = "12345:TESTTOKEN"; + +/* ————— env / KV fake (verbatim from webhook.test.mjs) and transport stub ————— */ + +function makeKV() { + const map = new Map(); + const ttlLog = []; + return { + async get(key) { + return map.has(key) ? map.get(key) : null; + }, + async put(key, value, opts) { + ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null }); + map.set(key, value); + }, + async delete(key) { + map.delete(key); + }, + map, + ttlLog, + }; +} + +function makeEnv(kv) { + return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; +} + +const require_ = createRequire(import.meta.url); +/** node-fetch's cached module.exports — grammy's node shim reads + * `default` off this same object at every Bot construction, so patching + * it suspends the outbound Telegram call for the duration of a case. */ +const nodeFetchExports = require_("node-fetch"); + +/** Swap in a capturing stand-in for the Telegram sender; returns a + * restore function (run in `finally`). */ +function stubTelegramSender(calls) { + const previous = nodeFetchExports.default; + nodeFetchExports.default = 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" }, + }); + }; + return () => { + nodeFetchExports.default = previous; + }; +} + +/* ————— LP1–LP4 — kv/location-pin.ts ————— */ + +test("LP1 writePin writes exactly one TTL-carrying key loc:", 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); +}); + +/* ————— LP5–LP9 — GET /api/location (the poll relay) ————— */ + +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); +}); + +/* ————— LP10–LP14 — POST /api/location-fallback (the keyboard trigger) ————— */ + +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 restore = stubTelegramSender(calls); + 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 { + restore(); + } +}); + +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 restore = stubTelegramSender(calls); + 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 { + restore(); + } + 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); +});