--- phase: 03-mini-app-shell-geolocation plan: 04 type: execute wave: 4 depends_on: [03-01, 03-03] files_modified: - bot/wrangler.jsonc - bot/package.json - package.json - .gitignore - web/vite.config.ts - web/index.html - web/src/vite-env.d.ts - web/scripts/capture.mjs - web/test/build-artifact.test.mjs - .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md autonomous: false requirements: [GEO-01, GEO-02, GEO-03, PX-02] must_haves: decisions_implemented: - "D-12: local dev topology is `wrangler dev` on 8787 serving web statics + `/api/*` + webhook with local KV (theme → web build chain)" - "D-15: deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets)" truths: - "One npm run deploy builds the theme, then the web app, then deploys the Worker with static assets attached" - "The Worker owns /api/* and /telegram/* FIRST; the asset binding serves everything else" - "web/dist contains the inlined style JSON, self-hosted fonts, and the MapLibre worker, with zero CDN references" - "web/dist NEVER contains the Mock-Telegram installer in ANY emitted file — HTML included; the harness lives in a separate web/dist-harness build that is never deployed" - "Playwright captures the shell through the Mock-Telegram harness off the HARNESS build: boot stages, light, dark, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side" - "A runtime probe after boot-to-live asserts localStorage, sessionStorage, cookies, and IndexedDB are all empty (GEO-03 spot-check)" - "The Android, iOS, Desktop, plain-browser, and mid-session theme-flip matrix is verified on real devices before the phase closes" artifacts: - path: "bot/wrangler.jsonc" provides: "assets binding + run_worker_first glob array (D-13)" - path: "web/vite.config.ts" provides: "dual-build config: __ARAHARAH_HARNESS__ define + dist / dist-harness outDirs" - path: "web/scripts/capture.mjs" provides: "Playwright capture harness extending theme/scripts/screenshot.mjs, serving web/dist-harness (D-11)" - path: "web/test/build-artifact.test.mjs" provides: "D-14 build-artifact gate: no CDN refs, style JSON inlined, fonts copied, harness absent from web/dist" - path: ".planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md" provides: "the device-matrix exit-gate checklist and result record (Success Criterion 5)" key_links: - from: "bot/wrangler.jsonc" to: "web/dist" via: "assets.directory points at ../web/dist and run_worker_first keeps /api/* on the Worker" pattern: "run_worker_first" - from: "web/scripts/capture.mjs" to: "web/dist-harness" via: "the docroot is the --mode harness build, because the mock installer and fetch stubs are stripped from any production-mode bundle" pattern: "dist-harness" - from: "web/scripts/capture.mjs" to: "web/src/main.ts" via: "races the RAMS-BASEMAP-READY and ARAHARAH-LIVE console signals" pattern: "ARAHARAH-LIVE" - from: "web/scripts/capture.mjs" to: "stitch_minimalist_route_navigator/navigate_map_overview/screen.png" via: "the DSGN-02 side-by-side fidelity artifact" pattern: "navigate_map_overview" --- Complete the one-deployable architecture (D-13/D-14/D-15): wire `web/dist` into the existing Worker via the `[assets]` binding, add the build-artifact gate proving nothing ships from a CDN, extend the Phase-1 Playwright toolchain into a capture harness that drives the Mock-Telegram runner and produces the DSGN-02 fidelity artifact plus the GEO-03 runtime probe — then run the blocking device-matrix exit gate (ROADMAP Success Criterion 5). Purpose: the phase cannot honestly close without proving the build is self-contained and the app works on real Android, iOS, Desktop, and plain browser. Real-device availability is an open question (RESEARCH section "Environment Availability" lists it as blocking with no fallback), so this plan carries the blocking human-verification checkpoint rather than deferring the discovery. Output: `bot/wrangler.jsonc` `[assets]` block; root one-command build/deploy; `web/vite.config.ts` dual-build config; `web/index.html` harness profile; `web/src/vite-env.d.ts`; `web/scripts/capture.mjs`; `web/test/build-artifact.test.mjs`; `03-DEVICE-MATRIX.md`. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @AGENTS.md @.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md @.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md @.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md @theme/scripts/screenshot.mjs @theme/package.json @bot/wrangler.jsonc @bot/package.json @package.json @web/index.html @web/src/main.ts @web/src/mock/telegram-mock.ts Cloudflare Workers static assets binding (RESEARCH cites developers.cloudflare.com/workers/static-assets/binding): ```jsonc { "name": "araharah-bot", "main": "src/webhook.ts", "compatibility_date": "2026-10-22", "workers_dev": true, "assets": { "directory": "../web/dist", "binding": "ASSETS", "not_found_handling": "none" }, "run_worker_first": ["/api/*", "/telegram/*"], "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } ``` `run_worker_first` is a GLOB ARRAY (max 100 entries), not a boolean. The Worker still owns `/api/*` and `/telegram/*` first (D-13); the asset binding serves everything else. `theme/scripts/screenshot.mjs` (194 lines) is the established pattern to extend, not rewrite: - `MIN_BYTES = 10240` blank bar - `serveTheme()` static server with the WR-05 docroot guard (`target.startsWith(THEME + sep)`) - READY-signal race: `page.on('console', m => m.text().startsWith('RAMS-BASEMAP-READY'))` raced against a 30s timeout - Report plus `process.exit(1)` on FAILED / MISSING / UNDERSIZED For `web/`, the docroot is `web/dist` and the wait signal is `ARAHARAH-LIVE` (main.ts emits it). Playwright is already `theme/`'s dependency at `^1.50.0` with chromium/webkit/firefox cached. task 1: Worker `[assets]` binding + one-command deploy + D-14 build-artifact gate bot/wrangler.jsonc, bot/package.json, package.json, web/vite.config.ts, web/test/build-artifact.test.mjs, .gitignore - `bot/wrangler.jsonc` (the current shape — keep name, main, compatibility_date, workers_dev, kv_namespaces byte-identical) - `bot/package.json` (the `dev` script this task pins to port 8787) - `package.json` (root — the existing delegate-script block and where `deploy` and `dev` go) - `web/vite.config.ts` (written in plan 03-01 task 1 — extend it, do not rewrite) - `.gitignore` (confirm the `dist/` entry covers BOTH `theme/dist/` and `web/dist/`; REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED) **`bot/wrangler.jsonc`.** Add the `assets` block and the `run_worker_first` glob array, preserving every existing key: ```jsonc { "name": "araharah-bot", "main": "src/webhook.ts", "compatibility_date": "2026-10-22", "workers_dev": true, // Workers static assets binding (D-13, D-14): serves ../web/dist. The // Worker fetch still owns /api/* and /telegram/* first via // run_worker_first (a GLOB ARRAY, not a boolean). "assets": { "directory": "../web/dist", "binding": "ASSETS", "not_found_handling": "none" }, "run_worker_first": ["/api/*", "/telegram/*"], "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } ``` **Root `package.json`.** Add the D-15 one-command deploy, which builds the theme first, then the web app, then runs the bot's existing deploy (`wrangler deploy && node scripts/set-webhook.mjs`): ```json "build": "npm run build -w theme && npm run build -w web", "deploy": "npm run build && npm run deploy -w bot" ``` **The `theme` build step is REQUIRED, not cosmetic.** `theme/dist/` is gitignored generated output (RESEARCH / `.gitignore`), and `web/src/shell.css` imports `@araharah/theme/dist/rams.css` at build time. On a fresh clone, `npm run build -w web` alone fails to resolve that specifier. Chaining `theme` → `web` makes every build script in the repo — root `build`, root `deploy`, root `dev` — correct on a fresh clone with no extra instructions. Update the existing `web:build` delegate to `npm run build -w theme && npm run build -w web` for the same reason. Keep the existing `bot:deploy` delegate as the low-level entry point. **`web/vite.config.ts` — the dual-build config (the config half of the capture-harness fix in task 2).** The capture harness must serve a bundle that still contains the Mock-Telegram installer, while the production bundle must not. A single `import.meta.env.DEV` guard cannot express both, because `import.meta.env.DEV` is statically `false` in ANY production-mode build — including one served to a local capture server. So the harness gets its own mode with its own `outDir` and its own injected constant: ```typescript import { defineConfig } from "vite"; export default defineConfig(({ mode }) => { const isHarness = mode === "harness"; return { base: "/", define: { // Vite replaces this IDENTIFIER textually at build time, so the // harness bundle compiles `if (VITE_HARNESS) installMockWebApp()` // down to `if (true) …`. The production build never defines it, // so the identifier is unreferenced and the branch is tree-shaken. __ARAHARAH_HARNESS__: JSON.stringify(isHarness), }, build: { outDir: isHarness ? "dist-harness" : "dist", emptyOutDir: true, target: "es2022", }, server: { port: 5173, strictPort: true }, }; }); ``` Add `"build:harness": "vite build --mode harness"` to `web/package.json` and a root delegate `"web:build:harness": "npm run build:harness -w web"`. In `web/src/main.ts`, the mock-install guard becomes `if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();` — declare the constant once in a `web/src/vite-env.d.ts` (`declare const __ARAHARAH_HARNESS__: boolean;`) so typecheck passes in both modes. **This guard lives ONLY in `main.ts`** — the runner page uses a separate, whole-block `if (__ARAHARAH_HARNESS__) { … }` wrapper (task 2), because it must also gate the `fetch` stubs and must NOT be combined with `import.meta.env.DEV` or `!window.__ARAHARAH_MOCK__` (either would disable the harness's own profile in `vite dev`). Together the two guards are what make the capture race reachable: in the served `web/dist-harness/`, `installMockWebApp` is present; in `web/dist/`, it is absent and BA5 holds. **`.gitignore`.** Verify the existing `dist/` entry covers `theme/dist/` AND `web/dist/`. If it is root-anchored (`/dist/`), change it to `dist/` (any depth) or add explicit `theme/dist/` and `web/dist/` lines. REGENERATED BUILD OUTPUT MUST NEVER BE COMMITTED — including `theme/dist/`, which the web build depends on but must not vendor. **D-12 — local dev topology.** The `[assets]` binding makes `wrangler dev` serve the app, the API, and the webhook from one origin on port 8787 with local KV simulation, which is exactly D-12's topology. Verify it and record the command in the root delegates and in `bot/package.json`: - Change `bot/package.json`'s `dev` script from `"wrangler dev"` to `"wrangler dev --port 8787 --local"` so the port is pinned (8787 is the number D-12 names) and KV runs in local-simulation mode rather than against the remote namespace. - Add a root delegate `"dev": "npm run build && npm run dev -w bot"` so the daily loop is: build the theme + web app once, then one command brings up statics + `/api/*` + webhook + local KV at `http://localhost:8787`. - Record in `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` (created in task 3) that `localhost:8787` is a secure context, which is why `navigator.geolocation` works under `wrangler dev` while a LAN-IP dev URL would not (RESEARCH assumption A3). Remote staging is used sparingly, immediately before a real-device verification session, for exactly that reason. **Create `web/test/build-artifact.test.mjs`.** It reads `web/dist` (produced by `npm run build -w web`, which this task's verify runs first). If `web/dist` does not exist, every case fails with a message telling the executor to run the build first. Cases: ``` BA1 no emitted .js or .css file references cdn.tailwindcss.com, fonts.googleapis.com, fonts.gstatic.com, unpkg.com, or cdn.jsdelivr.net (D-14: no CDN fallbacks) BA2 web/dist contains at least one .woff2 file (self-hosted fonts copied at build time) CONTINGENCY — if this fails, the fonts were not carried through by Vite's `url()` resolution of the imported `@araharah/theme/dist/rams.css`. Add an explicit copy step rather than leaving BA2 red: either (a) copy `theme/dist/fonts/` into `web/public/fonts/` so Vite emits them verbatim, or (b) add a `postbuild` copy script (`node scripts/copy-fonts.mjs`) that mirrors `theme/dist/fonts/*.woff2` into `web/dist/fonts/`. Both are one-time fixes; re-run `npm run build -w theme && npm run build -w web` and re-check before moving on. Do NOT add a CDN `` fallback — BA1 would fail it. BA3 web/dist/assets contains a hashed MapLibre worker chunk (filename matching /worker/i) BA4 the built JS bundle carries the inlined Rams style — assert the string "liberty-rams" OR "ne2_shaded" appears in the concatenated JS (both are unique to liberty-rams.json's source/layer names), proving the style was inlined at build rather than fetched at runtime BA5 the PRODUCTION bundle does not contain "installMockWebApp" or "__ARAHARAH_MOCK__" — Vite's `define` renders `if (__ARAHARAH_HARNESS__)` as `if (false)`, so the runner page's profile block in web/index.html is dead-code-eliminated and the main.ts DEV guard is tree-shaken. Scan ALL of web/dist — BOTH `web/dist/assets/*.js` AND every emitted `web/dist/**/*.html` (web/index.html is the ONE entry shared by both builds, so an unguarded profile block would ship the mock and the fetch stubs as inline HTML that the JS-only scan would miss entirely). Scan `web/dist/` ONLY: the harness build emits to a SEPARATE outDir (`web/dist-harness/`, plan task 2), so this gate can never see harness code. Do NOT point BA5 at `web/dist-harness/` — the whole point is that the shipped bundle is clean while the harness bundle deliberately is not. BA6 the PRODUCTION bundle does not contain "/api/session" or "/api/location-fallback" inside any emitted HTML (the runner page's fetch stubs are stripped with the same dead-code elimination; a hit means an unguarded profile block shipped) ``` **Two-build directory contract (load-bearing — the builds must NEVER share an outDir):** ``` web/dist/ ← production build (npm run build -w web) — the ONLY thing BA1–BA5 scan web/dist-harness/ ← harness build (vite build --mode harness) — served ONLY by capture.mjs ``` `web/dist-harness/` is gitignored generated output like `web/dist/` and is NEVER deployed — the `[assets]` binding points at `../web/dist` only. Add `web/dist-harness/` to `.gitignore` in this task. Sharing one outDir would make the two builds clobber each other and make the BA5 gate meaningless. Implement BA1 by walking `web/dist` recursively with `readdirSync({withFileTypes: true})`, filtering `.js`/`.css`, and `assert.ok(!content.includes(host), ...)` for each host with the file path in the message. For BA4 read and concatenate every `web/dist/assets/*.js`. For BA5/BA6 walk the SAME tree and read every `.js` AND every `.html` file into two concatenated buffers (jsAssets, allHtml), then assert `installMockWebApp` and `__ARAHARAH_MOCK__` are absent from the combined buffer, and (BA6) that `/api/session` and `/api/location-fallback` are absent from `allHtml`. Put the offending file path in every failure message — a JS-only scan that passes vacuously because the mock survived as inline HTML is the exact regression BA5's extension exists to prevent. Copy the path-scaffolding style from `bot/test/privacy.test.mjs` lines 23-27. cd /home/C-Fu/araharah && npm run build -w theme 2>&1 | tail -3 && npm run build -w web 2>&1 | tail -5 ; npm test -w web 2>&1 | grep -E '^PASS BA|runner' ; node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error('assets wiring missing'); if(!c.includes('../web/dist')) throw new Error('assets.directory wrong'); console.log('wrangler OK')" ; node -e "const p=require('./package.json'); if(!p.scripts.deploy) throw new Error('root deploy missing'); if(!p.scripts.build) throw new Error('root build missing'); console.log('scripts OK')" - `npm run build -w theme && npm run build -w web` exits 0 - `npm test -w web` exits 0 and the runner output contains `PASS BA1` through `PASS BA6` - `node -e "const c=require('fs').readFileSync('bot/wrangler.jsonc','utf8'); if(!c.includes('run_worker_first')) throw new Error(1); if(!c.includes('../web/dist')) throw new Error(2); console.log('OK')"` exits 0 - `grep -q '"deploy": "npm run build && npm run deploy -w bot"' package.json` exits 0 - `grep -q '"build": "npm run build -w theme && npm run build -w web"' package.json` exits 0 (theme must build before web — `theme/dist/` is gitignored generated output, so a fresh clone cannot resolve `@araharah/theme/dist/rams.css` without this) - `grep -q '"dev": "npm run build && npm run dev -w bot"' package.json` exits 0 (D-12 one-command local loop) - `grep -q '\-\-port 8787 --local' bot/package.json` exits 0 (D-12 port pinned, KV in local-simulation mode) - `grep -q 'dist-harness' web/vite.config.ts` exits 0 AND `grep -q '__ARAHARAH_HARNESS__' web/vite.config.ts` exits 0 (the two builds have separate outDirs and an injected harness constant — the production `dist` can never contain harness code) - `grep -q '"build:harness": "vite build --mode harness"' web/package.json` exits 0 - `grep -q 'dist-harness' .gitignore` exits 0 (harness build output is never committed) - `cd bot && npx wrangler deploy --dry-run 2>&1 | tail -5` exits without a config-parse error — `wrangler deploy` DOES support `--dry-run` (it validates the bundle + config without uploading), which proves the `assets` and `run_worker_first` keys are accepted by wrangler 4.151.3. Note: `wrangler dev` has NO `--dry-run` flag; do not attempt it. - `grep -v '^ \*\|^/\*\|^$' web/dist/assets/*.js | grep -c 'cdn.tailwindcss.com\|fonts.googleapis.com\|unpkg.com\|cdn.jsdelivr.net' | head -1` returns 0 - `find web/dist -name '*.woff2' | wc -l` returns >= 1 (if 0, apply BA2's contingency — a `web/public/fonts/` copy or a `postbuild` font-copy script — never a CDN link) - **Both-direction harness isolation (T-03-21), asserted directly:** `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/dev/null | wc -l` returns 0 — including `web/dist/index.html`, not just the JS chunks — while `grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l` returns >= 1 - `grep -c 'api/session\|api/location-fallback' web/dist/index.html 2>/dev/null` returns 0 (the runner page's fetch stubs did not survive as inline production HTML) - `git check-ignore -q web/dist/index.html && echo IGNORED` prints IGNORED (build output never committed) The Worker serves the app from its own static-assets binding, deploy is one command, and six automated gates prove the shipped bundle is fully self-contained (D-14) and free of the Mock-Telegram installer in every emitted file, HTML included. task 2: Mock-Telegram runner page + Playwright capture harness (D-11, DSGN-02, GEO-03 runtime probe) web/index.html, web/scripts/capture.mjs, web/src/vite-env.d.ts - `theme/scripts/screenshot.mjs` (the entire file — extend, do not rewrite: MIN_BYTES, serveTheme, READY-signal race, report + exit code) - `web/vite.config.ts` (task 1 output — the `__ARAHARAH_HARNESS__` define and the `dist-harness` outDir this task serves) - `web/src/main.ts` (the `ARAHARAH-LIVE` console signal it emits, and its install guard `(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__`) - `web/src/mock/telegram-mock.ts` (the `installMockWebApp(options)` signature and `MockOptions` shape — add the `languageCode` option if absent) - `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Fidelity Artifact" (what the gallery row must show) **Why this task builds a SEPARATE bundle (this is the load-bearing design decision).** The obvious design — serve `web/dist` (the production build) and rely on `import.meta.env.DEV` to include the mock — CANNOT work. `import.meta.env.DEV` is statically `false` in every production-mode build, so the served `dist` contains neither `installMockWebApp` nor the harness `fetch` stubs. With no `window.Telegram`, `POST /api/session` 404s, `main.ts` renders the denied card, and the `ARAHARAH-LIVE` race in step 5 times out on every capture — the harness would silently never produce a live shot. Task 1 therefore added `vite build --mode harness` → `web/dist-harness/` with an injected `__ARAHARAH_HARNESS__` constant. **This task serves `web/dist-harness/`, not `web/dist/`.** The production bundle is what gets deployed and what BA1–BA5 gate; the harness bundle exists solely so the capture race is reachable. **`web/index.html` — the runner page is the SOLE mock installer.** `main.ts` installs a default mock only when none is present: `if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();`. That guard is what makes the profile below authoritative. An unconditional DEV install inside `main.ts` would clobber `?mock=tdesktop&scheme=dark` with an android/light default, so the Desktop capture would silently be an Android light capture and the exit-gate artifact would prove nothing. **THE ENTIRE PROFILE BLOCK BELOW IS WRAPPED IN `if (__ARAHARAH_HARNESS__) { … }` — this is load-bearing, not stylistic.** `web/index.html` is the ONE entry file shared by BOTH builds, so an unguarded block would put the mock installer AND the fake `fetch` (which short-circuits the HMAC session gate) into the production `web/dist/index.html`. `define` replaces `__ARAHARAH_HARNESS__` textually with `false` in the production build, so Vite/Rollup folds the branch away entirely (dead-code elimination); with `true` in the harness build it runs. `__ARAHARAH_HARNESS__` is a global identifier, so no import is needed to reference it inside ` ``` **Mock `initData`.** The `POST /api/session` stub never reaches the Worker, so `installMockWebApp`'s `initData` only has to be a non-empty, correctly-shaped string — the harness does NOT need a valid HMAC. Have the mock emit `user={"language_code":"","id":1,"first_name":"Harness"}&auth_date=1&query_id=h&hash=harness` (URL-encoded per Telegram's wire format) so `localeFromInitData` in `main.ts` exercises its real parsing path instead of always falling back to `"en"`. The `?locale=` parameter above overrides the language_code, so a future ms capture needs no code change. Note in `web/index.html`'s comment that no `localStorage.setItem`/`sessionStorage.setItem` may appear here — the harness profile lives in a module-scope global (`window.__ARAHARAH_MOCK__`), never in storage (D-07). **`web/scripts/capture.mjs`.** Copy `theme/scripts/screenshot.mjs` and adapt. Structure: ```javascript #!/usr/bin/env node /* * capture.mjs — Phase-3 capture harness (D-11, DSGN-02, GEO-03 runtime probe). * Extends theme/scripts/screenshot.mjs (Phase 1 precedent) rather than * replacing it: same MIN_BYTES blank bar, same docroot-guarded static * server, same READY-signal console race, same FAILED/MISSING/UNDERSIZED * report + exit(1). * * Docroot is web/dist-HARNESS (the --mode harness build), NOT web/dist. * Reason: web/index.html's entire Mock-Telegram profile block — the mock * installer and the /api/session + /api/location + /api/location-fallback * fetch stubs — sits inside `if (__ARAHARAH_HARNESS__)`. `vite build` replaces * that identifier with `false`, so the branch is dead-code-eliminated and * web/dist/index.html carries none of it (BA5 scans the dist HTML too). * Serving web/dist would yield no window.Telegram, a real POST /api/session * with no Worker behind it, the denied card, and a permanent * ARAHARAH-LIVE timeout. * * Wait signals: RAMS-BASEMAP-READY (earlier gate), then ARAHARAH-LIVE. * * Outputs (web/screenshots/ — regenerated, gitignored): * shell-boot-1.png boot stage 1 (validating session card over live map) * shell-boot-2.png boot stage 2 (acquiring location card) * shell-live-light.png LIVE state, Telegram light theme * shell-live-dark.png LIVE state, Telegram dark theme (PX-02) * shell-denied.png denied card + ASK VIA CHAT (GEO-02 entry point) * shell-chat-pin.png LIVE via the chat-pin relay (?pin=1, stubbed /api/location) * gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png * * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails * the run on any non-empty storage surface. */ ``` Implementation specifics: 1. **Build the harness bundle first, and PROVE it is the harness bundle.** `execSync("npm run build -w theme && npm run build -w web && npm run build:harness -w web", { cwd: repoRoot })`. After the build, assert `web/dist-harness/index.html` exists AND that the harness bundle actually contains `installMockWebApp` (a cheap `grep -q` over `web/dist-harness/assets/*.js`). If either check fails, abort immediately with "harness build did not include the Mock-Telegram installer — the capture would race forever" rather than letting the READY race time out 30s later with no diagnosis. The production `web/dist` must NOT contain it (BA5, task 1) — assert both directions. 2. **Server.** Copy `serveTheme()` verbatim, changing `THEME` to `DIST = resolve(SCRIPTS, "..", "dist-harness")` and keeping the WR-05 docroot guard. Extend the `MIME` map to include `.mjs`, `.map`, `.webmanifest`. 3. **Contexts.** One chromium browser; per capture, `browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme, deviceScaleFactor: 2 })`. 4. **Geolocation grant.** `await context.grantPermissions(["geolocation"], { origin: base });` and `await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });` before `page.goto` — for every profile EXCEPT `tdesktop`. 5. **READY race.** Attach `page.on("console", m => { if (m.text().startsWith("RAMS-BASEMAP-READY")) basemapReady = true; if (m.text().startsWith("ARAHARAH-LIVE")) liveReady = true; })` then `await Promise.race([liveReady, timeout(30000)])` per the Phase-1 pattern. **The `?mock=tdesktop` DENIED leg gets its own dedicated race against the `.card` + `[data-state="denied"]` strip lamp (see leg 6), NOT `ARAHARAH-LIVE` and NOT the 30s timeout** — `chain.ts` LINK 3 deliberately hard-codes `pollChatPin(token, { timeoutMs: 60000 })` before it returns `denied`, so the denied card first renders at roughly **60 seconds**. Race that leg against `timeout(90000)` with the reason recorded in this header comment (`DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first`). Any other leg that waits on a signal must not silently sit under 60s. **A timeout is a hard failure with a diagnostic, not a silent skip** — print which signals fired (`RAMS-BASEMAP-READY` yes/no, `ARAHARAH-LIVE` yes/no) plus the last 5 console lines, so a future regression points at the missing piece immediately. 6. **The seven captures**, in order: - `?mock=android` immediately after goto → `shell-boot-1.png` (stage 1 card). - `?mock=android` after 400ms → `shell-boot-2.png`. - `?mock=android&scheme=light` after ARAHARAH-LIVE + 2000ms tile settle → `shell-live-light.png`. - `?mock=android&scheme=dark` same → `shell-live-dark.png`. - `?mock=tdesktop` (no `grantPermissions`, no `setGeolocation`; the mock's `grantLocation:false` plus no navigator geolocation forces the chain to link 3) → `shell-denied.png`, waiting on the `.card` + `[data-state="denied"]` strip lamp rather than ARAHARAH-LIVE, with the **dedicated 90s race** from step 5 (`DENIED_LEG_TIMEOUT_MS`). **This is the leg that proves the ASK VIA CHAT CTA renders**, which is the entire entry point to GEO-02 on Desktop (issue #77). - `?mock=tdesktop&pin=1` → `shell-chat-pin.png`, waiting for ARAHARAH-LIVE. The `?pin=1` query makes the runner page's `GET /api/location` stub return a pin, exercising the chat-pin link of the chain end-to-end offline — the automated counterpart to the Desktop leg of the device matrix. - `gallery-compare.png`: compose the live-light capture beside `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` using a second page with a two-column grid, or by compositing the two PNGs side by side with a short script. Label each column with its source path. 7. **GEO-03 runtime probe.** After the live capture, run in-page: ```javascript const probe = await page.evaluate(async () => ({ local: localStorage.length, session: sessionStorage.length, cookie: document.cookie, dbs: (await indexedDB.databases()).length, lsKeys: Object.keys(localStorage), })); ``` Fail the run if `probe.local !== 0 || probe.session !== 0 || probe.cookie !== "" || probe.dbs !== 0`, with the probe values in the error message. This is GEO-03's "spot-checkable at phase exit" criterion made mechanical. 8. **Report.** Reuse the Phase-1 report block: log every capture with its byte size, then FAIL on `missing`, `undersized` (`< MIN_BYTES`), or any recorded failure; `process.exit(1)` if any, else `console.log("CAPTURE OK — 7/7 non-blank captures under web/screenshots/")` and `process.exit(0)`. Never log the bot token or `initData`; the harness reads `BOT_TOKEN` from `bot/.dev.vars` if it needs to sign, and prints only the capture filename and byte size. cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -25 - `npm run capture -w web` exits 0 and prints `CAPTURE OK — 7/7` with 7 non-blank captures - `ls web/screenshots/ | wc -l` returns >= 7 - `for f in web/screenshots/*.png; do test $(stat -c%s "$f") -ge 10240 || echo "UNDERSIZED $f"; done | wc -l` returns 0 - `grep -q 'dist-harness' web/scripts/capture.mjs` exits 0 — the harness serves the harness build, NOT the production bundle (the race is unreachable otherwise) - `grep -q 'web/dist-harness/assets' web/scripts/capture.mjs; grep -q "resolve(SCRIPTS, \"..\", \"dist\")" web/scripts/capture.mjs; test $? -ne 0` — the docroot must not be `web/dist` - `grep -q 'installMockWebApp' web/scripts/capture.mjs` exits 0 — the capture asserts the harness bundle really contains the installer before racing - `grep -q 'api/session' web/index.html && grep -q 'api/location' web/index.html && grep -q 'api/location-fallback' web/index.html` exits 0 — all three endpoints are stubbed in the runner page, inside the `__ARAHARAH_HARNESS__` guard - **The build-time guard exists (T-03-21):** `grep -c 'if (__ARAHARAH_HARNESS__) {' web/index.html | head -1` returns 1 AND `node -e "const s=require('fs').readFileSync('web/index.html','utf8'); const g=s.indexOf('if (__ARAHARAH_HARNESS__) {'); const m=s.indexOf('installMockWebApp('); if(g===-1||m===-1) throw new Error('guard or installer missing'); if(m/dev/null | wc -l` returns 0 AND `grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l` returns >= 1 - `grep -q 'import.meta.env.DEV' web/index.html; test $? -ne 0` — must FAIL: the runner page's guard is `__ARAHARAH_HARNESS__` only (a DEV guard would be statically false in BOTH builds and would break the harness); the DEV guard lives only in `main.ts` and is combined with `!window.__ARAHARAH_MOCK__` - `grep -q 'DENIED_LEG_TIMEOUT_MS' web/scripts/capture.mjs` exits 0 AND `grep -q '90000' web/scripts/capture.mjs` exits 0 — the `?mock=tdesktop` denied leg races LINK 3's 60s pre-denial chat-pin poll, not the 30s default (issue: a dead-end capture) - `grep -q 'pin") === "1"' web/index.html` exits 0 (the `?pin=1` chat-pin capture is wired) - `grep -q 'indexedDB' web/scripts/capture.mjs` exits 0 AND `grep -q 'localStorage.length' web/scripts/capture.mjs` exits 0 (the GEO-03 runtime probe exists) - `grep -q 'sessionStorage.setItem\|localStorage.setItem' web/index.html` must FAIL — the harness profile is not persisted - `grep -q '__ARAHARAH_MOCK__' web/index.html` exits 0 - `grep -q 'RAMS-BASEMAP-READY' web/scripts/capture.mjs` exits 0 AND `grep -q 'ARAHARAH-LIVE' web/scripts/capture.mjs` exits 0 (both READY signals raced) - `grep -q 'navigate_map_overview' web/scripts/capture.mjs` exits 0 (DSGN-02 side-by-side source) - `grep -q 'MIN_BYTES = 10240' web/scripts/capture.mjs` exits 0 (the Phase-1 blank bar is carried over) - `grep -q 'startsWith(THEME + sep)\|startsWith(DIST + sep)' web/scripts/capture.mjs` exits 0 — the WR-05 docroot guard survives - `grep -q 'declare const __ARAHARAH_HARNESS__' web/src/vite-env.d.ts` exits 0 (the injected constant is declared, so typecheck passes in both modes) Seven non-blank captures exist — including the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side — and the GEO-03 runtime probe mechanically proves every storage surface is empty after a real boot-to-live cycle. task 3: device-matrix exit gate — Android + iOS + Desktop + plain browser (ROADMAP Success Criterion 5) .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md - `AGENTS.md` (free-first infra mandate; the constraints that bear on staging) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Environment Availability" (Android, iOS, Desktop listed as blocking with NO fallback) - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Assumptions Log" (assumption A1, the Android stale-fix, can only be retired on a physical Android phone) - `.planning/phases/03-mini-app-shell-geolocation/03-VALIDATION.md` section "Manual-Only Verifications" (the four manual behaviors) This checkpoint is BLOCKING and cannot be automated. RESEARCH section "Environment Availability" lists a real Android phone, a real iOS phone, and a Telegram Desktop install as required dependencies with no fallback, and Open Question 3 (whether the executor has physical devices at all) is an open question only the user can answer. Assumption A1 (the Android stale-fix workaround) can only be retired on a physical Android device. **Pre-work the executor must complete BEFORE presenting this checkpoint:** 1. Deploy to staging: `npm run deploy` (builds `theme/`, then `web/`, then `wrangler deploy`). Note the deployed URL. Confirm it serves the app and that `POST /api/session` still answers. 2. **Verify the BotFather-registered Mini App URL matches the deployed origin.** Bot API 10.2 (auto-enabled 2026-07-20) rejects Mini App methods called from any other origin, and the failure is silent. If BotFather points elsewhere, `Telegram.WebApp.*` calls fail with no clear error. This is a hard prerequisite — resolve it first or every mobile leg is meaningless. 3. Write `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` with these six legs, each an unchecked checkbox plus an "Observed / Symptom" blank line: - **Android (real phone, Telegram Android)** — open the Mini App; confirm BOOT-01 then BOOT-02 then LIVE; confirm the position marker renders on the Rams basemap. Then close the app fully, reopen it, and confirm **no second permission prompt** appears. That is GEO-01's "permission persists across sessions": the grant lives in Telegram's per-bot store on the device, outside the WebView — record that observation explicitly, it is the subtlest point in the phase. - **Android stale-fix check (retires A1)** — with the app live, walk several metres, then re-check. If the pin does not move, toggle the phone's Location setting once to force a refresh and record whether the `navigator.geolocation` nudge resolved it. Note explicitly whether the first Android verification needed the Location toggle; that result decides whether A1 stands or is retired. - **iOS (real phone, Telegram iOS)** — same boot to LIVE check; marker renders; permission grant persists across a reopen. - **Desktop Telegram** — geolocation is absent by design (issue #77), so the expected behavior is the **denied card with identical copy** (no client-specific variant, per D-06 and RESEARCH Pitfall 4). Tap ASK VIA CHAT; this POSTs `/api/location-fallback`, and the Worker asks the bot to send the location keyboard into that user's own chat (chat id comes from the session claim — plan 03-01's `handleLocationFallback`). Confirm the keyboard appears in chat; tap it, share a pin; confirm the app picks it up via `GET /api/location` and reaches LIVE. That is GEO-02 end-to-end. - **Plain browser** — open the deployed URL directly in a desktop browser. The app must boot to a deliberate state: **the denied card** (off-Telegram there is no `initData`, so the session cannot be HMAC-validated → 401 → denied). The live branch is NOT reachable here — the denied card short-circuits the chain before any geolocation prompt. The check is that the page renders the denied card over the Rams shell instead of failing to load; a blank page is a FAIL. (If you separately confirm LIVE via a browser geolocation prompt, record it as an extra observation, not as this leg's pass condition.) - **Theme flip, live (PX-02 — VALIDATION.md "Manual-Only Verifications" row 4)** — with the app reached LIVE on a real client, switch the Telegram theme light ↔ dark **mid-session**, without closing or restarting the app. Confirm: (a) the shell chrome re-slots (document background, card field, shell ink all swap); (b) the status strip stays matte-black (Rams-constant); (c) the **map is unchanged** — same style, no flash, no camera jump (D-10 / RESEARCH Pitfall 7: a `setStyle` on `themeChanged` would tear down and rebuild all 110 layers). Record a before/after screenshot in each direction and note which client you flipped. This leg is the ONLY place PX-02's mid-session behavior is verified — the Playwright captures prove only separate cold boots per scheme. Fold it into whichever of the Android/iOS/Desktop legs you can actually perform, and record it as its own checkbox either way; an unrecorded theme flip leaves PX-02's "while the map keeps the Rams cartography constant" half unverified. **Present to the user with these exact instructions:** - Deploy first: `npm run deploy`, and note the deployed URL. - Confirm BotFather's Mini App URL is that exact origin. - Walk the six legs one at a time on real hardware. - Report back which legs passed, which failed, and the exact symptom for each failure. Type "device matrix passed" if all six legs reached LIVE or the documented denied-and-recovered state (the theme-flip leg counts as passed when the chrome re-slotted mid-session and the map was visibly unchanged). Otherwise describe which leg failed and the symptom — the executor opens a `/gsd-debug` for it. If the user reports that no physical Android or iOS device is available, the honest outcome is that Success Criterion 5 is UNVERIFIED. Do NOT mark the phase complete. Record the blocker in `.planning/STATE.md` section "Blockers/Concerns" and recommend either acquiring a device or moving the matrix to Phase 7 hardening with an explicit requirement-level note that GEO-01 and GEO-02 remain unverified on real hardware. Never close the phase claiming device verification that did not happen. cd /home/C-Fu/araharah && npm run build 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -3 && npm run capture -w web 2>&1 | tail -5 && test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md - `npm run capture -w web` exits 0 (the automated half of the gate is green before any device is touched) - `npm test -w web` and `npm test -w bot` both exit 0 - `test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 - `grep -c 'Android' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md | head -1` returns >= 1 AND the same for `iOS`, `Desktop`, `browser` — all four legs are recorded - `grep -q 'PASS' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (every leg has an explicit verdict recorded) - `grep -q 'A1\|stale-fix\|Location setting' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Android stale-fix result is recorded, not left implicit) - `grep -q 'BotFather' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Bot API 10.2 origin prerequisite was checked) - `grep -qi 'theme flip\|theme.*light.*dark\|LIGHT ↔ DARK' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (PX-02's mid-session theme flip has its own leg and an explicit verdict — VALIDATION.md lists it as manual-only) - If any leg is UNVERIFIED, `.planning/STATE.md` contains the blocker under "Blockers/Concerns" All six legs of the device matrix are verified on real hardware and recorded, or the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Built bundle → production origin | The artifact that reaches real devices; a CDN slip would leak requests to third parties. | | Telegram client → deployed origin | Bot API 10.2 enforces same-origin Mini App calls; a wrong BotFather URL silently breaks WebApp APIs. | | Dev harness → production bundle | The Mock-Telegram fake must never ship — it fakes location and the session gate. | | Harness build → deployed bundle | Two builds exist; only one is deployable. Conflating them would ship a fake that bypasses the HMAC session gate. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-20 | Information Disclosure | Third-party CDN receiving user navigation requests (or app fingerprinting) | mitigate | D-14 build-artifact gate BA1: every emitted `.js`/`.css` file is scanned for `cdn.tailwindcss.com`, `fonts.googleapis.com`, `fonts.gstatic.com`, `unpkg.com`, `cdn.jsdelivr.net`; any hit fails the suite. Fonts are self-hosted woff2 (BA2), the MapLibre worker is a Vite-emitted hashed asset (BA3), and the style JSON is inlined (BA4). Tiles come only from OpenFreeMap. | | T-03-21 | Information Disclosure | Mock-Telegram fake shipping to production | mitigate | **Dual-build isolation, enforced by a build-time constant that folds the branch away.** Two separate guards, both keyed on `__ARAHARAH_HARNESS__` (a Vite `define` that renders as `false` in `vite build` and `true` in `vite build --mode harness`). (1) `main.ts` installs its fallback mock only under `if ((__ARAHARAH_HARNESS__ \|\| import.meta.env.DEV) && !window.__ARAHARAH_MOCK__)` — both halves statically false in production, so the branch is unreferenced and tree-shaken. (2) `web/index.html` — the ONE entry shared by both builds — wraps its ENTIRE profile block (the `window.__ARAHARAH_MOCK__` marker, the `/api/session` + `/api/location` + `/api/location-fallback` fetch stubs, and the `installMockWebApp` call) in `if (__ARAHARAH_HARNESS__) { … }`; `define` substitutes `false`, Rollup dead-code-eliminates the block, and `await import("/src/main.ts")` stays OUTSIDE it so production boots normally. The outDirs are separate (`web/dist/` vs `web/dist-harness/`), and the `[assets]` binding points at `../web/dist` only. **BA5/BA6 scan every emitted `.js` AND every emitted `.html` under `web/dist/` for `installMockWebApp`, `__ARAHARAH_MOCK__`, `/api/session`, and `/api/location-fallback`** — an HTML-inclusive scan is mandatory because the runner page is inline HTML, so a JS-only gate would pass vacuously on exactly the regression this threat describes. `capture.mjs` asserts the inverse (installer PRESENT in `web/dist-harness/`) before racing, so the gate cannot pass by the harness build being empty. | | T-03-22 | Spoofing | Bot API 10.2 origin restriction silently disabling WebApp APIs | mitigate | The device-matrix checkpoint's step 2 makes "BotFather URL == deployed origin" a hard prerequisite before any mobile leg. A mismatch is caught there, not shipped. `wireBackButton` and `LocationManager` calls all originate from the app's own document. | | T-03-23 | Elevation of Privilege | A compromised asset binding serving arbitrary paths from the Worker origin | mitigate | `assets.directory` is a fixed build output (`../web/dist`); `not_found_handling: "none"` means a miss falls through to the Worker's 404 rather than serving a directory listing; `run_worker_first` guarantees `/api/*` and `/telegram/*` never resolve from the asset tree. | | T-03-24 | Information Disclosure | GEO-03 runtime probe leaking probe data into logs | accept | The probe reads counts and key names only (`localStorage.length`, `Object.keys(localStorage)`, `indexedDB.databases().length`) and prints them in the failure message. No coordinate value is read or printed. | | T-03-25 | Repudiation | Closing the phase claiming device verification that did not happen | mitigate | The checkpoint's acceptance criteria require an explicit PASS/UNVERIFIED verdict per leg in `03-DEVICE-MATRIX.md`, and the resume-signal block forbids closing on unverified hardware. Missing devices route to a documented Phase-7 deferral instead. | | T-03-30 | Repudiation | The capture harness reporting a fake Android capture for a Desktop profile, or racing to LIVE against the wrong build | mitigate | `main.ts` installs a mock only when `window.__ARAHARAH_MOCK__` is absent, so the runner page's `?mock=` profile is the sole authority — an unconditional DEV install would silently clobber `tdesktop`/`dark` with android/light and produce a plausible-looking but wrong artifact. `capture.mjs` aborts with a named diagnostic when the READY race times out (reporting which signals fired) rather than skipping, and asserts the served docroot is `dist-harness` and that the harness bundle really contains the installer. Every capture's profile is stated in the report line. | | T-03-31 | Tampering | A future edit serving `web/dist` from `capture.mjs`, reintroducing the unwinnable race | mitigate | The acceptance criteria grep `capture.mjs` for `dist-harness` and require that it does NOT reference `resolve(SCRIPTS, "..", "dist")`. `capture.mjs`'s header comment states the reason in full, so the failure mode is documented at the point of the code that would cause it. | - `npm test -w web` green (includes the 6 D-14 build-artifact gates over `web/dist` only, HTML included) - `npm test -w bot` green - `npm run build` green from a fresh clone (theme → web chain) - `npm run capture -w web` exits 0 with 7 non-blank captures off the HARNESS build, including a dark-theme shot, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side - `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/` returns NOTHING (including `web/dist/index.html`) while `grep -rl 'installMockWebApp' web/dist-harness/` succeeds — the two builds are isolated in both directions, and the production scan is not JS-only - `03-DEVICE-MATRIX.md` records an explicit verdict for all six legs, or the phase is held open with the blocker in STATE.md - D-13: one Worker serves static assets plus `/api/*` plus the webhook, with `run_worker_first` preserving the API routes - D-14: the shipped bundle references zero third-party CDNs; style JSON inlined, fonts self-hosted, MapLibre worker bundled - D-15: `npm run deploy` from the root is the single deploy command, and it builds theme before web so a fresh clone works - T-03-21 holds in both directions: the Mock-Telegram installer and its fetch stubs are provably absent from EVERY file in the deployable bundle (`.js` and `.html`) and provably present in the harness-only build - GEO-03: the runtime probe shows localStorage, sessionStorage, cookies, and IndexedDB all empty after a real boot-to-live cycle - GEO-01 / GEO-02 / PX-02: verified on Android, iOS, Desktop, plain browser, and one mid-session light↔dark theme flip — or explicitly recorded as unverified with the blocker documented After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-04-SUMMARY.md`