---
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 gatebot/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