forked from c-fu/arah2
603 lines
53 KiB
Markdown
603 lines
53 KiB
Markdown
---
|
||
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> |