---
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"
---
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`.
@$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.
@AGENTS.md
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
@bot/src/webhook.ts
@bot/src/kv/sessions.ts
@bot/src/bot/bot.ts
@bot/src/bot/commands/start.ts
@bot/src/i18n/strings.ts
@bot/src/config.ts
@bot/test/privacy.test.mjs
@bot/test/commands.test.mjs
@bot/test/webhook.test.mjs
@bot/test/runner.mjs
@bot/test/harness.mjs
@bot/package.json
@bot/tsconfig.json
@package.json
@theme/package.json
From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
```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;
put(key: string, value: string, options: { expirationTtl: number }): Promise;
delete(key: string): Promise;
}
export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise;
export async function getClaimByToken(kv: MintSessionKv, token: string): PromiseSessionClaim | null;
export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise;
```
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 {
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;
```
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}`;
}
```
task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14)
web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json
- `package.json` (root — see current workspaces array and bot:* delegate script block)
- `bot/package.json` (the workspace manifest shape to mirror exactly)
- `bot/tsconfig.json` (the strictness flags to preserve)
- `bot/test/runner.mjs` and `bot/test/harness.mjs` (copy verbatim)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Standard Stack (pinned versions)
**Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
```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
araharah
```
Do NOT add a `