Files
2026-10-23 22:37:55 +08:00

603 lines
53 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@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
<wrangler_assets_shape>
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.
</wrangler_assets_shape>
<screenshot_toolchain>
`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.
</screenshot_toolchain>
</context>
<tasks>
<task type="auto">
<name>task 1: Worker `[assets]` binding + one-command deploy + D-14 build-artifact gate</name>
<files>bot/wrangler.jsonc, bot/package.json, package.json, web/vite.config.ts, web/test/build-artifact.test.mjs, .gitignore</files>
<read_first>
- `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)
</read_first>
<action>
**`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 `<link>` 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.
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>task 2: Mock-Telegram runner page + Playwright capture harness (D-11, DSGN-02, GEO-03 runtime probe)</name>
<files>web/index.html, web/scripts/capture.mjs, web/src/vite-env.d.ts</files>
<read_first>
- `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)
</read_first>
<action>
**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 `<script type="module">` — it is textually replaced before module evaluation. Do NOT wrap it in `import.meta.env.DEV` (statically false in both builds, so the harness would break) and do NOT add `!window.__ARAHARAH_MOCK__` (the runner page is the sole authority; it sets that global itself).
The profile block lives INSIDE the existing `<script type="module">`, BEFORE the `main.ts` import, and the `main.ts` import stays OUTSIDE the guard so production still boots normally:
```html
<script type="module">
/* D-11 Mock-Telegram profile: ?mock=android|ios|tdesktop|macos|web + ?scheme=light|dark + ?pin=1
Harness-only: served from web/dist-harness, never deployed.
The WHOLE block below is inside `if (__ARAHARAH_HARNESS__)` — Vite's `define`
replaces that identifier with `false` in the production build, so the branch is
dead-code-eliminated and web/dist/index.html carries neither installMockWebApp
nor the fetch stubs. BA5 scans ALL of web/dist HTML as well as its JS assets. */
if (__ARAHARAH_HARNESS__) {
const q = new URLSearchParams(location.search);
const profile = q.get("mock") || "android";
const scheme = q.get("scheme") === "dark" ? "dark" : "light";
const locale = q.get("locale") === "ms" ? "ms" : "en";
// Set BEFORE main.ts is imported — this is the marker main.ts's install guard checks.
window.__ARAHARAH_MOCK__ = { profile, scheme, locale };
// Stub the endpoints the app calls, OUTSIDE any dead-code-eliminated branch. These
// live in the runner page (not in main.ts) precisely so no import.meta.env guard can
// strip them: the harness bundle must reach LIVE without a Worker in the loop.
const realFetch = window.fetch.bind(window);
const fakeToken = "00000000-0000-4000-8000-000000000001";
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : (input && input.url) || "";
if (url.includes("/api/session") && (!init || init.method === "POST")) {
// The session gate is HMAC-validated server-side; offline the harness
// short-circuits it with a fake token the stubbed /api/location also accepts.
return new Response(JSON.stringify({ token: fakeToken }), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location") && (!init || init.method === "GET")) {
const body = q.get("pin") === "1"
? { pin: { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 } }
: { pin: null };
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
}
if (url.includes("/api/location-fallback")) {
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}
return realFetch(input, init);
};
const { installMockWebApp } = await import("/src/mock/telegram-mock.ts");
installMockWebApp({
platform: profile,
colorScheme: scheme,
languageCode: locale,
grantLocation: profile !== "tdesktop",
telegramFix: { lat: 3.1513, lng: 101.6958, accuracyM: 12, headingDeg: null },
});
}
await import("/src/main.ts");
</script>
```
**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":"<locale>","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.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -25</automated>
</verify>
<acceptance_criteria>
- `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<g) throw new Error('VIOLATION: installer is outside the guard — it would ship in web/dist/index.html'); console.log('guard OK')"` exits 0 — the ENTIRE profile block (marker, fetch stubs, installer) is inside the guard, and the `main.ts` import is outside it
- **Both directions on the built output:** `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/ 2>/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)
</acceptance_criteria>
<done>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.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>task 3: device-matrix exit gate — Android + iOS + Desktop + plain browser (ROADMAP Success Criterion 5)</name>
<files>.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md</files>
<read_first>
- `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)
</read_first>
<action>
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.
<resume-signal>
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.
</resume-signal>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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"
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-04-SUMMARY.md`
</output>