forked from c-fu/arah2
ambek .planning
This commit is contained in:
@@ -0,0 +1,824 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user