ambek .planning
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Phase 1: Design Tokens + Rams Foundation - Context
|
||||
|
||||
**Gathered:** 2026-10-22
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Distill the Telemetry Rams design system into a single centralized, importable token package — including a Rams-styled MapLibre basemap derived from OpenFreeMap `liberty` — proven with a rendered component sheet and a user-approved mockup-vs-implementation side-by-side gallery, before any product screen is built. Ends the inline-Tailwind-duplication and design-source conflict. Bot, Mini App shell, search, and routing are separate phases; this phase builds no product screens.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Token package format
|
||||
- **D-01:** Tailwind v4 CSS-first package: tokens declared as CSS custom properties plus the `@theme` directive. No v3-style JS config. CSS variables double as the consumption path for MapLibre basemap styles and later phases.
|
||||
- **D-02:** Token naming mirrors the mockup vocabulary verbatim (~40-token Material-style scale: `signal-orange`, `matte-black`, `space-md`, `display-lg`, ...) so mockup-derived markup maps 1:1 and fidelity comparison stays direct.
|
||||
- **D-03:** The token package ships together with a rendered component sheet that consumes it — the sheet itself proves token completeness (DSGN-01's "every rendered component derives from tokens" is demonstrated, not asserted).
|
||||
- **D-04:** Pre-slot the dual Telegram light/dark theme boundary now (themeable shell surfaces as token slots), while map cartography stays Rams-constant. Phase 3 (PX-02) then maps two background values onto slots instead of restructuring tokens.
|
||||
|
||||
### Source-of-truth resolution
|
||||
- **D-05:** Root `DESIGN.md` is superseded for specifics: keep it as Rams philosophy prose with a header banner pointing to the token package. `telemetry_rams/DESIGN.md` content (the mockup-matching system) flows into the package; the losing typography claim (Helvetica/Inter + 5 colors) is explicitly marked superseded.
|
||||
- **D-06:** The four mockup `code.html` files stay untouched, read-only generated reference artifacts (fine as-is per CONCERNS.md). Their inline `tailwind.config` blocks are superseded but NOT edited. Fidelity comparison targets rendered output, not markup.
|
||||
- **D-07:** The token package owns all numeric values (hex, px, rgba, font sizes). Design docs describe principles and link to the package; docs never restate hex codes or dimensions — the drift that caused the conflict cannot recur.
|
||||
|
||||
### Basemap styling depth
|
||||
- **D-08:** Full Rams pass on the OpenFreeMap `liberty` fork in this phase: grayscale/muted land + water grounding, desaturated roads, muted label styling; `signal-orange` accent reserved exclusively for the route line (rendered in Phase 5). Orange remains navigation-only semantics.
|
||||
- **D-09:** Basemap colors are derived from the token package (values read/synced from the package), not hand-tuned in the style JSON — honoring DSGN-01's "map cartography follows the same tokens". Exact derivation mechanism (generation script vs synced values file) is planner/researcher's choice.
|
||||
- **D-10:** Map labels adopt Rams typography (Space Grotesk / Geist), coherent with the HUD, using the same font assets.
|
||||
|
||||
### Component sheet + gallery
|
||||
- **D-11:** Component sheet scope is mockup-derived: everything the 4 mockup screens actually show — buttons, cards, chips/badges, the maneuver banner, inputs, and a typography specimen. No invention of unseen components.
|
||||
- **D-12:** The approval gallery is a static side-by-side HTML page: rendered implementation components against the four mockup screens' renders, token-by-token. This page is the DSGN-02 mechanism template — every later UI phase extends the same pattern.
|
||||
- **D-13:** DSGN-01's audit sweep is automated: a script that scans rendered CSS/classes and flags any color/spacing/typography/radius value not in the token set. "Zero off-token values" is evidence, not eyeball claim.
|
||||
- **D-14:** Gallery renders are produced with Playwright real-browser screenshots (script committed for reuse by later UI phases under the design-taste-frontend skill's real-browser inspection rule).
|
||||
|
||||
### Build pipeline
|
||||
- **D-15:** Minimal `package.json` + Tailwind v4 CLI and npm scripts; design gallery/sheet + basemap style packaged in one design folder consumed by later phases. No app framework scaffold — the Mini App shell shape is decided in Phase 3.
|
||||
- **D-16:** Self-host Geist + Space Grotesk woff2 in the repo as part of the token package; Google Fonts CDN links are removed going forward (may remain in mockups).
|
||||
- **D-17:** Local npm scripts only (build / gallery / audit) in Phase 1; CI workflows come with Phase 2's deploy pipeline. Keep tooling minimal.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Exact derivation/refresh mechanism basemap ← tokens (script vs synced file)
|
||||
- Gallery page file layout within the design folder
|
||||
- Audit script implementation details and threshold handling
|
||||
- Icon strategy details within the sheet (Material Symbols vs inline SVG usage observed in mockups)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "Map cartography stays Rams-constant" — the map background must look identical under Telegram light and dark themes; only shell surfaces re-theme.
|
||||
- Mockup screens are the spec — the gallery side-by-side against their rendered output is the fidelity bar.
|
||||
- Scope creep avoided by design: motion/animation polish, incident components (reporter screen's modal flow) render only as static sheet entries where the mockups show them; full interactive flows belong to later phases.
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Design system (binding)
|
||||
- `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md` — canonical token vocabulary, component rules, zero-radius / no-blur constraints
|
||||
- `DESIGN.md` — Rams philosophy (kept for principles; its typography/palette specifics are superseded per D-05)
|
||||
- `stitch_minimalist_route_navigator/active_route_guidance/code.html` — markup/layout reference (read-only) for maneuver banner and incident telemetry band
|
||||
- `stitch_minimalist_route_navigator/navigate_map_overview/code.html` — layout reference for map screen + layer controls
|
||||
- `stitch_minimalist_route_navigator/community_incident_reporter/code.html` — layout reference for incident card components
|
||||
- `stitch_minimalist_route_navigator/telegram_live_traffic_feed/code.html` — layout reference for chip/badge components
|
||||
|
||||
### Planning inputs
|
||||
- `.planning/ROADMAP.md` §Phase 1 — goal, success criteria, notes (repo hygiene, skill usage, no research phase)
|
||||
- `.planning/REQUIREMENTS.md` — DSGN-01, DSGN-02 (this phase's requirements)
|
||||
- `.planning/codebase/CONCERNS.md` — Zone.Identifier debt, design-source conflict, mockup fragility (Phase 1 resolves these)
|
||||
- `.planning/codebase/STACK.md` — token duplication, Play-CDN caveat, design constraints
|
||||
- `.agents/skills/design-taste-frontend/SKILL.md` — applies in existing-brand special case: keep Rams palette/type/dashboard house rules; real-browser inspection for the gallery artifact
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- Inline `tailwind.config` block in each `code.html` — byte-identical token source to extract into the v4 package (matches `telemetry_rams/DESIGN.md:2-148`)
|
||||
- Mockup `<screen>/code.html` + `<screen>/screen.png` pairs — ready-made inputs for the side-by-side gallery
|
||||
- SVG patterns in mockups (route vectors, `stroke-linecap="square"`, 2px strokes) — reuse for sheet icon components
|
||||
|
||||
### Established Patterns
|
||||
- kebab-case token/class names (`bg-matte-black`, `text-signal-orange`) — carry into the v4 package
|
||||
- 8px baseline grid, 4px/8px multiples for every dimension
|
||||
- Uppercase labels with expanded tracking; Material Symbols Outlined iconography
|
||||
- Color semantics: orange = navigation/primary, red = hazards/errors, green = police-only (per mockups); keep strict
|
||||
|
||||
### Integration Points
|
||||
- The token package is consumed by: the rendered sheet + gallery (Phase 1), the Mini App shell (Phase 3), every UI phase's Tailwind build
|
||||
- The basemap style JSON becomes the MapLibre style loaded by Phase 3/5/6's map views
|
||||
- Playwright capture script becomes the DSGN-02 gallery evidence toolchain for all UI phases
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Interactive mockup/implementation compare tool — a static page suffices for v1; revisit if gallery review becomes painful
|
||||
- Full incident-report interaction flows — Phase 1 renders reporter-screen components statically only; the privacy boundaries of incident reporting ship post-v1 (v2 cluster REP-01/02, per REQUIREMENTS.md)
|
||||
- CI workflow for gallery + audit — deferred to Phase 2 deploy pipeline (D-17)
|
||||
|
||||
None — discussion stayed within phase scope.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01-design-tokens-rams-foundation*
|
||||
*Context gathered: 2026-10-22*
|
||||
@@ -0,0 +1,57 @@
|
||||
# Phase 1: Design Tokens + Rams Foundation - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions captured in CONTEXT.md — this log preserves the analysis.
|
||||
|
||||
**Date:** 2026-10-22
|
||||
**Phase:** 01-design-tokens-rams-foundation
|
||||
**Mode:** default (interactive)
|
||||
|
||||
## Areas Discussed
|
||||
|
||||
### Token package format
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Package form | Tailwind v4 @theme / v3 JS config / plain CSS vars + adapter | Tailwind v4 CSS-first (@theme) |
|
||||
| Naming | Mirror mockup names / semantic rename / you decide | Mirror mockup names |
|
||||
| Bare tokens vs tokens + rendered sheet | Tokens + sheet / tokens only / you decide | Tokens + rendered sheet |
|
||||
| Telegram light/dark pre-planning | Pre-slot dual theme / defer to Phase 3 / you decide | Pre-slot dual theme |
|
||||
|
||||
### Source-of-truth resolution
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Root DESIGN.md conflict | Supersede + keep philosophy / merge + rewrite / archive | Supersede, keep philosophy |
|
||||
| Fate of mockups + inline Tailwind blocks | Stay read-only / migrate mockups / you decide | Mockups stay read-only |
|
||||
| Owner of numeric values | Package owns / docs own / you decide | Package owns values |
|
||||
|
||||
### Basemap styling depth
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Rams-ification depth | Full Rams pass / grayscale only / iterate in gallery | Full Rams pass |
|
||||
| Color sourcing | Derived from tokens / fork + hand-tune / you decide | Derived from tokens |
|
||||
| Label typography | Rams fonts / stock / compare in gallery | Rams fonts in labels |
|
||||
|
||||
### Component sheet + gallery scope
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Sheet components | Mockup-derived set / core primitives / you decide | Mockup-derived set |
|
||||
| Gallery form | Static side-by-side / interactive tool / you decide | Static side-by-side page |
|
||||
| DSGN-01 audit | Automated / manual / you decide | Automated token audit |
|
||||
| Render production | Playwright / manual captures / you decide | Playwright screenshots |
|
||||
|
||||
### Build pipeline choice
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Phase 1 tooling | Minimal npm + v4 CLI / full framework / CSS only | Minimal npm + v4 CLI |
|
||||
| Fonts | Self-host woff2 / keep Google Fonts CDN / you decide | Self-host fonts |
|
||||
| CI scope | Local scripts / CI day one / you decide | Local scripts first |
|
||||
|
||||
## Corrections Made
|
||||
|
||||
No corrections — user accepted recommended options throughout.
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Interactive compare tool (revisit if gallery review painful)
|
||||
- Incident-report interaction flows (v2: REP-01/02)
|
||||
- CI workflow (Phase 2)
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
phase: 01-design-tokens-rams-foundation
|
||||
fixed_at: 2026-10-22T02:05:00Z
|
||||
review_path: .planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 6
|
||||
fixed: 6
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 1: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-22T02:05:00Z
|
||||
**Source review:** `.planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md`
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 6 (BLOCKER CR-01 + WR-01..WR-05; INFO findings out of scope per orchestrator)
|
||||
- Fixed: 6
|
||||
- Skipped: 0
|
||||
|
||||
**Note on provenance:** a prior fixer run applied all six fixes as atomic commits (61d15c2…43b7d99)
|
||||
but terminated before writing this report. This pass *independently re-verified* each committed fix
|
||||
(diffs audited against the review's prescribed mechanism, all four verification gates re-run
|
||||
empirically, see Verification section) — no fixes were re-applied; master was already clean.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: Dead `--radius-DEFAULT` token — bare `rounded` compiles to `0.25rem` (BLOCKER)
|
||||
|
||||
**Commit:** `61d15c2`
|
||||
**Files modified:** `theme/src/tokens.css`, `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
|
||||
**Applied fix:** Replaced the inert v3-style `--radius-DEFAULT: 0px` with the deprecated bare
|
||||
`--radius: 0px` — the key Tailwind v4.3.3 actually drives the bare `rounded` utility from
|
||||
(`node_modules/tailwindcss/theme.css` "Deprecated" block). Block comment corrected (bare
|
||||
`rounded` is now var-driven; `rounded-none`/`rounded-full` are literal built-ins; the DEFAULT
|
||||
sub-key must not reappear). Audit radius drift guard extended to `/--radius(?:-[A-Za-z0-9-]*)?…/`
|
||||
so a future non-zero bare key trips exit 2. Regression tests T9 added: bare-key presence,
|
||||
inert-DEFAULT absence, non-zero bare key → exit 2, and a controlled build asserting
|
||||
`.rounded` compiles to `var(--radius)` with `--radius: 0px` emitted by `@theme static`.
|
||||
|
||||
### WR-01: Prose candidate leakage compiles Rams-forbidden `.shadow`/`.blur` into the shipped stylesheet
|
||||
|
||||
**Commit:** `689bc9f`
|
||||
**Files modified:** `theme/src/theme.css`, `theme/src/tokens.css`, `theme/scripts/audit.test.mjs`
|
||||
**Applied fix:** (1) `@source` narrowed from the whole mockup tree to the four mockup markup
|
||||
files, plus `@source not "../scripts"` / `@source not "../basemap"` so toolchain prose can never
|
||||
mint utility classes. (2) Defense in depth at the key: the deprecated bare `--shadow`,
|
||||
`--shadow-inner`, `--drop-shadow`, `--blur` namespaces are set to `initial` in `@theme static`,
|
||||
so bare shadow/blur utilities can never compile from any candidate source (scale keys remain —
|
||||
mockup-worn `shadow-*` classes still compile and stay under adjudication 1). T5 regression
|
||||
checks added: no `.shadow{` / `.blur{` in `dist/rams.css`, no `border-radius:.25rem` anywhere
|
||||
in dist.
|
||||
|
||||
### WR-02: `familiesFromShorthand` logic bug — off-token families in `font:` shorthands pass unflagged
|
||||
|
||||
**Commit:** `5e95741`
|
||||
**Files modified:** `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
|
||||
**Applied fix:** Two-layers, contract-true: (1) any literal (non-`var()`) `font:` shorthand is
|
||||
flagged outright with the convention message (`font: var(--type-*)`), naming the extracted
|
||||
families; `var()` shorthands pass. (2) `familiesFromShorthand` itself repaired — iteratively
|
||||
strips the leading style/variant/weight keyword run (any CSS-grammar order, incl. oblique
|
||||
angles), then the size[/line-height] component (incl. `calc()`), per comma group, so the family
|
||||
is genuinely extracted for the message instead of being silently dropped by the old
|
||||
single-strip + numeric-filter bug. T10 regression tests: literal shorthand → exit 1, Arial
|
||||
extracted, sanctioned-family shorthand still flagged, `var()` shorthand passes.
|
||||
|
||||
### WR-03: Color-evasion gaps — named keywords, modern color functions, `text-shadow` all pass
|
||||
|
||||
**Commit:** `ee48e76`
|
||||
**Files modified:** `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
|
||||
**Applied fix:** (1) Full CSS Color keyword list (`NAMED_COLORS`, ~148 entries) flagged on the
|
||||
color-bearing property set (`COLORISH_PROPS` incl. shorthands, fill/stroke, borders,
|
||||
text-decoration, accent-color); `currentcolor`/`transparent` and css-wide keywords stay
|
||||
sanctioned. (2) Modern color functions — `oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix()`
|
||||
— flagged on any property unless the value is `var()`-bearing (Tailwind's own `color-mix`
|
||||
emissions are var-driven and pass). (3) `text-shadow` added to `shadowViolations`: any
|
||||
non-none value forbidden (Rams Shapes). T11 regression tests: `color: red`, `oklch(...)`,
|
||||
`color(display-p3 ...)`, non-hex `text-shadow` all exit 1 and are listed; sanctioned keywords
|
||||
pass. Audit header (§5) updated to document the extended surface.
|
||||
|
||||
### WR-04: D-13 contract shortfall — spacing and typography numerics not audited
|
||||
|
||||
**Commit:** `5e254cb`
|
||||
**Files modified:** `theme/scripts/audit.mjs`, `theme/gallery.html`
|
||||
**Applied fix:** Implemented the missing checks rather than amending the claim. (1) Spacing:
|
||||
`padding`/`margin`/`gap` families (logical variants included) — literals must equal a
|
||||
`--spacing-*` token value, be zero, or `auto`; dimensional geometry (width/height, offsets,
|
||||
border widths, the 18×18 checkbox) is spec-per-components-header and out of scope
|
||||
(adjudication 6). (2) Typography: `font-size`/`font-weight`/`line-height`/`letter-spacing`
|
||||
validated against the `--type-*` scale, parsed from the compound token grammar (`weight
|
||||
size/line-height family…` + `-tracking` companions); `normal`/`bold` keyword equivalents
|
||||
sanctioned; `@font-face` blocks exempt from numeric scale as asset metadata (D-16) but still
|
||||
color/family-checked (adjudication 7). `npm run audit` output now reports
|
||||
"6 spacing values / 10 type sizes / 9 trackings". Gallery D-13 evidence wording updated to
|
||||
"colors, spacing, typography, radius, shadows, blur". T12 regression tests: off-scale
|
||||
numerics (`padding: 13px`, `font-size: 17px`, weight 650, tracking) → exit 1 and listed;
|
||||
token-value literals (1rem, 0.25rem, 14px, 700, 20px, -0.04em), zero/auto, and font-face
|
||||
metadata pass.
|
||||
|
||||
### WR-05: Static server path guard uses prefix match without separator
|
||||
|
||||
**Commit:** `43b7d99`
|
||||
**Files modified:** `theme/scripts/screenshot.mjs`
|
||||
**Applied fix:** `sep` imported from `node:path`; guard changed to
|
||||
`if (target !== THEME && !target.startsWith(THEME + sep))` — the docroot prefix must end at a
|
||||
path separator, so a decoded `..%2FthemeX` (which joins to a `themeX`-sibling path passing the
|
||||
old bare `startsWith(THEME)`) now 403s.
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
None — all six in-scope findings fixed and verified.
|
||||
|
||||
## Verification (all four gates, empirically re-run in this pass)
|
||||
|
||||
1. **Build radius gate** — `npm run build --workspace theme`; `grep -o 'border-radius:[^;}]*' theme/dist/rams.css | sort -u` →
|
||||
`border-radius:0`, `border-radius:var(--radius)`, `border-radius:3.40282e38px`
|
||||
(the sanctioned `rounded-full` ≥1e30px literal clamp). **No `.25rem` / non-token radius forms.**
|
||||
`.rounded` compiles to `border-radius:var(--radius)` with `--radius:0px` emitted by `@theme static`. ✅
|
||||
2. **Audit gate** — `npm run audit` (repo root) →
|
||||
`AUDIT CLEAN — 354 declarations checked … — 0 off-token values. token sheet: 55 colors / 9 radius
|
||||
tokens (all zero) / 7 sanctioned font families / 6 spacing values / 10 type sizes / 9 trackings`. ✅
|
||||
3. **Test suite** — `node theme/scripts/audit.test.mjs` → **38 passed, 0 failed**
|
||||
(was 16/16 pre-fix; now includes T9 CR-01, T10 WR-02, T11 WR-03, T12 WR-04, extended T5 WR-01). ✅
|
||||
4. **Screenshot server guard (live probe)** — server started under the real capture run
|
||||
(`node theme/scripts/screenshot.mjs`, listener located by PID), probed with `curl --path-as-is`:
|
||||
- `/..%2FthemeX/secret` → **403 forbidden** (the exact WR-05 exploit shape: a `themeX` sibling
|
||||
that the old bare prefix check passed)
|
||||
- `/..%2FthemeX/gallery.html` → **403**
|
||||
- `/..%2Fstitch_minimalist_route_navigator/…` → **403**
|
||||
- `/src/sheet.html` → 200 (correct content — no over-blocking)
|
||||
- `/gallery.html` → 200 — ✅
|
||||
(Method note: a first probe port grab had hit Chromium's own devtools listener — PID-matched
|
||||
listener selection fixed that; the guard result above is from the real capture server.)
|
||||
Bonus: full capture run completed `CAPTURE OK — 6/6` non-blank captures.
|
||||
|
||||
Also verified byte-for-byte: deep diff audit of all six fix commits against the REVIEW.md
|
||||
prescribed fix shapes (`git show 61d15c2 689bc9f 5e95741 ee48e76 5e254cb 43b7d99`) — mechanisms
|
||||
are real (key-level token disarmament, regex/grammar repair, parser extension, guard change),
|
||||
not cosmetic suppressions; per CR-01 guidance the elimination of the banned utility is the
|
||||
intended goal, so post-fix compiled output genuinely lacks `.rounded`/`.shadow`/`.blur`
|
||||
non-zero/off-token forms.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-22T02:05:00Z_
|
||||
_Fixer: OpenCode (gsd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,312 @@
|
||||
---
|
||||
phase: 01-design-tokens-rams-foundation
|
||||
reviewed: 2026-10-22T01:08:55Z
|
||||
depth: standard
|
||||
files_reviewed: 13
|
||||
files_reviewed_list:
|
||||
- package.json
|
||||
- theme/package.json
|
||||
- theme/src/tokens.css
|
||||
- theme/src/theme.css
|
||||
- theme/src/components.css
|
||||
- theme/src/sheet.html
|
||||
- theme/basemap/build.mjs
|
||||
- theme/basemap/liberty-rams.json
|
||||
- theme/basemap/index.html
|
||||
- theme/scripts/audit.mjs
|
||||
- theme/scripts/audit.test.mjs
|
||||
- theme/scripts/screenshot.mjs
|
||||
- theme/gallery.html
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 5
|
||||
info: 7
|
||||
total: 13
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-22T01:08:55Z
|
||||
**Depth:** standard (with empirical verification: controlled Tailwind builds, live audit runs, test-suite execution, machine verification of the 6,089-line generated `liberty-rams.json`)
|
||||
**Files Reviewed:** 13
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
The phase delivers a genuinely well-engineered token package: `npm run audit` exits 0 on the
|
||||
real build (354 declarations checked), the TDD suite passes 16/16, all 7 self-hosted woff2
|
||||
files exist and are wired correctly (no CDN fonts), the compiled `dist/rams.css` emits the
|
||||
`--color-*`/`--spacing-*`/`--type-*` tokens plus the D-04 shell slots with the dark-scheme
|
||||
media query, and `liberty-rams.json` was machine-verified clean (no accent hex/rgb forms, no
|
||||
`line-gradient`, no `icon-color`, no non-zero halo blur, only opacity ramps preserved,
|
||||
font substitutions honestly recorded in `metadata`).
|
||||
|
||||
However, the adversarial pass found one blocker in the foundation deliverable itself, plus a
|
||||
cluster of audit-gate holes proven by end-to-end evasion tests:
|
||||
|
||||
1. **BLOCKER (CR-01):** `--radius-DEFAULT: 0px` is a dead token in Tailwind v4.3.3 — the bare
|
||||
`rounded` utility is driven by the deprecated `--radius` key, not `--radius-DEFAULT`. The
|
||||
shipped `theme/dist/rams.css` therefore contains `.rounded{border-radius:.25rem}` — a
|
||||
non-zero radius value owned by **no token**, violating the binding zero-roundness
|
||||
constraint, D-07 ("the token package owns all numeric values"), and the tokens.css comment
|
||||
that asserts "every token-driven rounded-* utility compiles to 0".
|
||||
2. **Vocabulary leakage (WR-01):** `.shadow{…}` and `.blur{…}` (Rams-forbidden constructs,
|
||||
with off-token values `#0000001a` / `blur(8px)` baked in) compile into the shipped
|
||||
stylesheet from **prose**, not from any mockup class attribute — so audit adjudication 1's
|
||||
rationale ("utilities the mockups wear") does not even cover them.
|
||||
3. **Audit-gate holes (WR-02/03/04):** proven evasions — `font: 700 14px/18px Arial` passes
|
||||
unflagged (logic bug in `familiesFromShorthand`), as do `color: red`, `oklch(...)`,
|
||||
`color(display-p3 …)`, non-hex `text-shadow`, and all spacing/type-scale numerics
|
||||
(`padding: 13px`, `font-size: 17px`), contradicting the tool's own coverage claims and
|
||||
D-13's stated contract.
|
||||
|
||||
None of these are caught by the current green gates, which is exactly why they need fixing
|
||||
before later phases consume the package.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Dead `--radius-DEFAULT` token — bare `rounded` compiles to `0.25rem`, violating the binding zero-roundness constraint (BLOCKER)
|
||||
|
||||
**File:** `theme/src/tokens.css:98-109` (token), `theme/src/theme.css:13-15` (build entry); evidence in compiled `theme/dist/rams.css` (minified, line 2): `.rounded{border-radius:.25rem}`
|
||||
**Issue:** The radius block sets `--radius-DEFAULT: 0px` intending to zero the bare `rounded`
|
||||
utility (v3-style `DEFAULT` key), but Tailwind v4.3.3 does **not** read `--radius-DEFAULT` for
|
||||
that utility — the bare `rounded` is driven by the deprecated `--radius` key (see
|
||||
`node_modules/tailwindcss/theme.css:508`, `@theme default inline reference { --radius: 0.25rem; }`).
|
||||
Empirically verified with controlled builds against the repo's own Tailwind 4.3.3:
|
||||
|
||||
- `@theme { --radius-DEFAULT: 0px; }` → `.rounded { border-radius: 0.25rem; }` (token ignored)
|
||||
- `@theme { --radius: 0px; }` → `.rounded { border-radius: var(--radius); }` (correct override)
|
||||
|
||||
Consequences:
|
||||
- The shipped `dist/rams.css` contains `.rounded{border-radius:.25rem}` — a numeric value
|
||||
owned by no token, in the phase deliverable whose thesis is D-07 ("the token package owns
|
||||
all numeric values") and the "zero off-token values" evidence claim.
|
||||
- The tokens.css comment (lines 98-101) is factually wrong: it carves out only
|
||||
`rounded-none`/`rounded-full` as literal built-ins and asserts every other token-driven
|
||||
`rounded-*` compiles to 0. Bare `rounded` compiles to `0.25rem` with the current token set.
|
||||
- Any later phase wearing `rounded` (a Tailwind-default habit; the class *is* in the compiled
|
||||
vocabulary) silently gets 4px corners. The audit does flag it once worn (verified with a
|
||||
fixture page: `theme/dist/rams.css:2: .rounded { border-radius: .25rem } — non-zero
|
||||
border-radius`), but the package is supposed to prevent, not be caught by, its own gate.
|
||||
- The audit's radius drift-guard (`theme/scripts/audit.mjs:173`,
|
||||
`/--radius-[A-Za-z0-9-]*\s*:\s*(…)/`) only matches `--radius-*` with a dash, so a future
|
||||
bare `--radius` override escapes the drift guard entirely; `radiusAllowed()`
|
||||
(`audit.mjs:409`) also passes any `var()` unconditionally.
|
||||
|
||||
**Fix:**
|
||||
```css
|
||||
/* theme/src/tokens.css — @theme static radius block */
|
||||
@theme static {
|
||||
--radius: 0px; /* v4's deprecated bare key — THE key that drives the bare `rounded` utility */
|
||||
--radius-xs: 0px; /* …rest unchanged… */
|
||||
}
|
||||
```
|
||||
Delete `--radius-DEFAULT: 0px;` (inert in v4) or keep it only with a comment marking it
|
||||
inert; correct the block comment to state that `rounded`/`rounded-none`/`rounded-full` are
|
||||
literal built-ins and that `--radius` must be overridden explicitly. Rebuild and confirm
|
||||
`.rounded{border-radius:0px}` in `dist/rams.css`. Extend the audit drift-guard:
|
||||
|
||||
```js
|
||||
/* audit.mjs — cover the deprecated bare key too */
|
||||
for (const m of text.matchAll(/--radius(?:-[A-Za-z0-9-]*)?\s*:\s*([^;}]+?)\s*;/g)) { … }
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Prose candidate leakage compiles Rams-forbidden `.shadow`/`.blur` utilities into the shipped stylesheet
|
||||
|
||||
**File:** `theme/src/theme.css:24` (`@source "../../stitch_minimalist_route_navigator"`), plus the CLI's implicit auto-scan of `theme/`
|
||||
**Issue:** `@source` points at the whole mockup tree — including `telemetry_rams/DESIGN.md`
|
||||
prose — and the CLI auto-scan ingests the toolchain's own text (e.g. `scripts/audit.mjs:36`
|
||||
prose "shadow/backdrop-blur/bg-black"). Tailwind extracts class candidates from natural
|
||||
language. Result in `dist/rams.css` (all verified present, none worn by any mockup class
|
||||
attribute or implementation page):
|
||||
|
||||
- `.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a)…box-shadow:…}` — bare `shadow` is worn by **no** mockup (mockups wear `shadow-md`/`shadow-sm`/`shadow-[…]` only)
|
||||
- `.blur{--tw-blur:blur(8px);…}` — bare `blur` likewise unworn anywhere
|
||||
- `.rounded` (see CR-01) entered the vocabulary the same way (e.g. `telemetry_rams/DESIGN.md:155` "devoid of rounded ornament")
|
||||
|
||||
These carry off-token values (`#0000001a`, `8px`) into the artifact, and audit adjudication 1
|
||||
("utilities the mockups wear but the design doc rejects") does not cover them — they are not
|
||||
mockup-worn at all. The audit skips them only because no page harvests those classes, so the
|
||||
"zero off-token values" evidence holds by accident of non-usage.
|
||||
**Fix:** Scope `@source` to the four mockup markup files and exclude tooling prose from the
|
||||
auto-scan:
|
||||
```css
|
||||
@source "../../stitch_minimalist_route_navigator/active_route_guidance/code.html";
|
||||
@source "../../stitch_minimalist_route_navigator/navigate_map_overview/code.html";
|
||||
@source "../../stitch_minimalist_route_navigator/community_incident_reporter/code.html";
|
||||
@source "../../stitch_minimalist_route_navigator/telegram_live_traffic_feed/code.html";
|
||||
@source not "../scripts";
|
||||
@source not "../basemap";
|
||||
```
|
||||
After the fix, `.rounded`/`.shadow`/`.blur` should disappear from `dist/rams.css` (verify by
|
||||
grep); only mockup-worn classes remain in the compiled vocabulary.
|
||||
|
||||
### WR-02: `familiesFromShorthand` logic bug — off-token families in `font:` shorthands pass the audit unflagged
|
||||
|
||||
**File:** `theme/scripts/audit.mjs:449-455` (helper), consumed at `audit.mjs:439-441`
|
||||
**Issue:** The strip regex removes only the **first** shorthand token:
|
||||
`'700 14px/18px Arial'` → strips `'700 '` → leaves `'14px/18px Arial'`, which the
|
||||
`/^[0-9.]/` filter then drops as "numeric" — so the family list comes out empty and **no
|
||||
`font:` shorthand can ever be flagged**, regardless of family. Verified end-to-end:
|
||||
`.a-font-shorthand { font: 700 14px/18px Arial; }` produces zero violations (Arial is
|
||||
off-token and should be flagged per the T2 contract). The test suite only covers the
|
||||
`font-family:` longhand, so the hole is untested. The package's own convention is
|
||||
`font: var(--type-*)` everywhere, so nothing currently trips it — but the gate's contract
|
||||
(T2: "off-token font → flagged") is silently incomplete.
|
||||
**Fix:** Simplest contract-true fix — flag any literal (non-var) `font:` shorthand outright,
|
||||
matching the package convention:
|
||||
```js
|
||||
if (prop === 'font' && !/var\(/i.test(value)) {
|
||||
violations.push('literal font: shorthand — use font: var(--type-*) (Rams typography tokens)');
|
||||
} else if (prop === 'font-family' && !/var\(/i.test(value)) {
|
||||
violations.push(...fontViolations(value, tokens));
|
||||
}
|
||||
```
|
||||
(Alternatively repair `familiesFromShorthand` to iteratively strip weight and size/line-height
|
||||
components.) Add a T2-style test case for the shorthand form.
|
||||
|
||||
### WR-03: Color-evasion gaps — named keywords, `oklch()`/`lab()`/`lch()`/`hwb()`/`color()`, and `text-shadow` all pass the audit
|
||||
|
||||
**File:** `theme/scripts/audit.mjs:361-394` (`literalHexViolations` — hex/`rgb()`/`hsl()` only), `audit.mjs:419-429` (`shadowViolations` — `box-shadow`/`backdrop-filter`/`filter` only)
|
||||
**Issue:** Verified evasions (fixture run through the real audit — none flagged):
|
||||
`.b { color: red; }`, `.c { background: oklch(0.6 0.2 30); }`, `.e { fill: color(display-p3 1 0 0); }`,
|
||||
and `.h2 { text-shadow: 0 1px 0 red; }` (any non-hex `text-shadow`). Modern CSS Color 4
|
||||
functions and named keywords are fully unaudited escape hatches, and `text-shadow` (a shadow
|
||||
Rams forbids) is not in `shadowViolations`. For a tool whose purpose is "zero off-token
|
||||
values is evidence, not an eyeball claim", these are known-open doors that later phases can
|
||||
walk through accidentally (e.g. `color: white`, `oklch()` from a design-tool export).
|
||||
**Fix:** Extend the literal checks:
|
||||
```js
|
||||
const NAMED = new Set(['red','green','blue','white','black','gray','grey','yellow','orange',
|
||||
'purple','pink','brown','silver','gold','cyan','magenta','lime','navy','teal','olive',
|
||||
'maroon','aqua','fuchsia']);
|
||||
// in literalHexViolations: flag NAMED keywords on color-ish props
|
||||
// (keep currentcolor/inherit/transparent sanctioned);
|
||||
// flag /\b(?:oklch|oklab|lab|lch|hwb|color|color-mix)\(/ unless the value contains var(.
|
||||
// in shadowViolations: add text-shadow (any non-none value).
|
||||
```
|
||||
|
||||
### WR-04: D-13 contract shortfall — spacing and typography numerics are not audited at all
|
||||
|
||||
**File:** `theme/scripts/audit.mjs:433-446` (`checkDeclaration` — no spacing/type-size/weight/line-height properties), claims at `audit.mjs:8-9`, `theme/gallery.html:155`, `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` (D-13)
|
||||
**Issue:** D-13 states the audit "flags any color/**spacing**/**typography**/radius value not
|
||||
in the token set", the audit header says "any color / typography / radius / shadow / blur
|
||||
value", and the gallery tells the approver "colors, typography, radius, shadows, blur **all**
|
||||
derive from theme/src/tokens.css". Verified: `padding: 13px; gap: 7px;`, `font-size: 17px;
|
||||
font-weight: 650; line-height: 1.7;` all pass unflagged. The typography check is font-family
|
||||
only; spacing is entirely absent. The evidence claim is broader than the implementation.
|
||||
**Fix:** Either implement the missing checks (spacing literals validated against the 8px-grid
|
||||
scale; `font-size`/`font-weight`/`line-height` validated against the `--type-*` scale) or
|
||||
amend the claim wording in `audit.mjs`'s header and `gallery.html` to "color families, font
|
||||
families, radius, shadows, blur" so the recorded DSGN-01 evidence matches what actually runs.
|
||||
|
||||
### WR-05: Static server path guard uses prefix match without separator — sibling `theme*` paths escape the docroot
|
||||
|
||||
**File:** `theme/scripts/screenshot.mjs:68-71`
|
||||
**Issue:** `join(THEME, decodeURIComponent(url.pathname))` then `target.startsWith(THEME)`.
|
||||
A URL-encoded `..%2FthemeX` decodes to `../themeX`, joins to `/…/araharah/themeX`, which
|
||||
**passes** the `startsWith('/…/araharah/theme')` check while being outside the docroot — the
|
||||
classic incomplete-prefix-traversal idiom. Exploitability is low (bound to `127.0.0.1`,
|
||||
ephemeral port, alive only during capture, no CORS headers), but the guard exists precisely
|
||||
to prevent this and fails at its boundary condition.
|
||||
**Fix:**
|
||||
```js
|
||||
import { sep } from 'node:path';
|
||||
// …
|
||||
const target = join(THEME, decodeURIComponent(url.pathname));
|
||||
if (target !== THEME && !target.startsWith(THEME + sep)) {
|
||||
res.writeHead(403); res.end('forbidden'); return;
|
||||
}
|
||||
```
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Dead `send` script
|
||||
|
||||
**File:** `theme/package.json:13`
|
||||
**Issue:** `"send": "mkdir -p dist/fonts && cp -r fonts/geist fonts/space-grotesk dist/fonts/"`
|
||||
is referenced by nothing (not delegated from the root, not a dependency of any other script),
|
||||
and its output is unused — `dist/rams.css` references `url(../fonts/…)`, which resolves from
|
||||
`dist/` to the tracked `theme/fonts/` directly; nothing serves `dist/fonts/`.
|
||||
**Fix:** Remove the script (or document its purpose if one is intended).
|
||||
|
||||
### IN-02: `playwright` is a runtime `dependency` of a CSS package
|
||||
|
||||
**File:** `theme/package.json:8-10`
|
||||
**Issue:** `@araharah/theme` ships no runtime JS, yet `playwright` sits under
|
||||
`dependencies` — any consumer installing the theme package for its CSS pulls the browser
|
||||
toolchain as a prod dependency.
|
||||
**Fix:** Move `playwright` to `devDependencies` (it is only used by `scripts/screenshot.mjs`,
|
||||
a dev-time capture tool).
|
||||
|
||||
### IN-03: `python3` dependency in an otherwise Node-only toolchain
|
||||
|
||||
**File:** `theme/package.json:17`
|
||||
**Issue:** `basemap:preview` shells out to `python3 -m http.server 8123`, introducing a
|
||||
Python runtime requirement into the Node-first toolchain (AGENTS.md free-first/Node-first
|
||||
stack). A tiny Node static server already exists in `screenshot.mjs` and could be extracted
|
||||
and shared.
|
||||
**Fix:** Extract the `serveTheme()` server (with a port argument) into a small shared script,
|
||||
or accept and document the Python requirement.
|
||||
|
||||
### IN-04: Root scripts miss the workspace's `test` and `basemap` entries
|
||||
|
||||
**File:** `package.json:8-14`
|
||||
**Issue:** The root delegates `build`/`sheet`/`gallery`/`audit`/`capture` but not `test` or
|
||||
`basemap` — `npm test` at the repo root fails ("Missing script") even though the theme
|
||||
workspace defines a passing 16/16 test suite, and the basemap rebuild documented in
|
||||
`theme/basemap/build.mjs:31` must be invoked with the longer `--workspace` form.
|
||||
**Fix:** Add `"test": "npm run test --workspace theme"` and
|
||||
`"basemap": "npm run basemap --workspace theme"` to the root scripts.
|
||||
|
||||
### IN-05: Font probing — no fetch timeout; partial substitutions unrecorded
|
||||
|
||||
**File:** `theme/basemap/build.mjs:305-315` (`probeGlyph`), `build.mjs:321-345` (`resolveFontStacks`)
|
||||
**Issue:** (a) `fetch` has no `AbortSignal.timeout`, so a stalled glyphs endpoint hangs the
|
||||
basemap build indefinitely. (b) If *some* of a role's intended stacks are hosted (e.g.
|
||||
`'Geist Regular'` 200 but `'Space Grotesk Regular'` 404), `served.minor` silently becomes the
|
||||
partial list with no `rams-font-substitutions` record — undermining the "honest fallback
|
||||
provenance" the feature exists to provide. (Current committed output hit the clean
|
||||
all-404 → full-fallback path, so both are latent.)
|
||||
**Fix:** Add `{ signal: AbortSignal.timeout(10_000) }` to the probes; record a substitution
|
||||
entry whenever `served[kind]` differs from `FONT_INTENT[kind]`, not only on full fallback.
|
||||
|
||||
### IN-06: Sheet inputs have no accessible name
|
||||
|
||||
**File:** `theme/src/sheet.html:167-168`
|
||||
**Issue:** Both `input-field` text inputs are labeled by placeholder only — no `<label>`/`aria-label` — leaving them nameless to assistive tech.
|
||||
**Fix:** Add `aria-label="Destination search"` / `aria-label="Bot command input"` (or wrap with visually-hidden label text), consistent with the Rams register.
|
||||
|
||||
### IN-07: Mockup capture fallback is invisible in the gallery artifact
|
||||
|
||||
**File:** `theme/scripts/screenshot.mjs:130-138`
|
||||
**Issue:** When the network render of a mockup fails, the committed `screen.png` is copied in
|
||||
as the gallery's "mockup reference render" — indistinguishable from a true render in the
|
||||
final artifact. Only the capture log records the substitution, so a DSGN-02 approver can
|
||||
unwittingly compare the implementation against a fallback capture made under different
|
||||
conditions (offline, no CDN fonts).
|
||||
**Fix:** Emit a machine-readable marker alongside the capture (e.g. a `screenshots/manifest.json`
|
||||
with `{ file, source: "network" | "fallback" }`) and/or note fallback occurrences in the
|
||||
phase summary where DSGN-02 is recorded.
|
||||
|
||||
---
|
||||
|
||||
**Verified-clean aspects (for the record):** `npm run audit` exits 0 on the real build (354
|
||||
declarations, 97 rules, 298 compiled-vocabulary rules out of scope, 49 classes harvested);
|
||||
`node scripts/audit.test.mjs` passes 16/16; `liberty-rams.json` machine-verified: 110 layers,
|
||||
no accent hex/rgb forms, no `line-gradient`, no `icon-color` paints, no non-zero
|
||||
`text-halo-blur`, only the 3 documented untouched layers (`road_area_pattern`,
|
||||
`road_one_way_arrow`, `road_one_way_arrow_opposite`), `building-3d` removed with reason
|
||||
recorded, Noto Sans font substitution honestly recorded in metadata; all 7 self-hosted woff2
|
||||
present and matched 1:1 by the `@font-face` URLs; `--shell-*` slots + dark-scheme media query
|
||||
present in the compiled CSS; the basemap proof page reads the route color from the token
|
||||
package at runtime; the audit's used-class harvesting and utilities-layer scoping behave as
|
||||
documented (verified with fixture pages); the audit does flag `.rounded` the moment any page
|
||||
wears it.
|
||||
|
||||
_Reviewed: 2026-10-22T01:08:55Z_
|
||||
_Reviewer: OpenCode (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
phase: 01-design-tokens-rams-foundation
|
||||
verified: 2026-10-22T02:55:00Z
|
||||
status: passed
|
||||
score: 13/14 plan must-haves verified literally (14/14 at goal level); 4/4 roadmap success criteria verified
|
||||
overrides_applied: 0
|
||||
re_verification: # initial — no previous VERIFICATION.md existed
|
||||
previous_status: null
|
||||
previous_score: null
|
||||
gaps_closed: []
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
gaps: []
|
||||
deferred:
|
||||
- truth: "Basemap POI icons come from the hosted colored OFM sprite (not token-derived)"
|
||||
addressed_in: "Phase 3 / Phase 5 (first product map phase)"
|
||||
evidence: "Logged in phase deferred-items.md #1; Phase 3 SC1 renders the live position on the Rams basemap, Phase 5 renders route display — treatment candidates (text-only POIs, custom grayscale sprite) attached to those map phases"
|
||||
- truth: "Sprite-pattern layers (landcover_wetland, road_area_pattern) left un-derived"
|
||||
addressed_in: "Phase 3 / Phase 5"
|
||||
evidence: "Logged in phase deferred-items.md #3; visible only at zooms above the proof view"
|
||||
- truth: "Self-hosted pbf glyph route for Rams map-label fonts (Space Grotesk/Geist as map glyphs)"
|
||||
addressed_in: "Candidate for Phase 2+ (Worker infra) / product map phases"
|
||||
evidence: "Recorded in liberty-rams.json metadata.rams-font-substitutions reason: 'revisit with a Worker-served glyphs route'; no Phase 1 SC requires self-hosted glyphs, and no later phase SC claims it — tracked here for visibility"
|
||||
---
|
||||
|
||||
# Phase 1: Design Tokens + Rams Foundation — Verification Report
|
||||
|
||||
**Phase Goal:** The Telemetry Rams design system exists as one centralized, importable token set — including map basemap cartography — with a user-approved mockup-vs-implementation gallery proving the fidelity bar before any product screen is built.
|
||||
**Verified:** 2026-10-22T02:55:00Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (roadmap success criteria = the contract)
|
||||
|
||||
| # | Truth (Roadmap SC) | Status | Evidence |
|
||||
|---|--------------------|--------|----------|
|
||||
| 1 | Every rendered Rams component derives exclusively from the centralized token set — audit sweep finds zero off-token values | ✓ VERIFIED | `npm run audit` re-run on freshly rebuilt dist: **AUDIT CLEAN — 354 declarations, 0 off-token values** (55 colors / 9 radius tokens all zero / 7 families / 6 spacing / 10 type sizes / 9 trackings). `border-radius` forms in dist: `0`, `var(--radius)`, `3.40282e38px` (sanctioned rounded-full clamp) — no `.25rem`. tokens.css is the audit allowlist source (`DEFAULT_TOKENS = src/tokens.css`). |
|
||||
| 2 | Side-by-side gallery exists and is user-approved (DSGN-02 mechanism) | ✓ VERIFIED | `theme/gallery.html` (290 lines, CDN-free, token-driven): 4 mockup-vs-sheet rows + basemap row + token ledger (names only) + approval strip; `<img>` links to all 5 committed captures (visually inspected — real renders). `01-04-DSGN-02-APPROVAL.md` records verbatim user response **"approved"** (2026-10-22T00:51:39Z), scope = fidelity bar + basemap direction. |
|
||||
| 3 | Rams-styled MapLibre basemap renders in a browser | ✓ VERIFIED | `theme/basemap/liberty-rams.json` (110 layers, `metadata.rams-cartography.derived-from = theme/src/tokens.css` with per-role derivation formulas) + `index.html` MapLibre proof page (loads `./liberty-rams.json`, route color read at runtime via `getComputedStyle('--color-signal-orange')`). Committed real-browser capture `screenshots/basemap-kl.png` inspected: grayscale/muted KL metro, muted labels, zero orange except the reserved route vector. |
|
||||
| 4 | Design authority is singular (conflict resolved) | ✓ VERIFIED | Root `DESIGN.md` carries the SUPERSEDED-FOR-SPECIFICS banner (conflicting Helvetica/Inter typography + palette explicitly marked superseded, values delegated to the package). `README.md` contains zero hex/px. Mockup tree untouched (git porcelain clean — read-only per D-06, `@source` scoped to its markup only). One token package (`theme/`), all scripts delegate to it. |
|
||||
|
||||
**Score (goal level):** 4/4 success criteria verified
|
||||
|
||||
### Plan-Level Must-Haves (merged from PLAN frontmatters)
|
||||
|
||||
| Plan | Truth | Status | Evidence |
|
||||
|------|-------|--------|----------|
|
||||
| 01 | Tokens importable as plain CSS (`@theme static` + `:root`) | ✓ VERIFIED | tokens.css 176 lines; dist carries token vars |
|
||||
| 01 | Build produces compiled dist with Tailwind v4 utilities | ✓ VERIFIED | `npm run build` → dist/rams.css (90ms, v4.3.3); audit harvests 49 classes from 3 pages |
|
||||
| 01 | Zero Google Fonts CDN refs; self-hosted fonts | ✓ VERIFIED | grep for `fonts.googleapis|fonts.gstatic|cdn.tailwindcss` in theme/ → 0 hits; 7 woff2 files present + `@font-face` blocks + `dist/fonts` |
|
||||
| 01 | DESIGN.md superseded banner | ✓ VERIFIED | lines 1–3 of DESIGN.md |
|
||||
| 02 | Sheet displays every mockup component family | ✓ VERIFIED | Visual inspection of `sheet-full.png`: typography specimen (D-11), buttons, inputs, chips/badges, telemetry card, maneuver banner, checkboxes/toggle |
|
||||
| 02 | Legibility specimen included (D-11) | ✓ VERIFIED | SEC-00 // LEGIBILITY section, 13 `--type-*` specimens |
|
||||
| 02 | Sheet dist CSS contains zero off-token values | ✓ VERIFIED | audit exit 0 post-rebuild; components.css = 104 `var(--` refs, no literal hex (only documented 18×18 checkbox geometry, adjudication 6) |
|
||||
| 03 | Basemap renders: grayscale landing, muted water/roads/labels, zero signal-orange in basemap | ✓ VERIFIED | Capture inspection + metadata; audit-style accent guard in build.mjs (`assertNoAccent`) |
|
||||
| 03 | Values derived from tokens, not hand-tuned | ✓ VERIFIED | build.mjs parses tokens.css; metadata records `desaturate(tokenHex, …)` per role |
|
||||
| 03 | Map labels use self-hosted Space Grotesk/Geist assets (D-10) | ⚠ VERIFIED-WITH-RECORDED-DEVIATION | See "Recorded deviation" below — hosted OpenFreeMap glyphs endpoint only serves Noto Sans (Space Grotesk/Geist 404 at build time, probed); closest hosted stack substituted, recorded in `metadata.rams-font-substitutions` with reason + revisit path. Pre-planned in 01-03 plan body, REVIEW-adjudicated clean, and inside the basemap render the user explicitly approved. |
|
||||
| 04 | Audit is scripted evidence, zero off-token values (D-13) | ✓ VERIFIED | 38/38 regression tests pass (incl. T9–T12 evasion tests); exit 2 radius-drift guard |
|
||||
| 04 | Static side-by-side gallery vs all 4 mockups (D-12) | ✓ VERIFIED | gallery.html, 4 rows + basemap row, all `<img>`s resolve |
|
||||
| 04 | Playwright real-browser capture script committed (D-14) | ✓ VERIFIED | scripts/screenshot.mjs committed; 6/6 captures present and visually verified non-blank |
|
||||
| 04 | User viewed gallery + basemap and approved | ✓ VERIFIED | Approval record, verbatim "approved" |
|
||||
|
||||
**Score (plan level):** 13 verified literally / 14 (1 verified-with-recorded-deviation, no goal-level gap)
|
||||
|
||||
### Recorded Deviation (D-10 map-label glyph stacks) — suggested formal override
|
||||
|
||||
Map label glyph stacks are `Noto Sans Bold / Noto Sans Regular`, not self-hosted Space Grotesk/Geist. MapLibre label glyphs must be served as pbf glyph ranges; the OpenFreeMap glyphs endpoint hosts only Noto Sans and self-hosted woff2 cannot serve glyph ranges without a glyphs route (Worker arrives in Phase 2). The substitution was pre-planned in the 01-03 plan body, is machine-recorded in `metadata.rams-font-substitutions` (intended/served/reason), was adjudicated clean by 01-REVIEW, and the user approved the rendered basemap that displays it. Rams-type *roles* are honored (bold-major / regular-minor hierarchy, muted matte labels). To formalize in the record, the developer may add to this file's frontmatter:
|
||||
|
||||
```yaml
|
||||
overrides:
|
||||
- must_have: "Map labels use the self-hosted Space Grotesk / Geist assets (D-10)"
|
||||
reason: "OpenFreeMap glyphs endpoint hosts only Noto Sans (woff2 cannot serve pbf glyph ranges pre-Worker); closest hosted stack substituted, recorded in style metadata, and covered by the user's DSGN-02 basemap approval"
|
||||
accepted_by: "(developer)"
|
||||
accepted_at: "(timestamp)"
|
||||
```
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
| -------- | -------- | ------ | ------- |
|
||||
| `theme/src/tokens.css` | Token package (55 colors, 10 spacing, 9 radius all zero, 13 type vars) | ✓ VERIFIED | 176 lines; `--radius: 0px` bare key + family zeroed (CR-01 fix commit 61d15c2 present) |
|
||||
| `theme/src/theme.css` | @import tokens + @font-face + scoped @source | ✓ VERIFIED | imports tokens.css + components.css; `@source not ../scripts` / `../basemap` (WR-01 fix present) |
|
||||
| `theme/src/components.css` | var()-only component classes | ✓ VERIFIED | 104 var() refs, no literal hex |
|
||||
| `theme/src/sheet.html` | Component sheet consuming dist/rams.css | ✓ VERIFIED | link at line 15; renders all 7 component families (screenshot inspected) |
|
||||
| `theme/dist/rams.css` | Built stylesheet | ✓ VERIFIED | rebuilt this pass; radius forms token-only; no `.shadow{`/`.blur{` (WR-01) |
|
||||
| `theme/basemap/build.mjs` + `liberty-rams.json` + `index.html` | Token-derived basemap + render proof | ✓ VERIFIED | derivation provenance in metadata; style JSON valid; proof page wired |
|
||||
| `theme/scripts/audit.mjs` (+ test harness) | Automated D-13 audit | ✓ VERIFIED | 814 lines; scans dist, parses tokens.css allowlist, 38/38 tests |
|
||||
| `theme/scripts/screenshot.mjs` | Playwright capture toolchain (D-14) | ✓ VERIFIED | WR-05 docroot guard fix present (`startsWith(THEME + sep)`) |
|
||||
| `theme/gallery.html` | DSGN-02 gallery (min 80 lines) | ✓ VERIFIED | 290 lines, CDN-free |
|
||||
| `theme/fonts/{geist,space-grotesk}/*.woff2` | Self-hosted typefaces | ✓ VERIFIED | 7 files |
|
||||
| `.gitignore` (+ Zone scrub) | Repo hygiene | ✓ VERIFIED | 0 `*:Zone.Identifier` files remain; secrets/node_modules/dist entries present |
|
||||
| `package.json` (root) | Workspace delegates | ✓ VERIFIED | build/sheet/gallery/audit/capture → theme workspace |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
| ---- | -- | --- | ------ | ------- |
|
||||
| theme/src/theme.css | theme/src/tokens.css | CSS @import | ✓ WIRED | line 14 |
|
||||
| theme build | theme/dist/rams.css | tailwindcss CLI | ✓ WIRED | ran this pass (90ms) |
|
||||
| theme/src/sheet.html | theme/dist/rams.css | `<link rel=stylesheet>` | ✓ WIRED | line 15 |
|
||||
| theme/src/components.css | tokens.css | var(--color-*)/var(--spacing-*) | ✓ WIRED | 104 refs; literal scan clean |
|
||||
| theme/basemap/build.mjs | theme/src/tokens.css | token parse | ✓ WIRED | metadata `derived-from`; derivation report to stdout |
|
||||
| theme/basemap/index.html | liberty-rams.json | MapLibre style URL | ✓ WIRED | line 115 |
|
||||
| theme/basemap/index.html | token package | getComputedStyle at runtime | ✓ WIRED | reads `--color-signal-orange`/`--color-matte-black` |
|
||||
| theme/scripts/audit.mjs | tokens.css (allowlist) + dist/rams.css (scan) | default paths | ✓ WIRED | exit 0 run; exit-1/2 behavior covered by 38 tests |
|
||||
| theme/scripts/screenshot.mjs | theme/screenshots/* | Playwright captures | ✓ WIRED | 6/6 captures on disk |
|
||||
| theme/gallery.html | theme/screenshots/* | `<img>` rows | ✓ WIRED | 9 img elements incl. basemap row |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
| -------- | ------------- | ------ | ------------------ | ------ |
|
||||
| basemap/index.html | route placeholder color | `getComputedStyle(--color-signal-orange)` from dist/rams.css | Yes (token value, not hand-tuned) | ✓ FLOWING |
|
||||
| liberty-rams.json | all cartography colors | tokens.css parse + desaturate() | Yes (provenance metadata per role) | ✓ FLOWING |
|
||||
| sheet.html / gallery.html | styles | dist/rams.css tokens | Yes (renders verified visually) | ✓ FLOWING (static design artifacts — no dynamic data expected) |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
| -------- | ------- | ------ | ------ |
|
||||
| Build produces dist | `npm run build --workspace theme` | Done in 90ms, v4.3.3 | ✓ PASS |
|
||||
| Audit sweep zero off-token | `npm run audit` | AUDIT CLEAN — 354 declarations, 0 off-token (exit 0), re-run post-rebuild | ✓ PASS |
|
||||
| Theme test suite | `node theme/scripts/audit.test.mjs` | 38 PASS / 0 FAIL (first invocation hit a stale `/tmp/opencode/audit-test` symlink from the fixer's sandbox — environment artifact, not a test failure; clean re-run passed) | ✓ PASS |
|
||||
| CR-01 fix: bare `rounded` var-driven | grep dist | `.rounded` → `border-radius:var(--radius)`, `--radius:0px` emitted; no `.25rem` | ✓ PASS |
|
||||
| Basemap renders as Rams cartography | inspect committed `screenshots/basemap-kl.png` (real-browser capture) | Grayscale/muted map, muted labels, orange only on reserved route vector | ✓ PASS |
|
||||
| Sheet renders all component families | inspect committed `screenshots/sheet-full.png` | Typography specimen + buttons + inputs + chips + card + banner + switches, zero radius | ✓ PASS |
|
||||
| Renders without committed captures | serve theme/ + open gallery.html live | Not re-run live — captures committed post-fix and no worn class changed rendering between capture and now (`.rounded`/`.shadow`/`.blur` worn by zero pages) | ? SKIP (evidence already real-browser + user-approved) |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan(s) | Description | Status | Evidence |
|
||||
| ----------- | -------------- | ----------- | ------ | -------- |
|
||||
| DSGN-01 | 01-01, 01-02, 01-03 | All screens render from a centralized token set — no off-token values; basemap cartography follows the same tokens | ✓ SATISFIED | Audit exit 0 (scripted evidence, D-13); tokens.css single source; basemap derived per-role from tokens (D-09); component/basemap renders verified |
|
||||
| DSGN-02 | 01-04 | Mockup-vs-implementation gallery reviewed for fidelity before proceeding | ✓ SATISFIED | gallery.html + Playwright captures + recorded verbatim approval ("approved", 00:51:39Z); mechanism documented for per-phase reuse |
|
||||
|
||||
**Orphaned requirements:** none — REQUIREMENTS.md traceability maps exactly DSGN-01/DSGN-02 to Phase 1, and both are claimed by plans. (Info: the traceability table's "Pending" checkbox text was not flipped to Complete — doc-maintenance nit, not a gap.)
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
| ---- | ---- | ------- | -------- | ------ |
|
||||
| theme/src/theme.css | 31–34 | `@source` scans mockup markup (Tailwind Play-CDN replacement pattern) | ℹ️ Info | Intentional and deliberately narrowed (WR-01) — prose leakage tests exist (T5) |
|
||||
| theme/basemap/index.html | 127 | "PLACEHOLDER ROUTE VECTOR" | ℹ️ Info | Documented scope staging — route rendering is Phase 5 per plan; reads color from tokens at runtime |
|
||||
| stitch_minimalist_route_navigator/*/code.html | — | legacy inline `onclick` demo handlers | ℹ️ Info | Read-only mockups (D-06), untouched by design |
|
||||
| DESIGN.md | 31–39 | hex/px restated in legacy philosophy prose | ℹ️ Info | Under explicit SUPERSEDED-FOR-SPECIFICS banner naming palette/typography as superseded; README carries zero numerics; D-05 pre-authorized keeping philosophy prose |
|
||||
|
||||
No TODO/FIXME/stub markers; no blocker or warning anti-patterns.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
None outstanding. The phase's blocking human checkpoint (DSGN-02 gallery + basemap review) was completed on 2026-10-22 with a recorded verbatim approval — the one item a human must decide in this phase is already decided and archived (`01-04-DSGN-02-APPROVAL.md`). Visual fidelity, real-browser rendering, and basemap appearance were all covered by that approved review, and the committed captures inspected in this verification match what was approved (no worn class changed rendering between capture time and the post-fix state).
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No goal-level gaps. All four roadmap success criteria verified against the live codebase, with the code-review blocker fix (CR-01) and all five warnings (WR-01..05) confirmed present in the artifacts and the 38-test regression suite green. One documented, pre-adjudicated, user-approved deviation exists at plan-truth level (D-10 map-label glyph stacks → closest hosted stack, machine-recorded) — it does not block the phase goal, has a recorded revisit path, and a formal override entry is suggested above if the developer wants it in the durable record.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T02:55:00Z_
|
||||
_Verifier: OpenCode (gsd-verifier)_
|
||||
@@ -0,0 +1,11 @@
|
||||
# Phase 01 — Deferred Items (out-of-scope discoveries)
|
||||
|
||||
Log of issues found during execution that belong to other plans/phases.
|
||||
Do NOT fix here. (Executor scope boundary: only fix issues directly
|
||||
caused by the current task's changes.)
|
||||
|
||||
| # | Found during | Item | Disposition |
|
||||
|---|--------------|------|-------------|
|
||||
| 1 | plan 03 (basemap) | Liberty POI icons come from the hosted ofm sprite (`https://tiles.openfreemap.org/sprites/ofm_f384/ofm`) as colored raster icons — outside the plan's paint-property derivation list (`poi_r1/r7/r20` icon-image; not visible at the z13 proof view since POIs appear z15+) | Candidate treatment for the first product map phase (Phase 3 shell / Phase 5 guidance): text-only POIs, icon-opacity reduction, or a custom grayscale sprite served from the Worker |
|
||||
| 2 | plan 03 (basemap) | Upstream liberty warnings: `Expected value to be of type number, but found null instead.` ×3 at the KL view — verified present with the ORIGINAL untransformed liberty style too (scratch comparison page) | Upstream tile-data quirk; not caused by the Rams transform; no action in this phase |
|
||||
| 3 | plan 03 (basemap) | Sprite-pattern layers left untouched (no color paint props to derive): `landcover_wetland` (wetland texture pattern), `road_area_pattern` | Visible only at higher zooms; candidate for a custom Rams pattern sprite in a later map phase |
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- bot/package.json
|
||||
- bot/tsconfig.json
|
||||
- bot/wrangler.jsonc
|
||||
- bot/.gitignore
|
||||
- bot/.dev.vars.example
|
||||
- bot/src/config.ts
|
||||
- bot/test/harness.mjs
|
||||
- bot/test/runner.mjs
|
||||
- bot/test/initdata.test.mjs
|
||||
autonomous: true
|
||||
requirements: [ONBD-01, PRIV-01, PRIV-02]
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-09/D-10/D-11: backend lives in a `bot/` TypeScript workspace sibling to `theme/`, with the explicit src/ tree contracted via bot/src/config.ts"
|
||||
- "D-12: npm test -w bot runs a zero-dependency hand-rolled harness (audit.test.mjs shape) reporting PASS/FAIL/exit codes — substrate for all phase REQ verification"
|
||||
- "D-15/D-17: the initdata vector suite RED-demonstrates the security-gate contract BEFORE implementation exists (TDD proof for RESEARCH Pitfalls 1–3), with no secret value ever in the repo"
|
||||
- "D-11/D-15 contracts reach later plans: Env interface + wrangler entry points exist before implementation"
|
||||
artifacts:
|
||||
- path: "bot/package.json"
|
||||
provides: "Workspace scripts/deps (grammy ^1.46.0, wrangler ^4.151.3, TS ^5.9.3)"
|
||||
contains: "@araharah/bot"
|
||||
- path: "bot/src/config.ts"
|
||||
provides: "Env contract consumed by plans 02/03"
|
||||
contains: "export interface Env"
|
||||
- path: "bot/test/runner.mjs"
|
||||
provides: "D-12 hand-rolled runner, auto-discovers *.test.mjs, suite-load failures become FAILs"
|
||||
- path: "bot/test/initdata.test.mjs"
|
||||
provides: "Vector suite T1–T7 (known-good/tamper/stale/boundary/order/malformed/RFC-4231 pin)"
|
||||
- path: "bot/wrangler.jsonc"
|
||||
provides: "Worker config: main src/webhook.ts + chat_sessions KV binding"
|
||||
contains: "chat_sessions"
|
||||
key_links:
|
||||
- from: "bot/test/initdata.test.mjs"
|
||||
to: "bot/src/security/initdata.ts"
|
||||
via: "import "../src/security/initdata.ts" (RED until plan 02)"
|
||||
pattern: "initdata\\.ts"
|
||||
- from: "bot/wrangler.jsonc"
|
||||
to: "src/webhook.ts"
|
||||
via: "main entry binding"
|
||||
pattern: "\"main\": \"src/webhook.ts\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
Stand up the `bot/` npm workspace (D-09/D-10/D-11) with the hand-rolled test harness (D-12) and a RED initdata vector suite — the Wave-0 substrate every later verification run (`npm test -w bot`) executes on.
|
||||
|
||||
Purpose: Plans 02 and 03 fill in the security core and command surface against this scaffold; the initdata suite written here goes RED now and goes GREEN in plan 02 (TDD proof of the phase's security exit criterion).
|
||||
Output: Installable workspace, type-checkable `src/config.ts` contract, runner + RED vector suite.
|
||||
</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>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
|
||||
@theme/package.json
|
||||
@theme/scripts/audit.test.mjs
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts this plan creates that later plans implement against. -->
|
||||
|
||||
From bot/src/config.ts (created here, consumed by plans 02/03):
|
||||
```typescript
|
||||
export interface Env {
|
||||
BOT_TOKEN: string; // wrangler secret (D-15) — Telegram Bot API token from @BotFather
|
||||
WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token
|
||||
chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02)
|
||||
}
|
||||
```
|
||||
|
||||
Test harness API this plan creates (from `bot/test/harness.mjs`, consumed by every later `*.test.mjs`):
|
||||
```javascript
|
||||
export function test(name, fn) // register one async case; fn() throws → FAIL
|
||||
```
|
||||
|
||||
Runner contract (from `bot/test/runner.mjs`):
|
||||
- Scans `bot/test/*.test.mjs`, dynamic-imports each; a file that fails to import is recorded as one failing case (`suite load failed: <file>`), not a crash
|
||||
- Prints `PASS`/`FAIL <name>` per case + summary `N passed, M failed`; exit 0 only when M = 0
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: bot workspace scaffold with Env contract (D-09, D-10, D-11, D-15)</name>
|
||||
<files>package.json, bot/package.json, bot/tsconfig.json, bot/wrangler.jsonc, bot/.gitignore, bot/.dev.vars.example, bot/src/config.ts</files>
|
||||
<read_first>
|
||||
- ./package.json (current root — has workspaces ["theme"], delegate scripts pattern to extend)
|
||||
- ./theme/package.json (Phase 1 workspace precedent: scripts/deps shape)
|
||||
- ./theme/scripts/audit.test.mjs (runner shape D-12 builds on — read the header + structure)
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-09/D-10/D-11/D-15 exact wording)
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md (Standard Stack versions + Recommended Project Structure)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **Root package.json (edit, don't replace):** workspaces becomes `["theme", "bot"]` (D-09). Add delegate scripts mirroring the Phase 1 pattern:
|
||||
```json
|
||||
"bot:test": "npm test -w bot",
|
||||
"bot:typecheck": "npm run typecheck -w bot",
|
||||
"bot:dev": "npm run dev -w bot",
|
||||
"bot:deploy": "npm run deploy -w bot",
|
||||
"bot:commands": "npm run commands -w bot"
|
||||
```
|
||||
Keep existing theme scripts untouched.
|
||||
|
||||
2. **bot/package.json (new):**
|
||||
```json
|
||||
{
|
||||
"name": "@araharah/bot",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node test/runner.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"commands": "node scripts/register-commands.mjs"
|
||||
},
|
||||
"dependencies": { "grammy": "^1.46.0" },
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.151.3",
|
||||
"typescript": "^5.9.3",
|
||||
"@cloudflare/workers-types": "^5.20261022.1",
|
||||
"@types/node": "^26.6.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
Versions are registry-verified in RESEARCH.md — use exactly these ranges.
|
||||
|
||||
3. **bot/tsconfig.json:** strict, Workers-native, noEmit (wrangler/esbuild does the real build; `allowImportingTsExtensions` pairs with noEmit and matches Node 24 type-stripping for the scripts/ D-12 runner):
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
Constraint for all bot/ TS written in this phase: **erasable-only TypeScript** (no enums, no parameter properties, no namespaces — only type stripping survives). Node 24 runs `.ts` natively under this constraint [VERIFIED in RESEARCH.md].
|
||||
|
||||
4. **bot/wrangler.jsonc (config, not secrets — D-17):**
|
||||
```jsonc
|
||||
{
|
||||
// KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions`
|
||||
"name": "araharah-bot",
|
||||
"main": "src/webhook.ts",
|
||||
"compatibility_date": "2026-10-22",
|
||||
"kv_namespaces": [{ "binding": "chat_sessions", "id": "KV_ID_PLACEHOLDER" }]
|
||||
}
|
||||
```
|
||||
|
||||
5. **bot/.gitignore:** exactly `.dev.vars` and `.wrangler/` (D-15/D-17 — secret hygiene from the first commit; Phase 1 precedent for self-hosted assets mirrors backend secrets).
|
||||
|
||||
6. **bot/.dev.vars.example (shape only, no real values):**
|
||||
```
|
||||
# Copy to .dev.vars and fill for local wrangler dev (D-15). NEVER commit .dev.vars.
|
||||
BOT_TOKEN=
|
||||
WEBHOOK_SECRET=
|
||||
WEBHOOK_URL=
|
||||
```
|
||||
(WEBHOOK_URL is set once after the first deploy; `scripts/set-webhook.mjs` in plan 03 reads it.)
|
||||
|
||||
7. **bot/src/config.ts (interface contract only — no implementation beyond types):** the `Env` interface exactly as in `<interfaces>` above (D-10: grammY + workers-types first-class).
|
||||
|
||||
8. Run `npm i -w bot` (creates/updates root package-lock.json; verify grammy@1.46.x and wrangler@4.15x land under node_modules/bot's tree).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm i -w bot && npm run bot:typecheck; test -f bot/src/config.ts && test -f bot/.dev.vars.example && test -f bot/.gitignore && git check-ignore bot/.dev.vars && echo SCAFFOLD_OK</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run bot:typecheck` exits 0 (src/config.ts type-checks against @cloudflare/workers-types)
|
||||
- `bot/src/config.ts` contains `export interface Env` with members `BOT_TOKEN`, `WEBHOOK_SECRET`, `chat_sessions: KVNamespace`
|
||||
- root package.json contains `"workspaces": ["theme", "bot"]` (order irrelevant) and script keys `bot:test`, `bot:deploy`
|
||||
- `bot/package.json` contains `"name": "@araharah/bot"` and dependency `grammy` in the ^1.46.0 range
|
||||
- `bot/wrangler.jsonc` contains `"main": "src/webhook.ts"` and binding `chat_sessions`
|
||||
- `bot/.gitignore` contains both `.dev.vars` and `.wrangler/`
|
||||
- `bot/.dev.vars.example` contains `BOT_TOKEN=` and `WEBHOOK_SECRET=` and no actual token value
|
||||
- `git check-ignore bot/.dev.vars` exits 0 (secret mirror is actually ignored)
|
||||
</acceptance_criteria>
|
||||
<done>bot workspace installs, typechecks clean, wrangler entry points and secret-mirror files exist; nothing secret-value is committed (D-15/D-17).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: Wave 0 harness + RED initdata vector suite (D-12, TDD RED phase)</name>
|
||||
<files>bot/test/harness.mjs, bot/test/runner.mjs, bot/test/initdata.test.mjs</files>
|
||||
<read_first>
|
||||
- ./theme/scripts/audit.test.mjs (the D-12 "same shape" precedent — node:assert, exit codes, /tmp/opencode-style out-of-repo fixtures)
|
||||
- {bot/.dev.vars.example path too} not needed here; read .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § "Validation Architecture" and § Code Examples (vector contract)
|
||||
</read_first>
|
||||
<behavior>
|
||||
All cases begin failing (RED) because `../src/security/initdata.ts` does not exist yet — the runner must report the failed suite load as FAIL cases, not a crash. Plan 02 task 1 makes them GREEN. Vector contract (RFC-4231 cross-pinned in-suite):
|
||||
- T1 known-good: initData built with test token `12345:TESTTOKEN`, fields `user={"id":42,"first_name":"Zul","language_code":"ms"}`, `auth_date=<now-S>`, `hash=<computed-in-test via node:crypto>` → validateInitData resolves `{ ok: true }` and exposes `user.id === 42`, `languageCode === "ms"`
|
||||
- T2 tampered: any char of `hash` flipped → `{ ok:false, reason:"tampered" }`
|
||||
- T3 stale: `auth_date` = now − 3600 with default 900s window → `reason:"stale"` (D-13)
|
||||
- T4 freshness boundary: age 899s → ok; age 901s → stale
|
||||
- T5 order-independence: fields inserted out of alphabetical order (`user` before `auth_date`) still validate (RESEARCH Pitfall 1)
|
||||
- T6 malformed: missing `hash` field → `reason:"malformed"`; `auth_date` non-numeric → `reason:"malformed"`; `user` JSON missing `id` → `reason:"malformed"`
|
||||
- T7 suite self-check: `node:crypto` HMAC(key="key", data="data") hex equals `5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0` (independence pin — src under test uses SubtleCrypto, so primitive cross-check is real)
|
||||
</behavior>
|
||||
<action>
|
||||
1. **bot/test/harness.mjs** (the registry):
|
||||
```javascript
|
||||
const tests = [];
|
||||
export function test(name, fn) { tests.push({ name, fn }); }
|
||||
export const registry = tests;
|
||||
```
|
||||
Zero dependencies (D-12).
|
||||
|
||||
2. **bot/test/runner.mjs** (entry; D-12 shape from audit.test.mjs):
|
||||
- `import { readdirSync } from "node:fs"` + `path` from `node:path`; resolve suites as `readdirSync(import.meta.dirname).filter(f => f.endsWith(".test.mjs")).sort()`
|
||||
- For each suite: `await import(`./${file}`)` inside try/catch. On load error, push `{ name: "suite load failed: " + file, fn: () => { throw loadErr; } }` into the registry (RED survives, never crashes the run)
|
||||
- Run all registered cases in order: pass → print `PASS <name>`; throw → print `FAIL <name>` + `error.message`/stack, keep going
|
||||
- End with /\n summary `\n[runner] N passed, M failed\n` and `process.exitCode = M > 0 ? 1 : 0`
|
||||
3. **bot/test/initdata.test.mjs:** implement T1–T7 above as async cases via `import { test } from "./harness.mjs"` and `import { validateInitData } from "../src/security/initdata.ts"` (missing import = the RED state). The helper that builds a signed initData string must live IN the test file using `node:crypto` (createHmac twice: secret = HMAC(key="WebAppData", data=botToken); hash = HMAC(key=secretHexBytes, data=dataCheckString)) — independent of the SubtleCrypto path under test (T7 pins both primitives). Fixtures stay in-process; nothing written into the repo.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run bot:typecheck && npm test -w bot; echo "exit=$?"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w bot` prints at least 7 FAIL lines matching the T1–T7 contract and exits 1 (documented RED)
|
||||
- The runner summary line matches `N passed, M failed` with M > 0
|
||||
- A suite-load failure prints as `FAIL suite load failed: initdata.test.mjs` (or per-case FAILs) — the runner exits with code 1 after printing all summaries, never crashes mid-run
|
||||
- `node test/runner.mjs` with zero suite failures (temporarily verify by `cd bot && node test/runner.mjs` after hiding suites) prints `[runner] 0 passed, 0 failed` and exits 0
|
||||
- `npm run bot:typecheck` remains exit 0 (tests are .mjs, outside tsc include — intentional; they run via Node type-stripping)
|
||||
</acceptance_criteria>
|
||||
<done>Runner + harness exist with zero dependencies; the initdata vector suite is written to its final contract and demonstrably RED (plan 02 goes GREEN against it); D-12 replicated in a second workspace.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| repo → git history | Secrets and local-env values cross into permanent history on any accidental `git add` |
|
||||
| .dev.vars → local dev runtime | Local secrets enter the dev process |
|
||||
| npm registry → workspace tree | Supply-chain surface when installing grammy/wrangler/typescript |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-04 | Information Disclosure | bot/.gitignore, bot/.dev.vars.example | mitigate | `.dev.vars` ignored in the SAME commit that introduces the secret mirror (D-15/D-17); verify with `git check-ignore` in acceptance criteria |
|
||||
| T-02-07 | Tampering | bot/wrangler.jsonc, bot/tsconfig.json | mitigate | Exact registry-verified versions (^1.46.0, ^4.151.3, ^5.9.3 …) and lockfile from `npm i -w bot`; dry-run + typecheck gates in later plans catch env/shape drift |
|
||||
| T-02-08 | DoS (supply chain) | bot/package.json deps | accept | Only 1 runtime dep (grammy) for a single-person project; versions pinned; lockfile committed; no paid/obscure packages |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm i` at repo root succeeds with the new bot workspace
|
||||
- `npm run bot:typecheck` exit 0
|
||||
- `npm test -w bot` runs the full harness and exits 1 with the RED suite visible (this is the expected, documented state at end of this plan — plan 02 flips it)
|
||||
- `git check-ignore bot/.dev.vars` → ignored
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- bot/ workspace scaffolded per D-09/D-10/D-11 with confirmed-clean typecheck and a working zero-dep test runner
|
||||
- Wave 0 complete per 02-VALIDATION.md: harness + vector suite skeleton exist and FAIL (RED) before any implementation exists
|
||||
- No secret values in the repo; `.dev.vars` ignored from the first commit
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- bot/src/security/initdata.ts
|
||||
- bot/src/kv/sessions.ts
|
||||
- bot/test/sessions.test.mjs
|
||||
- bot/test/privacy.test.mjs
|
||||
autonomous: true
|
||||
requirements: [PRIV-02]
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13: tampered, stale (auth_date older than ~15 min), and malformed initData are rejected with typed rejection classes; known-good HMAC passes (ROADMAP SC3 — the security gate)"
|
||||
- "D-06: a validated identity mints exactly one live opaque token; D-07: re-mint rotates and deletes the previous token"
|
||||
- "D-08/PRIV-02: every KV write carries an expirationTtl — no write path can persist without TTL; the claim is exactly user_id + chat_id + issued_at + language_code, never more"
|
||||
- "D-11: rejection classes vocabulary (tampered/stale + expired-on-read) matches D-14's category-only log contract downstream"
|
||||
artifacts:
|
||||
- path: "bot/src/security/initdata.ts"
|
||||
provides: "validateInitData + WebAppInitUser hand-rolled types (grammy does not ship them — research-verified)"
|
||||
exports: ["validateInitData", "InitDataRejection"]
|
||||
- path: "bot/src/kv/sessions.ts"
|
||||
provides: "TTL-only KV access layer: mint/rotate/claim getters"
|
||||
exports: ["SESSION_TTL_SECONDS", "mintSession", "getClaimByToken", "getTokenForChat"]
|
||||
- path: "bot/test/sessions.test.mjs"
|
||||
provides: "Rotate-on-new invariant + D-08 key-set-equality + TTL assertions with mock KV"
|
||||
- path: "bot/test/privacy.test.mjs"
|
||||
provides: "PRIV-02 architecture gate: every .put( line carries expirationTtl"
|
||||
key_links:
|
||||
- from: "bot/src/security/initdata.ts"
|
||||
to: "npm test -w bot initdata suite"
|
||||
via: "validateInitData consumes the plan-01 T1–T7 suite (GREEN here)"
|
||||
pattern: "validateInitData"
|
||||
- from: "bot/src/kv/sessions.ts"
|
||||
to: "bot/src/security/initdata.ts"
|
||||
via: "languageCode flows from validated initData into SessionClaim (D-08)"
|
||||
pattern: "language_code"
|
||||
- from: "bot/src/kv/sessions.ts"
|
||||
to: "KVNamespace.put"
|
||||
via: "every put call line includes expirationTtl: SESSION_TTL_SECONDS"
|
||||
pattern: "expirationTtl"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the phase's security exit criterion as pure, testable code: HMAC-SHA256 initData validation with rejection classes, and the TTL-only KV session layer with rotate-on-new — making the RED vector suite from plan 01 GREEN and adding the PRIV-02 storage gates.
|
||||
|
||||
Purpose: "The initData validator is the first backend artifact" (ROADMAP goal) — provability comes from the vector suite, privacy-by-construction from the type-enforced TTL layer.
|
||||
Output: `src/security/initdata.ts` + `src/kv/sessions.ts`, both GREEN under `npm test -w bot` (`sessions` and `privacy` suites included).
|
||||
</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>
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts created in plan 01 that this plan consumes; contracts this plan creates for plan 03. -->
|
||||
|
||||
From bot/src/config.ts (plan 01):
|
||||
```typescript
|
||||
export interface Env {
|
||||
BOT_TOKEN: string;
|
||||
WEBHOOK_SECRET: string;
|
||||
chat_sessions: KVNamespace;
|
||||
}
|
||||
```
|
||||
|
||||
Test harness (plan 01): `import { test } from "./harness.mjs"` inside bot/test/*.test.mjs; runner auto-discovers every `*.test.mjs`.
|
||||
|
||||
API this plan creates for plan 03 (bot/src/security/initdata.ts):
|
||||
```typescript
|
||||
export type InitDataRejection = "malformed" | "tampered" | "stale";
|
||||
|
||||
export interface ParsedInitData {
|
||||
user: { id: number; first_name?: string; username?: string; language_code?: string; [k: string]: unknown };
|
||||
chat?: { id: number; type: string; [k: string]: unknown };
|
||||
authDate: number; // seconds-since-epoch (Pitfall 3: never ms)
|
||||
rawFields: Map<string, string>; // decoded field values, `hash`/`signature` excluded
|
||||
}
|
||||
|
||||
export interface WebAppInitUser { id: number; first_name?: string; last_name?: string; username?: string; language_code?: string; photo_url?: string; is_premium?: boolean; added_to_attachment_menu?: boolean; allows_write_to_pm?: boolean; } // hand-rolled — NOT shipped by grammy 1.46.0 (research-verified gap)
|
||||
|
||||
export async function validateInitData(
|
||||
raw: string,
|
||||
botToken: string,
|
||||
opts?: { nowSeconds?: number; maxAgeSeconds?: number },
|
||||
): Promise<
|
||||
| { ok: true; user: WebAppInitUser; chat?: { id: number; type: string }; languageCode: string | null; authDate: number }
|
||||
| { ok: false; reason: InitDataRejection }
|
||||
>;
|
||||
// defaults: maxAgeSeconds = 900 (D-13), nowSeconds = Math.floor(Date.now() / 1000)
|
||||
```
|
||||
|
||||
API this plan creates for plan 03 (bot/src/kv/sessions.ts):
|
||||
```typescript
|
||||
export const SESSION_TTL_SECONDS = 900; // D-13 pairing — "minutes" envelope
|
||||
|
||||
export interface SessionClaim {
|
||||
user_id: number; // D-08: the entire claim surface — nothing else ever stored
|
||||
chat_id: number;
|
||||
issued_at: number; // seconds
|
||||
language_code: string | null;
|
||||
}
|
||||
|
||||
export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise<string>;
|
||||
// mints crypto.randomUUID() token; rotates (D-07): reads chat:<chat_id> pointer,
|
||||
// deletes the previous `session:<oldToken>` key, then puts session:<newToken> and chat:<chat_id> —
|
||||
// both puts ALWAYS carry { expirationTtl: SESSION_TTL_SECONDS } (PRIV-02, type-enforced by usage).
|
||||
|
||||
export async function getTokenForChat(kv: KVNamespace, chatId: number): Promise<string | null>;
|
||||
|
||||
export async function getClaimByToken(kv: KVNamespace, token: string): Promise<SessionClaim | null>;
|
||||
// null = expired/unknown — callers treat as "expired" rejection class (D-14 vocabulary).
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: validateInitData — HMAC core with rejection classes (D-13, D-14; GREEN the RED suite)</name>
|
||||
<files>bot/src/security/initdata.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples + § Common Pitfalls 1–3 (algorithm, key derivation direction, sorting)
|
||||
- bot/test/initdata.test.mjs (the exact contract being GREENed — read before implementing)
|
||||
- bot/src/config.ts (existing Env; initdata.ts must not import Env — it is pure (raw, botToken) by D-11)
|
||||
</read_first>
|
||||
<behavior>
|
||||
Green conditions come from the plan 01 suite (T1–T7): known-good → ok + parsed user/languageCode; tampered hash → "tampered"; age 3600s → "stale"; 899s ok / 901s stale; out-of-order field insertion still OK; missing hash / NaN auth_date / user without id → "malformed"; RFC-4231 pin cross-check in-suite.
|
||||
</behavior>
|
||||
<action>
|
||||
Implement exactly per the RESEARCH.md-verified algorithm (no deviations; no third-party packages):
|
||||
1. **Parse:** split `raw` on `&` into `key=value` pairs; values URL-decode once (use `decodeURIComponent` on each value after split — `+` in initData is literal per Telegram's querystring encoding; do NOT use URLSearchParams because it converts `+` to space). Exclude both `hash` and `signature` from the check-string set but parse `hash`/`auth_date`/`user` normally when present. Keep insertion map (`rawFields`).
|
||||
2. **Data-check-string:** iterate field entries sorted **by field name** (default JS string sort — byte/case-sensitive), format `${key}=${value}`, join with `"\n"` (0x0A).
|
||||
3. **Key derivation (Pitfall 2 — direction):**
|
||||
- secretKey bytes = HMAC-SHA256(message = UTF-8(botToken), key = UTF-8("WebAppData"))
|
||||
- checkHex = hex(HMAC-SHA256(message = UTF-8(dataCheckString), key = secretKey))
|
||||
Both via `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" }, false, ["sign"])` + `crypto.subtle.sign` (identical API in Workers + Node 24 — research-verified).
|
||||
4. **Rejection precedence and classes (D-14 vocabulary):** raw without `hash` → `malformed`; `user` absent/unparseable JSON or missing numeric `id` → `malformed`; `auth_date` absent/non-numeric → `malformed`; checkHex mismatch (timing-safe hex compare — compare via constant-time byte loop on UTF-8 bytes, mirroring grammY's shipped approach) → `tampered`; `nowSeconds - authDate > maxAgeSeconds` (default 900) → `stale`; else `{ ok: true, user, chat, languageCode: user.language_code ?? null, authDate }`.
|
||||
5. **Types:** hand-roll `WebAppInitUser`/`WebAppInitChat` in this file from Telegram's WebAppInitData spec (research gap resolution — grammy does NOT ship these, verified). Export `InitDataRejection`/`ParsedInitData` per `<interfaces>` so plan 03's endpoint has a typed surface.
|
||||
6. Erasable-only TS (plan 01 constraint). Module imports use explicit `.ts` extensions (`allowImportingTsExtensions` is on; no runtime imports needed here besides types — keep the file dependency-free of grammY so it stays testable without provider deps (D-11)).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w bot` exits 0 with the initdata suite fully PASS (T1–T7 listed PASS) — RED suite is GREEN
|
||||
- `bot/src/security/initdata.ts` contains `export async function validateInitData`, `InitDataRejection`, and the string `"WebAppData"` exactly once (the key derivation constant)
|
||||
- No `import` of `grammy` inside `bot/src/security/initdata.ts` (D-11 isolation — grep returns 0)
|
||||
- `npm run bot:typecheck` exits 0
|
||||
- Sorting is by field name: suite T5 (out-of-order insertion) passes
|
||||
</acceptance_criteria>
|
||||
<done>The validator passes the pinned vector suite including tamper/stale/boundary/order/well-formed classes under SubtleCrypto (D-13 window wired via maxAgeSeconds default 900).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06, D-07, D-08, D-14)</name>
|
||||
<files>bot/src/kv/sessions.ts, bot/test/sessions.test.mjs, bot/test/privacy.test.mjs</files>
|
||||
<read_first>
|
||||
- bot/src/security/initdata.ts (just written — languageCode flows into the claim; SessionClaim contract above)
|
||||
- bot/test/harness.mjs + bot/test/runner.mjs (auto-discovery + `test()` API from plan 01)
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 3 (TTL-only KV access layer) + Open Questions resolution (rotate mechanics: delete-then-mint via chat pointer)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **bot/src/kv/sessions.ts** per the `<interfaces>` contract:
|
||||
- `SESSION_TTL_SECONDS = 900` (D-13 pairing — a session cannot outlive the freshness proof that minted it; "minutes" envelope per CONTEXT).
|
||||
- Key scheme: claim record under `session:<token>`; per-chat pointer under `chat:<chat_id>` holding the current token string. Both keys ALWAYS written with `{ expirationTtl: SESSION_TTL_SECONDS }`.
|
||||
- `mintSession(kv, claim)`: `token = crypto.randomUUID()` (opaque by construction, D-06); read `chat:<claim.chat_id>` → oldToken; `kv.delete("session:" + oldToken)` if found (D-07 rotate-on-new — the structural close of the recent-stale-replay mint window); `kv.put("session:" + token, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS })`; `kv.put("chat:" + claim.chat_id, token, { expirationTtl: SESSION_TTL_SECONDS })`; return token.
|
||||
- `getTokenForChat(kv, chatId)`: read pointer; `getClaimByToken(kv, token)`: read record, JSON.parse, return or null. Reads never write.
|
||||
2. **bot/test/sessions.test.mjs** — in-memory 20-line KV fake (Map-backed KVNamespace subset: `get`, `put(key, value, opts)` asserting `opts.expirationTtl` is present and finite, `delete`, plus a `ttlLog` of writes):
|
||||
- mints return a token whose record contains EXACTLY the D-08 keys `{user_id, chat_id, issued_at, language_code}` (assert key-set equality — no drift toward profiles)
|
||||
- every `put` recorded in ttlLog carries expirationTtl === 900 (PRIV-02 by construction)
|
||||
- rotate: mint twice for same chat → older `session:<tok1>` key is gone, pointer target is tok2, exactly one live session record (D-07 invariant)
|
||||
- `getClaimByToken` round-trips; token unknown → null (expired-path class, D-14)
|
||||
- claim survives with `language_code: "ms"` and null both
|
||||
3. **bot/test/privacy.test.mjs** — PRIV-02 architecture gate, zero-KV:
|
||||
- Read `src/kv/sessions.ts` + `src/webhook.ts` (latter may not exist yet — filter existence) and assert every `.put(` occurrence on any line also contains `expirationTtl` on the same line (grep gate: no TTL-free write path can ship)
|
||||
- Assert `bot/src/kv/sessions.ts` exports nothing named like persistence (e.g., `saveProfile`, `storeLocation` do not exist)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck && grep -cn "expirationTtl" bot/src/kv/sessions.ts | head -1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w bot` exits 0 — three suites PASS (initdata, sessions, privacy)
|
||||
- `sessions.test.mjs` contains a case named like `rotate` / D-07 that asserts the old `session:<token>` key no longer exists after re-mint
|
||||
- Every KV write in `src/kv/sessions.ts` occurs with `{ expirationTtl: SESSION_TTL_SECONDS }` visible on the call line (privacy suite enforces)
|
||||
- `SessionClaim` interface contains only `user_id`, `chat_id`, `issued_at`, `language_code` (D-08 — grep the file for a 5th property: none)
|
||||
- `npm run bot:typecheck` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Opaque tokens mint only alongside TTL-expiring claims, rotate-on-new invalidates the previous token, PRIV-02 gates are in the automated suite (this closes the plan-01→02 dependency: full GREEN).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → /api/session entry (built in plan 03) | initData arrives untrusted; this plan defines the ONLY accepted path through validation |
|
||||
| KV writes → durable storage | any schema-less KV write could persist PII indefinitely |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-01 | Tampering / Spoofing (forged initData) | validateInitData | mitigate | HMAC-SHA256 over data-check-string with derived secret key (Telegram-verified algorithm); constant-time hex compare |
|
||||
| T-02-02 | Replay of stale-but-valid hash | sessions.ts + validateInitData(900s) | mitigate | auth_date freshness 900s (D-13) + rotate-on-new invalidates any token minted from a replay (D-07); suite pins both |
|
||||
| T-02-05 | Spoofing (token prediction) | mintSession | mitigate | crypto.randomUUID() — 122-bit RNG, not Math.random |
|
||||
| T-02-06 | Information Disclosure (persistent claim drift) | sessions.ts | mitigate | D-08 key-set-equality test + same-line expirationTtl grep gate in privacy.test.mjs |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w bot` exit 0 (three suites green: initdata + sessions + privacy)
|
||||
- `npm run bot:typecheck` exit 0
|
||||
- grep gate: every `.put(` in src/kv/sessions.ts carries `expirationTtl` on its line
|
||||
- No grammy import inside src/security/initdata.ts or src/kv/sessions.ts (isolation per D-11)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Security exit criterion (ROADMAP SC3 logic): tampered/stale/malformed initData vectors all rejected; known-good HMAC passes — in automated CI-style suite
|
||||
- Privacy by construction (ROADMAP SC4 logic): TTL-only namespace written without exception; claim is exactly D-08-minimal; rotate-on-new holds the one-live-token-per-chat invariant
|
||||
- All `bots` suites pass without network, account, or Provider dependencies (pure Node 24)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["02-01", "02-02"]
|
||||
files_modified:
|
||||
- bot/src/i18n/strings.ts
|
||||
- bot/src/bot/bot.ts
|
||||
- bot/src/bot/commands/start.ts
|
||||
- bot/src/bot/commands/help.ts
|
||||
- bot/src/bot/commands/privacy.ts
|
||||
- bot/src/webhook.ts
|
||||
- bot/scripts/register-commands.mjs
|
||||
- bot/scripts/set-webhook.mjs
|
||||
- bot/test/commands.test.mjs
|
||||
- bot/wrangler.jsonc
|
||||
autonomous: false
|
||||
requirements: [ONBD-01, PRIV-01]
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01/D-02/D-04: a real Telegram user sends /start (or /mula) and receives one honest clinical sentence with no buttons/inline keyboards"
|
||||
- "D-03/D-04: the bot's command menu lists exactly /help and /privacy via setMyCommands in default + ms scopes — nothing else, no stub future entries"
|
||||
- "D-01/D-05/D-17: /privacy states stored-facts and never-facts in en + ms that match the code's actual KV writes (PRIV-01)"
|
||||
- "D-05: unknown Telegram locales fall back to English; ms-prefixed locales get the Malay surface"
|
||||
- "D-14/D-16/D-17: webhook 401s without the secret-token header; only validated initData identities obtain a one-time token; rejection logs are category-only; deploy is local wrangler + setWebhook"
|
||||
artifacts:
|
||||
- path: "bot/src/i18n/strings.ts"
|
||||
provides: "Single copy source of truth (en + ms with identical key-sets)"
|
||||
contains: "pickLocale"
|
||||
- path: "bot/src/webhook.ts"
|
||||
provides: "Worker entry: /telegram/webhook + /api/session security gate + D-14 category-only logs"
|
||||
contains: "cloudflare-mod"
|
||||
- path: "bot/scripts/register-commands.mjs"
|
||||
provides: "One-time setMyCommands (default + ms scope — D-03/D-04)"
|
||||
contains: "setMyCommands"
|
||||
- path: "bot/scripts/set-webhook.mjs"
|
||||
provides: "setWebhook with secret_token (D-16 deploy scripting)"
|
||||
contains: "secret_token"
|
||||
key_links:
|
||||
- from: "bot/src/webhook.ts"
|
||||
to: "bot/src/security/initdata.ts"
|
||||
via: "handleSession → validateInitData → 401 on rejection / mint on success"
|
||||
pattern: "validateInitData"
|
||||
- from: "bot/src/webhook.ts"
|
||||
to: "bot/src/kv/sessions.ts"
|
||||
via: "mintSession called ONLY after ok:true (one mint path — PRIV-02)"
|
||||
pattern: "mintSession"
|
||||
- from: "bot/src/webhook.ts"
|
||||
to: "bot/src/bot/bot.ts"
|
||||
via: "webhookCallback(bot, 'cloudflare-mod', secretToken=env.WEBHOOK_SECRET)"
|
||||
pattern: "webhookCallback"
|
||||
- from: "bot/src/i18n/strings.ts"
|
||||
to: "bot/src/bot/commands/*.ts"
|
||||
via: "handlers import copy from strings (no inline copy — D-05)"
|
||||
pattern: "strings"
|
||||
user_setup:
|
||||
- service: telegram-bot
|
||||
why: "Real BOT_TOKEN — webhook validation, session minting, and command-menu registration all authenticate with the bot token from @BotFather"
|
||||
env_vars:
|
||||
- name: BOT_TOKEN
|
||||
source: "Telegram @BotFather -> /newbot -> telegram -> copy token"
|
||||
dashboard_config:
|
||||
- task: "Keep token secret; paste into bot/.dev.vars (never commit)"
|
||||
location: "local file bot/.dev.vars (D-15)"
|
||||
- service: cloudflare
|
||||
why: "Workers deploy target + KV namespace chat_sessions (D-16 deploy gate)"
|
||||
env_vars:
|
||||
- name: CLOUDFLARE_API_TOKEN (or automated wrangler login)
|
||||
source: "Cloudflare Dashboard -> My Profile -> API Tokens (or `npx wrangler login` browser flow)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the user-facing Telegram surface (commands + honest privacy copy + Malay aliases) and wire the Worker: webhook route with secret-token auth, `/api/session` security gate minting tokens, command-menu registration, deploy plumbing — then pause for the human deploy gate (D-16) that proves the whole phase in a real client.
|
||||
|
||||
Purpose: ROADMAP SC1/SC2 (visible command surface, honest /privacy) become real bot behavior; the tested security core becomes reachable (`/api/session` is the phase's security-gate entry).
|
||||
Output: runtime-complete bot + wired worker + one-time registration scripts; deploy checkpoint for ONBD-01.
|
||||
</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>
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md
|
||||
@.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts consumed from plan 01/02 files. -->
|
||||
|
||||
From bot/src/config.ts: `interface Env { BOT_TOKEN: string; WEBHOOK_SECRET: string; chat_sessions: KVNamespace }`
|
||||
|
||||
From bot/src/security/initdata.ts (plan 02):
|
||||
```typescript
|
||||
export type InitDataRejection = "malformed" | "tampered" | "stale";
|
||||
export async function validateInitData(raw: string, botToken: string,
|
||||
opts?: { nowSeconds?: number; maxAgeSeconds?: number }): Promise<{ ok: true; user: {id: number; language_code?: string}; chat?: {id: number; type: string}; languageCode: string | null; authDate: number } | { ok: false; reason: InitDataRejection }>;
|
||||
```
|
||||
|
||||
From bot/src/kv/sessions.ts (plan 02):
|
||||
```typescript
|
||||
export const SESSION_TTL_SECONDS = 900;
|
||||
export interface SessionClaim { user_id: number; chat_id: number; issued_at: number; language_code: string | null; }
|
||||
export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise<string>;
|
||||
```
|
||||
|
||||
grammY webhook adapter (plan 02 RESEARCH verified): `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)` from `"grammy"` — constant-time X-Telegram-Bot-Api-Secret-Token check whose config is this plan's `env.WEBHOOK_SECRET`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: strings module + command surface with aliases (D-01, D-02, D-03, D-04, D-05)</name>
|
||||
<files>bot/src/i18n/strings.ts, bot/src/bot/commands/start.ts, bot/src/bot/commands/help.ts, bot/src/bot/commands/privacy.ts, bot/src/bot/bot.ts, bot/test/commands.test.mjs</files>
|
||||
<read_first>
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-01..D-05 verbatim + <specifics> "strings module is the copy's tokens.css")
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples (setMyCommands localized scopes — grammar verified)
|
||||
- bot/src/kv/sessions.ts (SESSION_TTL_SECONDS / claim fields — the copy's "what is stored" facts must match this code, D-17)
|
||||
- bot/test/harness.mjs (test API)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **bot/src/i18n/strings.ts** — single copy source of truth (D-05), en default + `ms` (Bahasa Melayu), clinical + honest tone (D-01, no emoji, no marketing). Keys (identical key-set in both locales — test asserts):
|
||||
```typescript
|
||||
export interface BotStrings {
|
||||
startIntro: string; // D-01: what araharah is, one sentence
|
||||
privacyStored: string; // exactly what is stored — must name: Telegram user id, chat id, language, kept minutes (claim fields from sessions.ts)
|
||||
privacyNotStored: string; // exactly what is NOT stored — must name: location, routes, trip history, no profile (D-17: copy verifiable against code)
|
||||
helpMenu: string; // lists only /help and /privacy (D-03 — no stub entries for future commands)
|
||||
}
|
||||
export type Locale = "en" | "ms";
|
||||
export const strings: Record<Locale, BotStrings> = { en: {…}, ms: {…} };
|
||||
export function pickLocale(telegramLanguageCode?: string | null): Locale;
|
||||
// "ms" prefix match → "ms"; every other value (incl. null/undefined) → "en" (D-05 fallback rule)
|
||||
```
|
||||
Concrete copy (use as written; MS kept parallel in meaning):
|
||||
- en.startIntro: `"araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means."`
|
||||
- en.privacyStored: `"What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone."`
|
||||
- en.privacyNotStored: `"What araharah never keeps: your location, your routes, your trip history, any profile about you."`
|
||||
- en.helpMenu: Commands `/help …` and `/privacy …` descriptions (matches the setMyCommands registry — D-03; /help itself is a live command this phase so listing it is honest).
|
||||
- ms.startIntro: `"araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya."`
|
||||
- ms.privacyStored: `"Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang."`
|
||||
- ms.privacyNotStored: `"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda."`
|
||||
- ms.helpMenu: parallel to en.
|
||||
2. **Commands as pure builders + thin grammar wiring (testable without a Bot instance):** each command module exports `replyText(kind, locale)` pure functions and the grammy handler that calls them. Handlers use **plain text-only replies** — `ctx.reply(text, { link_preview_options: { is_disabled: true } })` and NOTHING else (D-02: no reply keyboard, no inline buttons; the structural assertion lives in the test).
|
||||
- `start.ts`: exports handler for both `/start` and `/mula` (aliases on the same command stack, D-04)
|
||||
- `help.ts`: `/help` handler (no alias — D-04 names later-phase aliases only)
|
||||
- `privacy.ts`: handlers for `/privacy` and `/privasi` (D-04); reply = privacyStored + "\n\n" + privacyNotStored
|
||||
3. **bot/src/bot/bot.ts:** `createBot(env: Env): Bot` — instantiates `new Bot(env.BOT_TOKEN)`, registers handlers above (alias forms via `bot.command("mula", …)` etc.), and **sets NO keyboards, NO inline buttons anywhere** (D-02). Menu registration lives in the one-time script (next item), not per-request.
|
||||
4. **bot/scripts/register-commands.mjs** (one-time; D-03/D-04 — node, zero deps beyond `node:fs`):
|
||||
- Reads `BOT_TOKEN` from `bot/.dev.vars` (parse `KEY=VALUE` lines)
|
||||
- `fetch` to `https://api.telegram.org/bot<TOKEN>/setMyCommands` twice:
|
||||
- default scope: `{ "commands": [ {command:"help", desc-en}, {command:"privacy", desc-en} ], "bot_command_scope": { "type": "default" } }`
|
||||
- `ms` scope: same two commands with Malay descriptions + `"language_code": "ms"`
|
||||
- Exits 0 on `ok:true`; prints the JSON response; no other commands registered (D-03 — no stub entries for future commands like /cari)
|
||||
5. **bot/test/commands.test.mjs:**
|
||||
- strings integrity: en/ms key-sets identical (assert key arrays sort-equal); `pickLocale("ms-MY") → "ms"`, `pickLocale("en") → "en"`, `pickLocale(null/undefined/\"id\") → "en"`
|
||||
- PRIV-01 copy gate: `strings.en.privacyNotStored` + `ms.privacyNotStored` mention location, routes, history; `privacyStored` mentions the TTL minutes wording
|
||||
- D-02 structural: the bot module source contains no `"keyboard"` or `"inline_keyboard"`/`InlineKeyboard` token (grep gate on bot/src/bot/bot.ts + commands files)
|
||||
- helpMenu text matches the two descriptions passed to register-commands (gap-free D-03)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck && node --check bot/scripts/register-commands.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bot/src/i18n/strings.ts` exports `strings` (Record<"en"|"ms", BotStrings>) and `pickLocale`, with identical key-sets across locales (suite asserts)
|
||||
- commands.test.mjs asserts privacyNotStored text mentions location + routes + history (PRIV-01 gate) and privacyStored mentions 15 minutes
|
||||
- bot/src/bot/bot.ts + command files grep: zero occurrences of `keyboard` / `inline_keyboard` (D-02)
|
||||
- register-commands.mjs contains exactly two fetch calls to `setMyCommands`, one with `"language_code": "ms"`, one without; command lists contain only `help` and `privacy` (D-04)
|
||||
- `npm test -w bot` exit 0, `npm run bot:typecheck` exit 0
|
||||
</acceptance_criteria>
|
||||
<done>Copy lives only in strings.ts (en+ms); /start /help /privacy + /mula /privasi handlers wired text-only; menu registration script ready with honest D-03 scope.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: Worker entry — webhook route, /api/session security gate, startWebhook plumbing (D-14, D-16, D-17)</name>
|
||||
<files>bot/src/webhook.ts, bot/scripts/set-webhook.mjs, bot/wrangler.jsonc</files>
|
||||
<read_first>
|
||||
- bot/src/webhook.ts is new; read bot/src/bot/bot.ts, src/security/initdata.ts, src/kv/sessions.ts (contracts in <interfaces>)
|
||||
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 1 (real webhookCallback signature — verify before wiring) + § RESEARCH.md Architecture diagram
|
||||
- bot/wrangler.jsonc (created in plan 01 — KV_ID_PLACEHOLDER remains until task 3 checklist fills it)
|
||||
</read_first>
|
||||
<action>
|
||||
1. **bot/src/webhook.ts** (the D-11 "worker entry"):
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/telegram/webhook") {
|
||||
const bot = createBot(env); // env-bound token → per-request instantiation (research-verified pattern)
|
||||
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
|
||||
return cb(req);
|
||||
}
|
||||
if (url.pathname === "/api/session") { // Phase 2 security gate — Mini App caller arrives Phase 4
|
||||
return handleSession(req, env);
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
|
||||
async function handleSession(req: Request, env: Env): Promise<Response> {
|
||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||
const raw = await req.text(); // the verbatim Telegram.WebApp.initData string
|
||||
const result = await validateInitData(raw, env.BOT_TOKEN);
|
||||
if (!result.ok) {
|
||||
console.log(`[initdata] rejected: ${result.reason}`); // D-14 category-only — NO raw payload, NO user ids, NO timestamps
|
||||
return new Response(JSON.stringify({ error: result.reason }), { status: 401, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const token = await mintSession(env.chat_sessions, {
|
||||
user_id: result.user.id,
|
||||
chat_id: result.chat?.id ?? result.user.id, // private chat: Telegram conflates; claim surface unchanged (D-08)
|
||||
issued_at: Math.floor(Date.now() / 1000),
|
||||
language_code: result.languageCode,
|
||||
});
|
||||
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
```
|
||||
(Replay: a repeated valid initData rotates through mintSession — plan 02's D-07 invariant closes it; the endpoint adds no extra branch.)
|
||||
2. **bot/scripts/set-webhook.mjs:** one-time; reads BOT_TOKEN + WEBHOOK_URL + WEBHOOK_SECRET from `bot/.dev.vars`; `fetch` `https://api.telegram.org/bot<TOKEN>/setWebhook` with `{ "url": WEBHOOK_URL, "secret_token": WEBHOOK_SECRET, "allowed_updates": ["message"] }`; prints result; exit 0 only on `ok:true` (D-16).
|
||||
3. **bot/package.json scripts (edit — no new file):** `"deploy": "wrangler deploy && node scripts/set-webhook.mjs"` (D-16: deploy + webhook in one command), keeping `commands`, `dev`, `test`, `typecheck` from plan 01.
|
||||
4. **bot/wrangler.jsonc:** add `"workers_dev": true` comment marker only; leave `KV_ID_PLACEHOLDER` for task 3 (deployment checklist fills it).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck && (cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/worker-dryrun)</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `bot/src/webhook.ts` routes exactly `/telegram/webhook` and `/api/session` (dry-run bundle accepts `satisfies ExportedHandler<Env>`)
|
||||
- The only initdata-related log statement is category-only: grep `bot/src/webhook.ts` for `console.log` statements each matching `\`[initdata] rejected: ${…`} — no payload/ids logging (D-14)
|
||||
- `set-webhook.mjs` passes `secret_token` = WEBHOOK_SECRET into setWebhook payload (verified secret-token flow)
|
||||
- `wrangler deploy --dry-run` exits 0 (no account credentials needed — local esbuild only)
|
||||
- `npm run bot:typecheck` + `npm test -w bot` exit 0 (existing suites unbroken)
|
||||
</acceptance_criteria>
|
||||
<done>Worker builds for deploy; security gate reachable at /api/session; webhook auth + D-14 category logging in place; nothing secret leaves `env` reading.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>task 3: deploy gate — real-client walkthrough of the full command surface (D-16, ONBD-01)</name>
|
||||
<action>Automate everything possible first (credentials aside): wrangler kv namespace create + fill id, `npm run bot:deploy` (wrangler deploy + setWebhook), `npm run bot:commands`, KV key spot-check — then walk the user through the numbered real-client verification in <how-to-verify>. Human-only steps: @BotFather token, wrangler login/browser auth, Telegram client walkthrough (a–e).</action>
|
||||
<what-built>
|
||||
The complete Phase 2 bot: grammY bot with /start /help /privacy (+ /mula /privasi aliases), honest RAMS-tone privacy copy (en + ms), TTL-only KV sessions behind the /api/session initData security gate, secret-token-protected webhook, all under CI test vectors (green suites). All backend code paths are automated-verified; what remains needs a real Telegram client, a real bot token, and a real Worker deploy (D-16: no CI yet — human deploy gate).
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
OpenCode automates everything below EXCEPT the credential steps and the real-client walkthrough; run these in order:
|
||||
1. Credentials (human-only): create bot via @BotFather → copy token into `bot/.dev.vars` as `BOT_TOKEN=...`; generate `WEBHOOK_SECRET=$(openssl rand -hex 32)` and add to `bot/.dev.vars`; authenticate Cloudflare (`npx wrangler login` or CLOUDFLARE_API_TOKEN).
|
||||
2. OpenCode runs: `npx wrangler kv namespace create chat_sessions` → copy returned id into `bot/wrangler.jsonc` replacing `KV_ID_PLACEHOLDER`; set `WEBHOOK_URL` in `.dev.vars` to the printed workers.dev URL after `npm run bot:deploy` (deploy + setWebhook via scripts).
|
||||
3. OpenCode runs `npm run bot:commands` (registers /help /privacy default + ms scopes).
|
||||
4. Real-client walkthrough (human, this checkpoint): in Telegram against your own bot —
|
||||
a. send `/start` → one sentence intro, NO buttons (D-01/D-02)
|
||||
b. open the command menu (menu button) → ONLY /help and /privacy listed (D-03)
|
||||
c. `/privacy` → stored-facts block (user id, chat id, language, ~15 min) + never-block (location, routes, history, profile)
|
||||
d. `/mula` behaves exactly like /start; `/privasi` like /privacy
|
||||
e. send gibberish `/xyz` → no crash, no menu drift
|
||||
5. Storage spot-check (PRIV-02 work-around): OpenCode runs `npx wrangler kv key list` after a manual /start — only `session:*`/`chat:*` keys, TTL-bounded, no coordinates; keys vanish after ~15 min.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" or describe issues — deploy gate per ROADMAP SC1–SC4 (ONBD-01, PRIV-01, PRIV-02) must all be observed before phase exit.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Telegram servers → webhook endpoint | updates arrive over the public internet; secret-token header is the gatekeeper |
|
||||
| Mini App client → /api/session | initData arrives untrusted from the user's webview |
|
||||
| Worker logs → Cloudflare log stream | anything logged becomes visible outside the app |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-02-03 | DoS / webhook flooding | webhook route | mitigate | grammY's verified constant-time secret-token compare 401s garbage BEFORE bot processing; no KV touched pre-check |
|
||||
| T-02-01 | Spoofing (forged identity → token) | /api/session | mitigate | Only validateInitData→mintSession path mints; rejection classes log category-only, return 401s |
|
||||
| T-02-02 | Replay of stale-but-valid initData | /api/session | mitigate | 900s freshness window + D-07 rotate via plan-02 mintSession |
|
||||
| T-02-04 | Information Disclosure (logs, SSRF/repo) | webhook.ts, scripts/* | mitigate | D-14 category logs; secrets read from .dev.vars/ secrets at runtime only; scripts never echo the bot token (grep-gate: no `console.log` of BOT_TOKEN/secret) |
|
||||
| T-02-06 | Information Disclosure (KV persistence drift) | webhook.ts | mitigate | only mintSession writes KV (TTL-enforced by plan-02 suite); endpoint adds no storage |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w bot` + `npm run bot:typecheck` exit 0 (all 4 suites)
|
||||
- `wrangler deploy --dry-run` exit 0
|
||||
- Manual checkpoint observes SC1–SC3 in a real client before phase exit (D-16 deploy gate; autonomous: false precisely for this)
|
||||
- Storage spot-check shows TTL-only KV keys (SC4)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Real Telegram client: /start concise intro text-only; command menu lists exactly /help and /privacy; /privacy shows stored/never-stored truth; Malay aliases co-route; en fallback for unknown locales
|
||||
- Deployed Worker rejects unauthorized webhook calls (secret-token) and unvalidated session requests (rejection classes), mints one-token-per-chat for validated identities
|
||||
- KV contains only TTL-bounded session/pointer keys — no persistent coordinates or profiles
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 2: Bot Skeleton + initData Security - Context
|
||||
|
||||
**Gathered:** 2026-10-22
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Users can reach araharah through Telegram with a working command surface (/start, /help, /privacy, plus Malay aliases) served by a grammY bot on one Cloudflare Worker, and the backend's identity + privacy architecture is provably sound from the first deploy: HMAC-validated Telegram WebApp initData with CI test vectors, one-time opaque session tokens in TTL-only KV (`chat_sessions`). Mini App shell, search, and the `guidance_sessions` envelope design are later phases.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Bot copy + interaction style
|
||||
- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
|
||||
- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
|
||||
- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
|
||||
- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
|
||||
|
||||
### Copy localization
|
||||
- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
|
||||
|
||||
### Session token mechanics
|
||||
- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the DSGN-equivalent security gate: only successful initData validation mints one.
|
||||
- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat. The recent-stale-replay risk that a replicated validation could mint extra tokens is thereby closed structurally.
|
||||
- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code` (needed to route the bot's future chat cards in the user's locale). No location, no routes, no profile.
|
||||
|
||||
### Backend repo layout
|
||||
- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
|
||||
- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
|
||||
- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
|
||||
|
||||
### Validation + test harness
|
||||
- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
|
||||
- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch. This pairs with the `chat_sessions` minutes-TTL envelope.
|
||||
- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps. Satisfies the "logs carry no coordinate data" criterion from day one.
|
||||
|
||||
### Secrets + deploy setup
|
||||
- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
|
||||
- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet — it arrives in Phase 3 with real Mini App users.
|
||||
- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls — the architecture can't hold more than it says it does.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Exact setMyCommands scope/locale structuring details
|
||||
- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
|
||||
- Exact KV TTL values inside the "minutes" envelope
|
||||
- How local wrangler dev simulates KV for testing the product loop
|
||||
- How the researcher verifies exact grammY initData typings (flagged research gap)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Command menu and bot surface should feel like the Rams product inside Telegram: spare, honest, functional. /privacy is a promise visibly kept, not a legal disclaimer.
|
||||
- Malay aliases are the user's explicit addition — locals should be able to drive in their own language from day one.
|
||||
- Single-file source of truth predilection carries over: the strings module is the copy's tokens.css — no copy pasted inline in handlers.
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope + decisions
|
||||
- `.planning/ROADMAP.md` §Phase 2 — goal, 4 success criteria, notes (KV namespace, grammY research gap)
|
||||
- `.planning/REQUIREMENTS.md` — ONBD-01, PRIV-01, PRIV-02 (this phase's requirements)
|
||||
- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — Phase 1 pattern precedent (workspace structure, hand-rolled test runner, TDD discipline established)
|
||||
|
||||
### Codebase
|
||||
- `.planning/codebase/CONCERNS.md`, `STACK.md`, `ARCHITECTURE.md` — pre-existing repo caveats (secret handling from day one)
|
||||
- `AGENTS.md` — coding conventions
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `theme/` npm workspace pattern (root delegates + per-workspace package.json + scripts) — the repo's established structure for every subsequent workspace
|
||||
- `theme/scripts/audit.test.mjs` — hand-rolled test runner pattern: node:assert, minimal, no deps (D-12 builds on this shape)
|
||||
- Package-layout precedent: isolated concerns in their own module files (e.g. tokens/components/audit separation)
|
||||
|
||||
### Established Patterns
|
||||
- TDD discipline from Phase 1 (RED→GREEN commits where sensible)
|
||||
- Type-only imports, zero-radius kebab-case vocabulary, strict gitignored `.planning/`
|
||||
- npm workspaces with self-hosted assets — same mental model applies to backend secrets
|
||||
|
||||
### Integration Points
|
||||
- `theme/dist/rams.css` + basemap style become Mini App static assets served by the same Worker in Phase 3 — Phase 2's worker must implicitly plan where static asset binding lands (researcher's structural hint, not Phase 2 implementation)
|
||||
- `chat_sessions` KV namespace is the first durable namespace — its TTL/shape is what Phase 5's envelope design extends
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
|
||||
- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
|
||||
|
||||
None — discussion stayed within phase scope.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 02-bot-skeleton-initdata-security*
|
||||
*Context gathered: 2026-10-22*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Phase 2: Bot Skeleton + initData Security - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions captured in CONTEXT.md — this log preserves the analysis.
|
||||
|
||||
**Date:** 2026-10-22
|
||||
**Phase:** 02-bot-skeleton-initdata-security
|
||||
**Mode:** default (interactive)
|
||||
|
||||
## Areas Discussed
|
||||
|
||||
### Bot copy + interaction style
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Tone | Clinical + honest / Friendly + light / Formal | Clinical + honest |
|
||||
| /start form | Text-only / reply keyboard / inline button | Text-only minimal |
|
||||
| Command menu | Phase-2 commands only / v1 stubbed / you decide | Phase-2 only + user freeform added Malay aliases (/mula, /pergi_ke, /cari) |
|
||||
| Localization | Strings module + ms locale / defer / aliases only | Strings module + ms locale |
|
||||
|
||||
### Session token mechanics
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Token shape | Opaque KV id / signed JWT / you decide | Opaque token id in KV |
|
||||
| Second /start | Rotate-on-new / multi-session / you decide | Rotate-on-new, revocable |
|
||||
| Claim fields | Minimal / wider / you decide | Minimal bootstrap claim |
|
||||
|
||||
### Backend repo layout
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Workspace layout | bot/ / worker/ / flat src | bot/ workspace |
|
||||
| Language | TypeScript / JS ESM / you decide | TypeScript |
|
||||
| Internal structure | Explicit src/ tree / flat folder / you decide | Explicit src/ tree |
|
||||
|
||||
### Validation + test harness
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Test runner | Hand-rolled / vitest / workers-pool / you decide | Hand-rolled runner |
|
||||
| Stale policy | ~15 min / 5 min / you decide | ~15 min freshness |
|
||||
| Rejection logs | Category-only / + payload hash / you decide | Category-only |
|
||||
|
||||
### Secrets + deploy setup
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Secret storage | Wrangler secrets / CI-injected / you decide | Wrangler secrets |
|
||||
| Deploy | Local deploy / CI day one | Local deploy only |
|
||||
| Hygiene | Secrets separation / dry-run vault / you decide | Secrets separation |
|
||||
|
||||
## Corrections Made
|
||||
|
||||
No corrections — user accepted recommended options throughout; one freeform addition (Malay command aliases D-03/D-04/D-05).
|
||||
|
||||
## Auto-Resolved
|
||||
|
||||
Not applicable.
|
||||
|
||||
## External Research
|
||||
|
||||
Not applicable (research phase follows planning; the grammY initData typings verification was already flagged in ROADMAP.md).
|
||||
@@ -0,0 +1,489 @@
|
||||
# Phase 2: Bot Skeleton + initData Security - Research
|
||||
|
||||
**Researched:** 2026-10-22
|
||||
**Domain:** grammY bot on Cloudflare Workers + Telegram WebApp initData (HMAC) validation + KV TTL-only session storage
|
||||
**Confidence:** HIGH (core algorithm + webhook adapter + typing gap verified this session; typing/package versions verified against npm registry)
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 stands up the first backend artifact: a single Cloudflare Worker running a grammY bot with commands (/start, /help, /privacy + Malay aliases) and an HMAC-SHA256 initData validator backed by CI test vectors. All critical claims were verified against primary sources this session:
|
||||
|
||||
1. **The flagged typing gap is real and confirmed.** `grammY@1.46.0` and `@grammyjs/types@5.0.0` ship **no `WebAppInitData` typings anywhere** (verified by extracting both tarballs and grepping). The plan must hand-roll a small WebAppInitData interface inside `src/security/initdata.ts` — which fits D-11/D-12 (isolated testable module, zero extra test deps) exactly. No third-party initData package is needed.
|
||||
2. **The validation algorithm is fully specified by Telegram [VERIFIED: core.telegram.org/bots/webapps]**: `secret_key = HMAC_SHA256(bot_token, key="WebAppData")`, then `hex(HMAC_SHA256(data_check_string, secret_key)) == hash`, with data-check-string = all fields except `hash` (and `signature`), sorted alphabetically, `key=value` joined by `\n`. Staleness via `auth_date` (D-13: ~15 minutes).
|
||||
3. **Cross-runtime crypto works zero-dep.** Node 24's global `crypto.subtle` produced the exact RFC-4231 HMAC-SHA256 vector, identical to a reference implementation, on this machine today. The same API is the only crypto in Cloudflare Workers. So `src/security/initdata.ts` runs unchanged in Workers AND in a bare `node` test runner — D-12's hand-rolled runner needs no polyfills and no test framework [VERIFIED: local run].
|
||||
4. **grammY ships a first-class Workers adapter [VERIFIED: shipped source]**: `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)` accepts the module-format `fetch(request)` handler itself, reads the update from `request.json()`, and performs a **constant-time comparison of `X-Telegram-Bot-Api-Secret-Token`** against the configured secret token, rejecting with 401 on miss.
|
||||
5. **`setMyCommands` supports localized scopes [VERIFIED: shipped source]**: `bot.api.setMyCommands(commands, { bot_command_scope: { type: "default" }, language_code: "ms" })` — the `language_code` param is typed in the methods surface (`LanguageCode`).
|
||||
|
||||
**Primary recommendation:** Hand-roll initData validation + WebAppInitData types on `crypto.subtle` (zero deps, same code in Workers and Node tests); wire the bot into the Worker with `webhookCallback(bot, "cloudflare-mod", undefined, undefined, WEBHOOK_SECRET)`; store one opaque token per chat in KV `chat_sessions` with `expirationTtl` on every put.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
|
||||
- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
|
||||
- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
|
||||
- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
|
||||
- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
|
||||
- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the security gate: only successful initData validation mints one.
|
||||
- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat.
|
||||
- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code`. No location, no routes, no profile.
|
||||
- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
|
||||
- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
|
||||
- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
|
||||
- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
|
||||
- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch.
|
||||
- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps.
|
||||
- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
|
||||
- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet.
|
||||
- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Exact setMyCommands scope/locale structuring details
|
||||
- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
|
||||
- Exact KV TTL values inside the "minutes" envelope
|
||||
- How local wrangler dev simulates KV for testing the product loop
|
||||
- How the researcher verifies exact grammY initData typings (flagged research gap — RESOLVED, see below)
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
|
||||
- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| ONBD-01 | User opens the bot, /start gives concise explanation + always-available command menu (/help, /privacy) | grammY command handlers + `setMyCommands` localized scopes (default + `ms`), verified against shipped API surface |
|
||||
| PRIV-01 | Privacy promise visible in-product: /privacy command (Phase 2 half: the command + copy; in-app messaging completes Phase 6) | Strings module (`src/i18n/strings.ts`) with en/`ms` locales; copy verifiable by reading code's storage calls (D-17) |
|
||||
| PRIV-02 | Backend holds no persistent per-user location history or profile — TTL-expiring ephemeral state only | KV `chat_sessions` access layer always passes `expirationTtl`; claim restricted to D-08 fields; rejection logs category-only (D-14) |
|
||||
</phase_requirements>
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| initData HMAC validation + staleness check | API / Backend (Worker) | — | Secret key derived from bot token — never touches a client tier |
|
||||
| Session token minting (opaque id) | API / Backend (Worker) | — | Only validated identities mint tokens; crypto.randomUUID in Workers/Node |
|
||||
| Session storage (TTL-only KV) | Database / Storage (KV) | API tier wraps access | Access layer in `src/kv/sessions.ts` is the only KV consumer |
|
||||
| Bot command surface (/start /help /privacy + ms aliases) | API / Backend (Worker) | — | grammY runs inside the Worker; no SSR tier |
|
||||
| Bot copy (en + ms) | API / Backend (strings module) | — | Single source of truth per D-05 |
|
||||
| User sends command / receives message | Client (Telegram app) | API tier processes | Verification of this truth is manual-only (real Telegram client) |
|
||||
|
||||
**No security-sensitive capability is assigned outside the API/backend tier.**
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| grammy | 1.46.0 | Bot framework (commands, handlers) | First-class Cloudflare Workers support via `wh`/`webhookCallback`; typed Bot API surface. [VERIFIED: npm registry + shipped tarball] |
|
||||
| @grammyjs/types | 5.0.0 | grammY's Bot API typings (transitive dep) | Ships with grammy; provides `LanguageCode` and API method Param types. Contains **no** WebAppInitData — see gap note. [VERIFIED: tarball grep] |
|
||||
| @cloudflare/workers-types | 5.20261022.1 | Runtime types (KVNamespace, ScheduledController, env bindings) | The blessed runtime-types package for Workers TS. [VERIFIED: npm registry] |
|
||||
| wrangler | 4.151.3 | Dev server, generate types (`wrangler types`), deploy, secrets | Current Cloudflare CLI major. [VERIFIED: npm registry, modified 2026-10-20] |
|
||||
| typescript | 5.9.3 | Language | Latest stable 5.x line; TS 7.0.2 exists but wrangler tooling ecosystem is proven on 5.x — stay conservative (MEDIUM confidence choice, low risk). [VERIFIED: npm registry] |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| @types/node | 26.6.5 | Node types for the hand-rolled test runner only (test scope) | The test runner imports `node:assert`; production code uses no Node APIs. [VERIFIED: npm registry] |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Hand-rolled initData types | `@telegram-apps/init-data-node` | Same algorithm hand-implemented for zero deps keeps D-11/D-12 intact; community pkg adds a dep and a second way to do the same crypto. Rejected for this phase of one. |
|
||||
| Hand-rolled test runner (D-12) | `@cloudflare/vitest-pool-workers` | The security-critical validator runs in bare Node via global `crypto.subtle` — no pool needed. Pool adds a heavy dep for tests that don't need Worker runtime emulation. Keep pool off the path; Workers-runtime-only behaviors (KV real binding) can't be unit-tested either way and are verified by deploy spot-check per D-16. |
|
||||
| `webhookCallback(bot, "cloudflare-mod")` | Manual Request parsing | Manual parsing loses grammY's constant-time secret-token check (verified implementation) — don't rebuild it. |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
npm install -w bot grammy
|
||||
npm install -w bot -D wrangler typescript @cloudflare/workers-types @types/node
|
||||
```
|
||||
|
||||
**Version verification:** All versions probed against the npm registry today (2026-10-22). Registry publish dates confirmed fresh for every entry (wrangler modified 2026-10-20, workers-types 2026-10-22, grammy 2026-08-26).
|
||||
|
||||
**Typing gap resolution [research gap from ROADMAP notes — OPEN QUESTION RESOLVED]:**
|
||||
`WebAppInitData` is not exported by grammy@1.46.0 or @grammyjs/types@5.0.0 (verified by tarball inspection). `src/security/initdata.ts` therefore defines its own minimal interface from Telegram's official WebAppInitData spec (`query_id?`, `user?`, `chat?`, `start_param?`, `chat_type?`, `chat_instance?`, `auth_date`, `signature?`, `chat_join_request_query_id?`) plus the raw string fields (`hash`, `auth_date`, `user`, ...). This is ~20 lines of types, tested by the same vector suite.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Telegram client
|
||||
│ POST https://<worker>/telegram/webhook
|
||||
│ (header: X-Telegram-Bot-Api-Secret-Token)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Cloudflare Worker (src/webhook.ts) │
|
||||
│ └ route /telegram/webhook only │
|
||||
│ └ webhookCallback(bot, "cloudflare-mod", │
|
||||
│ secretToken=WEBHOOK_SECRET) │
|
||||
│ ├ 401 if secret header mismatches │
|
||||
│ └ bot command handlers: │
|
||||
│ /start /mula /help /privacy /privasi │
|
||||
│ └ copy from src/i18n/strings.ts │
|
||||
│ (en default, ms by user.language) │
|
||||
│ │
|
||||
│ POST /api/session (Phase 5 will be Mini App caller; │
|
||||
│ body: initData string — validated HERE) │
|
||||
│ ├ validateInitData(initData, BOT_TOKEN) │
|
||||
│ │ ├ parse querystring fields │
|
||||
│ │ ├ derive secretKey = HMAC(bot,"WebApp")│
|
||||
│ │ ├ hex compare → tamper / stale checks │
|
||||
│ │ └ rejection classes: tampered/stale/ │
|
||||
│ │ replayed/expired (category logs only│
|
||||
│ │ per D-14 — no payload, no coords)│
|
||||
│ ├ mint opaque token (crypto.randomUUID) │
|
||||
│ └ KV put(key=token, claim, │
|
||||
│ { expirationTtl: <minutes envelope> })│
|
||||
│ rotate-on-new: prior token invalidated│
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│ ▲
|
||||
▼ │ read claim by token
|
||||
KV namespace chat_sessions │ (phase-local verification)
|
||||
({expirationTtl} on every put — TTL-only, PRIV-02)
|
||||
```
|
||||
|
||||
Note on `/api/session`: the Mini App UI calling it arrives in Phase 4, but the endpoint is the vehicle that makes initData validation and D-06/D-07/KV-TTL observable in this phase (validator is "the first backend artifact" and must be reachable). The Phase 3 route addition (static assets binding) is additive and does not conflict.
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
bot/
|
||||
├── package.json # name @araharah/bot, type module, test/deploy/dev scripts
|
||||
├── wrangler.jsonc # KV binding chat_sessions, webhook URL vars in config (not secrets)
|
||||
├── .dev.vars.example # shape only: BOT_TOKEN=, INITDATA_WEBHOOK_SECRET=, WEBHOOK_SECRET=
|
||||
├── .dev.vars # gitignored — real local secrets (user creates from example)
|
||||
├── .gitignore # .dev.vars, .wrangler/
|
||||
├── src/
|
||||
│ ├── webhook.ts # export default { fetch } — routes /telegram/webhook and /api/session
|
||||
│ ├── config.ts # typed Env interface (BOT_TOKEN, WEBHOOK_SECRET, chat_sessions binding)
|
||||
│ ├── security/
|
||||
│ │ ├── initdata.ts # PURE: validateInitData(raw, botToken, now?) + WebAppInitData types
|
||||
│ │ └── initdata.test.mjs? # ← runner lives in /test (see below)
|
||||
│ ├── kv/
|
||||
│ │ └── sessions.ts # TTL-only access layer: put()pong required expirationTtl; no get-or-store persistent paths
|
||||
│ ├── i18n/
|
||||
│ │ └── strings.ts # en + ms copy, locale picker (D-05)
|
||||
│ └── bot/
|
||||
│ ├── bot.ts # Bot instance + middleware wiring (no handlers inline)
|
||||
│ └── commands/
|
||||
│ ├── start.ts # handler + alias (/start, /mula)
|
||||
│ ├── help.ts # handler only (no alias in this phase per D-04)
|
||||
│ └── privacy.ts # handler + alias (/privacy, /privasi)
|
||||
└── test/
|
||||
├── runner.mjs # minimal hand-rolled runner (theme/scripts/audit.test.mjs shape)
|
||||
└── initdata.test.mjs # HMAC vector suite (tamper/stale/replay/known-good)
|
||||
```
|
||||
|
||||
### Pattern 1: Workers module Worker with grammY webhook adapter
|
||||
**What:** grammY's built-in `webhookCallback` with the `"cloudflare-mod"` adapter converts the Workers `fetch(request)` handler into a grammY request-response cycle, including constant-time secret-token verification.
|
||||
**When to use:** Always in this project (D-webhook = every phase).
|
||||
**Example:**
|
||||
```typescript
|
||||
// Source: grammy@1.46.0 out/convenience/frameworks.js + webhook.js (shipped code, verified 2026-10-22)
|
||||
import { Bot, webhookCallback } from "grammy";
|
||||
import type { Env } from "./config.js";
|
||||
|
||||
const bot = new Bot(() => `t${""}`); // NOTE: do NOT copy this literal — see real signature in tasks; Bot(token) is resolved once in createBot(env.BOT_TOKEN)
|
||||
```
|
||||
Real shape (verified): grammY's adapter registration — `adapters["cloudflare-mod"]` takes `request` (with `.json()`, `.headers`), exposes constant-time `unauthorized()` responses. Compose as:
|
||||
```typescript
|
||||
export default {
|
||||
fetch(req: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/telegram/webhook") {
|
||||
return webhookCallback(botHandler(env), "cloudflare-mod")(
|
||||
req, undefined, undefined, env.WEBHOOK_SECRET,
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/session") { /* Phase 2: validate + mint */ }
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
```
|
||||
**Wait — signature check:** `webhookCallback(bot, adapter, onTimeout, timeoutMilliseconds, secretToken)` — verified from the shipped implementation: `(bot, adapter = defaultAdapter, onTimeout, timeoutMilliseconds, secretToken)` where `secretToken=undefined` disables the header check. Because the bot needs `env.BOT_TOKEN` (bound at deploy), create the Bot inside the fetch handler (Workers have no server startup hook):
|
||||
|
||||
```typescript
|
||||
// src/webhook.ts — real shape
|
||||
import { Bot, webhookCallback } from "grammy";
|
||||
import { createBot } from "./bot/bot.js";
|
||||
import { handleSession } from "./security/session.js";
|
||||
|
||||
export default {
|
||||
async fetch(req: Request, env: Env): Promise<Response> {
|
||||
const bot = createBot(env); // cheap per-request instantiation; grammY Bot is stateless without polling
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/telegram/webhook") {
|
||||
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
|
||||
return cb(req);
|
||||
}
|
||||
if (url.pathname === "/api/session") return handleSession(req, env);
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
```
|
||||
(Per-request Bot instantiation is the standard pattern for Workers with env-bound tokens — token can't be resolved at module scope since it arrives via `env`.)
|
||||
|
||||
### Pattern 2: initData validation (SubtleCrypto, pure function)
|
||||
**What:** Verified algorithm from Telegram's official docs, implemented as a pure async function testable in Node AND Workers.
|
||||
**Example:**
|
||||
```typescript
|
||||
// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app (verified 2026-10-22)
|
||||
export type InitDataRejection = "malformed" | "tampered" | "stale" | "replayed" | "expired";
|
||||
|
||||
interface ParseResult { fields: Map<string, string>; hash: string | undefined; }
|
||||
|
||||
export async function validateInitData(
|
||||
raw: string,
|
||||
botToken: string,
|
||||
opts: { now?: number; maxAgeSeconds?: number; existingToken?: string } = {},
|
||||
): Promise<{ ok: true; user: WebAppInitUser; chat?: WebAppInitChat; languageCode?: string | null; }
|
||||
| { ok: false; reason: InitDataRejection }> {
|
||||
// 1. parse raw querystring → fields (do NOT pre-decode values for the check string;
|
||||
// Telegram compares against the data-check-string built from the parsed fields)
|
||||
// 2. secretKey = HMAC_SHA256(botToken bytes, key="WebAppData")
|
||||
// 3. dataCheckString = fields minus hash, minus signature, sorted by key, "key=value\n..." (LF join)
|
||||
// 4. hexCheck = HMAC_SHA256(dataCheckString UTF-8, key=secretKey)
|
||||
// 5. mismatch → { ok:false, reason:"tampered" }
|
||||
// 6. auth_date parse fail → "malformed"; auth_date < now - 900s → "stale" (D-13: ~15 minutes)
|
||||
// 7. no user payload / user lacks id → "malformed"
|
||||
return {};
|
||||
}
|
||||
```
|
||||
(Full implementation belongs in the plan's task — algorithm + rejection classes above are the verified contract.)
|
||||
|
||||
### Pattern 3: TTL-only KV access layer (PRIV-02 by construction)
|
||||
**What:** The KV accessor's `put` REQUIRES an `expirationTtl` argument (no default path to infinity). Type signature enforces it:
|
||||
```typescript
|
||||
// src/kv/sessions.ts — TTL-only by type
|
||||
export interface SessionClaim {
|
||||
token_id: string; // opaque token id itself (key)
|
||||
user_id: number; // D-08 minimal claim
|
||||
chat_id: number;
|
||||
issued_at: number;
|
||||
language_code: string | null;
|
||||
}
|
||||
export async function mintSession(kv: KVNamespace, claim: Omit<SessionClaim, "token_id">, ttlSeconds: number): Promise<string> {
|
||||
const token = crypto.randomUUID();
|
||||
await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: ttlSeconds }); // required — no overload without TTL
|
||||
return token;
|
||||
}
|
||||
```
|
||||
**Verify in CI (PRIV-02 spot-check):** the runner asserts `mintSession` forwards `expirationTtl` (mock KV) and that no other code path in `src/` calls `.put(` without `expirationTtl` (grep gate in the runner).
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Bot token as HMAC key directly:** Telegram uses `secret_key = HMAC(bot_token, key="WebAppData")` first. Validating with `HMAC(dataCheckString, key=botToken)` silently fails everywhere except in your broken tests that encode the same mistake.
|
||||
- **Including `hash` (or `signature`) in data-check-string:** must exclude before sorting.
|
||||
- **Re-sorting after URL-decoding values or re-encoding:** build the check string from parsed field values as Telegram sent them (decoded), sorted by key, LF-joined. Sorting must be case-sensitive byte order (JS default `Array.sort()`).
|
||||
- **`parseInt` edge handling on auth_date:** Telegram sends seconds-since-epoch; parsing that yields `NaN` (e.g., `Date.now()` ms confusion) creates a stale/valid inversion bug. Normalize to seconds.
|
||||
- **Storing coordinates/profile in KV "temporarily":** D-08's claim is the exhaustive list. PR-14's category-only logs: never `console.log` the raw initData string.
|
||||
- **`bot.start()` anywhere in the Worker path:** grammY throws if you call `.start()` in webhook mode; handlers must not invoke polling.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Webhook request parsing + auth | Manual `request.json()` + header check | `webhookCallback(bot, "cloudflare-mod", …, secretToken)` | grammY's constant-time token comparison is already shipped and verified; manual parsing would reintroduce a timing side-channel |
|
||||
| HMAC-SHA256 | `npm i crypto-js` / custom table-coded blocks | `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" })` | Native in Workers and Node ≥15; FCA-grade correct; RFC-4231 verified this session |
|
||||
| Bot command parsing | Custom prefix routers | grammY commands + `bot.command("mula", handler)` for aliases on same composer | grammY grammar handles `/command @botname` edge cases |
|
||||
| Random opaque tokens | `Math.random().toString(36)` | `crypto.randomUUID()` | Math.random is predictable (rejection class "replay" hard to distinguish); crypto.randomUUID is available in both Workers and Node with zero deps |
|
||||
|
||||
**Key insight:** every cryptographic and webhook primitive this phase needs is native to the runtime — third-party crypto or webhook plumbing would only add diff surface to audit (D-17's "verifiable by reading the code's storage calls").
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Sorting alphabetically ≠ lexicographic on the raw VALUE
|
||||
**What goes wrong:** `data-check-string` fields are sorted **by key**, but implementers sort key-value pair strings or decode-dependent value strings. Telegram sorts by field name only.
|
||||
**Why it happens:** the spec phrase "sorted alphabetically" is ambiguous out of context.
|
||||
**How to avoid:** sort a list of `key=value` strings generated after splitting, restricting the sort key to the field name. Test vector suite should include an out-of-alphabetical-arrival-order payload (`user=` before `auth_date=`) that still passes.
|
||||
**Warning signs:** validation passes for canonical-order payloads but fails for real Telegram packets (Telegram sends `user` first in practice).
|
||||
|
||||
### Pitfall 2: HMAC key derivation direction confusion
|
||||
**What goes wrong:** using `HMAC(bot_token, key=WebAppData)`'s result as the *message* somewhere, or re-deriving the key as `HMAC("WebAppData", key=bot_token)` (reversed).
|
||||
**Why it happens:** the Telegram pseudocode line `secret_key = HMAC_SHA256(<bot_token>, "WebAppData")` reads left-to-right as message/key but is documented above in prose as **key = "WebAppData", message = bot_token**.
|
||||
**How to avoid:** encode the derivation as `HMAC(message = botToken ← Bot API token string, key = utf8("WebAppData"))` in both code AND test vectors; one known-good vector pins the convention.
|
||||
**Warning signs:** every initData with a given hash fails validation despite matching an official worked example.
|
||||
|
||||
### Pitfall 3: `auth_date` staleness window in ms-vs-seconds
|
||||
**What goes wrong:** comparing `auth_date` (seconds) against `Date.now()` (ms) → everything looks 1000× too old; or hard-failing the whole check when the payload is exactly at boundary — off-by-one D-13 semantics.
|
||||
**How to avoid:** compute age in seconds: `nowSeconds - authDate > maxAgeSeconds` → stale, with `maxAgeSeconds` parameterized (D-13 "$~15 min", plan fixes 900).
|
||||
**Warning signs:** fresh /start after deploy logs "stale" (or stale logs nothing).
|
||||
|
||||
### Pitfall 4: Replay handling vs rotate-on-new (D-07)
|
||||
**What goes wrong:** naive mintSession lets a stale-but-replayed initData mint *another* token (tests pass, D-07 broken structurally).
|
||||
**Why it happens:** minting is unordered in the naive flow.
|
||||
**How to avoid:** the session key is the token id; rotate-on-new means the KV put is keyed and unconditional — old token's key still lives until TTL; D-07's "old token invalidated" is realized by making the KV key a per-chat identity (one session key per chat) or by deleting the old token id. Plan must pick one explicitly (see Open Questions resolution below).
|
||||
**Warning signs:** two different session keys exist for the same chat id.
|
||||
|
||||
### Pitfall 5: `env` shape mismatch in `wrangler types` vs `config.ts`
|
||||
**What goes wrong:** declaring the Env interface by hand drifts from `wrangler generate-types` output.
|
||||
**How to avoid:** commit a `bot/src/config.d.ts` generated from `wrangler types` output (run once at phase setup), or choose import-vs-inline explicitly and check in CI by `wrangler deploy --dry-run` (no secret needed for typing check).
|
||||
**Warning signs:** `KVNamespace` NotFound / env binding missing only at deploy time.
|
||||
|
||||
### Pitfall 6: `.dev.vars` accidentally committed
|
||||
**What goes wrong:** real local secrets via `wrangler dev` get into git (D-17 violation on the first day they exist).
|
||||
**How to avoid:** `bot/.gitignore` ships in the same commit as `.dev.vars.example`; `.dev.vars` is created for the user, not by them.
|
||||
**Warning signs:** repo grep for `BOT_TOKEN=` returns a real value.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### initData validation core (verified shape; full impl in plan)
|
||||
```typescript
|
||||
// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
// + Node 24 / Workers globalThis.crypto (RFC-4231 vector verified 2026-10-22)
|
||||
async function hmacHex(message: string, key: Uint8Array): Promise<string> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
||||
return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function validateInitData(raw: string, botToken: string, maxAgeSeconds = 900, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const parsed = querystringParse(raw); // all `key=value` fields
|
||||
const { hash, ...rest } = parsed; // drop `hash` (+ `signature` if present)
|
||||
const dataCheckString = Object.entries(rest)
|
||||
.sort((a, b) => a[0].localeCompare(b[0])) // sort by FIELD NAME, case-sensitive
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
const secretKey = utf8Bytes("WebAppData");
|
||||
const derived = await hmacHex(botToken, secretKey); // key = "WebAppData", message = botToken
|
||||
const check = await hmacHex(dataCheckString, utf8Bytes(derived)); // key = secretKey (hex → bytes)
|
||||
if (check !== hash) return { ok: false, reason: "tampered" } as const;
|
||||
// ... stale / malformed / success per rejection classes
|
||||
}
|
||||
```
|
||||
|
||||
### Command registration with localized scopes
|
||||
```typescript
|
||||
// Source: grammy@1.46.0 api.d.ts surface (setMyCommands(commands, { bot_command_scope, language_code }), verified)
|
||||
const EN = [{ command: "help", description: "..." }, { command: "privacy", description: "..." }];
|
||||
const MS = [...]; // en + ms
|
||||
await bot.api.setMyCommands(EN, { bot_command_scope: { type: "default" } });
|
||||
await bot.api.setMyCommands(MS, { bot_command_scope: { type: "default" }, language_code: "ms" });
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Cloudflare Pages for static+API | Workers with static assets binding (project already committed — PROJECT.md) | CF deprecation wave | This phase's Worker is Workers-native; no Pages compat guarantee needed |
|
||||
| grammY needed polyfills on Workers | grammY 1.x auto-platform-detects; "cloudflare-mod" is shipped as a named adapter | long-standing stable | `import { Bot, webhookCallback } from "grammy"` just works |
|
||||
| telegram-web-app `WebAppInitData` via community packages | Official `signature` (Ed25519 third-party) channel exists alongside `hash` (HMAC first-party) | Bot API 7.x–10.x additions | Not needed for this phase (first-party bot validation uses `hash`); noted for Phase 5 playbook |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Nothing in the standard stack here is deprecated; `wrangler.toml` vs `wrangler.jsonc` — wrangler 4 supports both, jsonc preferred for comments.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `webhookCallback` bot instantiation per-request is accepted Workers practice (token arrives via env) | Architecture Patterns | LOW — bot migrations would document the binding pattern if not; cost of being wrong is minor refactor in webhook.ts |
|
||||
| A2 | TypeScript 5.9.3 (not TS 7) is the safer pin for wrangler tooling compatibility | Standard Stack | LOW — swap is trivial; nothing in phase depends on TS version specifics |
|
||||
| A3 | `maxAgeSeconds = 900` satisfies D-13's "~15 minutes" envelope | Common Pitfalls | LOW — one-line change; D-13 wrote the envelope as approximate |
|
||||
| A4 | KV key strategy for D-07 (`session:<token>` vs one-key-per-chat) — see Open Questions | Pitfall 4 | LOW-MED — affects rotate-on-new semantics; resolved in Open Questions and implemented accordingly |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **grammY initData typings — RESOLVED:** Not shipped in grammy@1.46.0 / @grammyjs/types@5.0.0 (tarball-verified). Hand-roll a ~20-line `WebAppInitData` + rejections interface in `src/security/initdata.ts` per Telegram's official spec; no third-party package.
|
||||
2. **D-07 rotate-on-new mechanics — RESOLVED:** KV key = `session:<token>` with claim containing `chat_id`; on fresh validated identity for a chat, mint new token and **delete the old token key explicitly** (`ctx`-aware: read prior token claim first). Alternative (single key per chat) was rejected because the token is opaque-by-value (D-06) and clients hold the token, not the chat id. Delete-then-mint in one exchange keeps "one live token per chat" and is testable in the runner.
|
||||
3. **TTL value inside the minutes envelope — RESOLVED:** `chat_sessions` TTL = 900 seconds (15 min), matching D-13's freshness window; the two eine envelope so a session cannot outlive the proof-of-freshness that minted it.
|
||||
4. **Local KV simulation — RESOLVED:** `wrangler dev` provides local KV emulation out of the box (dev bindings); the session endpoint against `wrangler dev` exercises mint/get/rotate manually. Unit tests fake KVNamespace with a 20-line in-memory mock (the runner, D-12).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Node.js | hand-rolled runner (D-12), crypto.subtle | ✓ | 24.18.0 | — |
|
||||
| npm | workspace installs, scripts | ✓ | 11.16.0 | — |
|
||||
| wrangler | dev/deploy/types (D-15/D-16) | ✗ (installs via `npm i -w bot -D wrangler`) | 4.151.3 pinned for install | none needed |
|
||||
| Cloudflare account + bot token + KV binding | deploy step (D-15/D-16) | user Setup | — | user provides `.dev.vars` values; deploy checkpoint |
|
||||
| git | repo hygiene (D-17) | ✓ | — | — |
|
||||
|
||||
**Missing dependencies with no fallback:**
|
||||
- None blocking implementation; deploy checkpoint requires user's Cloudflare/bot credentials (user_setup, standard pattern).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
|
||||
| Config file | none — plain `.mjs`, matches Phase 1 pattern |
|
||||
| Quick run command | `npm test -w bot` |
|
||||
| Full suite command | `npm test -w bot` (single runner, all vector + privacy gates inside) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| ONBD-01 | /start returns concise intro + /help,/privacy enrolled | unit (strings + commands module-level) + **manual Telegram walkthrough** (real client —cannot auto-verify Bot UX) | `npm test -w bot` (partial cases: strings module exports; handlers registered w/o keyboards) + manual | ✅ created this phase (Wave 0) |
|
||||
| PRIV-01 | /privacy copy states stored/not-stored truthfully | unit (strings snapshot classes: states no-location, TTL-only, what IS stored) | `npm test -w bot` | ✅ created this phase (Wave 0) |
|
||||
| PRIV-02 | TTL-only storage, zero persistent coordinates/profiles | unit (mock KV asserts expirationTtl on every put; grep gate for bare `.put(`) | `npm test -w bot` | ✅ created this phase (Wave 0) |
|
||||
| Security | initData vectors: known-good HMAC ✓; tampered → fail; stale → fail; replay double-mint → blocked by rotate | unit (crypto vectors + fake KV) | `npm test -w bot` | ✅ created this phase (Wave 0) |
|
||||
| Webhook auth | secret-token mismatch → 401 | **manual-only + deploy spot-check** — grammY's own verified constant-time check; not re-tested | manual (wrangler dev curl w/ bad header) | n/a — relies on verified library behavior |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `npm test -w bot`
|
||||
- **Per wave merge:** `npm test -w bot` + `tsc -w bot --noEmit`
|
||||
- **Phase gate:** full suite green + real-deploy smoke (manual checkpoint: send /start from real Telegram client)
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `bot/test/runner.mjs` — minimal harness (describe/it substitute, exit codes) — created in this phase's Wave 0 plan
|
||||
- [ ] `bot/test/initdata.test.mjs` — vector suite (known-good + tampered + stale + replay classes) — Wave 0
|
||||
- [ ] Install step: `npm i -w bot` — workspace packages introduced with the plan
|
||||
|
||||
*(Runner exists nowhere yet — every phase-2 test is a Wave 0 flat, created before the feature tasks land.)*
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | initData HMAC validation IS the authentication; only validated identities mint tokens (D-06) |
|
||||
| V3 Session Management | yes | Opaque tokens in TTL-only KV (D-06), rotate-on-new (D-07), one-live-token-per-chat invariant |
|
||||
| V4 Access Control | yes | /api/session refuses requests without valid initData; webhook 401s on secret-token mismatch |
|
||||
| V5 Input Validation | yes | reject classes malformed/tampered/stale; category-only logging (D-14) |
|
||||
| V6 Cryptography | yes | HMAC-SHA256 via native SubtleCrypto (never hand-rolled); constant-time header compare from grammY [VERIFIED] |
|
||||
| V9/V14 Communications/Config | yes | Secrets via wrangler secrets / .dev.vars (D-15); URL in config (D-17) |
|
||||
|
||||
### Known Threat Patterns for Cloudflare Workers + Telegram
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Forged initData / replay attack | Tampering, Spoofing | HMAC validation + auth_date freshness (D-13) + D-07 rotate-on-new |
|
||||
| Webhook flooding with garbage | DoS | grammY 401 fast-path on secret-token mismatch; no KV hit before token check |
|
||||
| Token theft/brute force | Spoofing, Elevation | Opaque random UUID (128-bit entropy), TTL ≤ session freshness window |
|
||||
| Secret leakage via logs/repo | Information Disclosure | D-14 category-only logs; D-17 no committed secrets, `.gitignore` for `.dev.vars` |
|
||||
| KV data毅力 after TTL | Information Disclosure | KV TTL-only namespaces — no KV path without `expirationTtl` (type-enforced) |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- npm registry — grammy@1.46.0, @grammyjs/types@5.0.0, wrangler@4.151.3, @cloudflare/workers-types@5.20261022.1, typescript@5.9.3, @types/node@26.6.5 (all probed 2026-10-22)
|
||||
- grammy 1.46.0 shipped source (`out/convenience/frameworks.js`, `out/convenience/webhook.js`) — cloudflare-mod adapter + constant-time secret-token compare + `webhookCallback` signature
|
||||
- grammy 1.46.0 shipped api.d.ts — `setMyCommands(commands, other?: { bot_command_scope?, language_code? })`
|
||||
- @grammyjs/types 5.0.0 tarball — confirmed absence of WebAppInitData typings
|
||||
- https://core.telegram.org/bots/webapps — field chart, data-check-string/secret-key algorithm, auth_date staleness, `secret_token` webhook param, Ed25519 third-party note, setMyCommands localization support
|
||||
- Local runtime probe: Node 24.18.0 global `crypto.subtle` == RFC-4231 HMAC-SHA256 vector; npm 11.16; wrangler NOT preinstalled (installs as dev dep)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- Cloudflare Workers KV `expirationTtl` semantics + wrangler secrets/.dev.vars — standard documented behavior; will verify live during deploy checkpoint
|
||||
- TS 5.9.3 vs 7.0.2 pin — Assumption A2
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None beyond A1/A2 (everything load-bearing was verified above)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — versions verified against registry; adapter verified from shipped source
|
||||
- Architecture: HIGH — algorithm verified from official Telegram docs; crypto verified locally
|
||||
- Pitfalls: HIGH — pitfalls correspond to verified algorithm details (sorting, key derivation, ms/seconds, replay)
|
||||
|
||||
**Research date:** 2026-10-22
|
||||
**Valid until:** 2026-11-21 (stable stack; wrangler major bumps warrant re-check)
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
status: all_fixed
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
source: [02-REVIEW.md]
|
||||
fixed: 2026-10-22
|
||||
---
|
||||
|
||||
## Fix Summary
|
||||
|
||||
| Finding | Fix | Commit | Verification |
|
||||
|---|---|---|---|
|
||||
| WR-01 unbounded body read on `/api/session` | `bot/src/webhook.ts`: 4 KB pre-read Content-Length rejection + post-read backstop → 413, category-only logs | `8995498` | WK1 valid mint 200; WK2 over-cap signed body → 413, zero KV writes, log `"[initdata] rejected: malformed"`; WK2b lying Content-Length → 413; WK3 broken stream resolves 413 |
|
||||
| WR-02 fail-open webhook when secret missing | `webhook.ts`: missing/empty `WEBHOOK_SECRET` binding → constant 500 + `[webhook] rejected: misconfigured` before grammY accept-all path; `config.ts` REQUIRED comment | `3ea1fdd` | WK5 unarmed → two consecutive constant 500s, no bot wiring, no KV touch; RED evidence recorded pre-fix; armed+unsigned covered by live 02-03 smokes (401) |
|
||||
| WR-03 rotate-on-new atomicity over-claim | `sessions.ts`: honest invariant documented (intended single live token; reconciliation at mint/next use; orphans TTL-bounded; Phase 4 must not assume more) + best-effort post-mint sweep | `af691ae` | SR2 re-worded sequential; SR3 stale-replica emulation asserts honest outcome; PG1/PG2 unchanged |
|
||||
|
||||
Adjacent INFO fixed in same commits: IN-01(b) dead branch removed, IN-01(c) Phase-4 don't-delete notes added.
|
||||
|
||||
Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm test -w bot` → **34 passed, 0 failed** (was 27)
|
||||
- `npm run bot:typecheck` → exit 0
|
||||
- All 4 `console.*` in bot/src are category-only (no payload/uid/timestamp/coords)
|
||||
- Redeployed after fixes: version `b6ba6e58`; live `/api/session` garbage → 401; webhook pending 0; only stale (pre-fix) last_error
|
||||
|
||||
## Review status
|
||||
|
||||
02-REVIEW.md status `issues_found` → all Critical+Warning findings fixed; 3 INFO deferred deliberately.
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
reviewed: 2026-10-22T19:59:00Z
|
||||
depth: standard
|
||||
files_reviewed: 14
|
||||
files_reviewed_list:
|
||||
- bot/package.json
|
||||
- bot/wrangler.jsonc
|
||||
- bot/tsconfig.json
|
||||
- bot/.gitignore
|
||||
- bot/.dev.vars.example
|
||||
- bot/src/config.ts
|
||||
- bot/src/webhook.ts
|
||||
- bot/src/security/initdata.ts
|
||||
- bot/src/kv/sessions.ts
|
||||
- bot/src/i18n/strings.ts
|
||||
- bot/src/bot/bot.ts
|
||||
- bot/src/bot/commands/start.ts
|
||||
- bot/src/bot/commands/help.ts
|
||||
- bot/src/bot/commands/privacy.ts
|
||||
- bot/scripts/register-commands.mjs
|
||||
- bot/scripts/set-webhook.mjs
|
||||
- bot/test/runner.mjs
|
||||
- bot/test/harness.mjs
|
||||
- bot/test/initdata.test.mjs
|
||||
- bot/test/commands.test.mjs
|
||||
- bot/test/privacy.test.mjs
|
||||
- bot/test/sessions.test.mjs
|
||||
- package.json
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 3
|
||||
info: 5
|
||||
total: 8
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-22T19:59:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22 (14 entry list + hidden config/test files)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
The security centerpiece holds up under scrutiny. The HMAC initData validator implements the official algorithm correctly (key = `HMAC-SHA256(key="WebAppData", msg=botToken)`, message = key-sorted data-check-string, comparison in constant time, freshness check after signature — D-13 precedence as specified). Re-verified against grammY's shipped typings: the `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMs, secretToken)` positional form used in webhook.ts matches the 5-arg overload in `grammy/out/convenience/webhook.d.ts`. KV session layer honors TTL-only on every write path; `.dev.vars` is correctly gitignored via `bot/.gitignore` and untracked in git (verified with `git check-ignore` / `git ls-files`). Full suite green (27/27) and `tsc --noEmit` clean at review time.
|
||||
|
||||
Three warnings remain, none of them in the validator's crypto path itself: (1) the only public unauthenticated endpoint accepts an unbounded request body straight into memory, (2) a one-secret-missing misconfiguration silently *disables* the webhook secret check instead of failing closed (confirmed in grammY's runtime source, `webhook.js:24-27`), and (3) the D-07 rotate-on-new invariant is only best-effort under KV's non-atomic read-delete-put, while the code comments call it "structurally closed".
|
||||
|
||||
Not flagged (locked decisions per D-01..D-17): opaque UUID tokens instead of JWTs, no replay-nonce store (D-07 rotation is the accepted replay answer), 900 s TTL envelope, per-request bot instantiation.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `/api/session` reads an unbounded request body into memory (DoS surface)
|
||||
|
||||
**File:** `bot/src/webhook.ts:49`
|
||||
**Issue:** `const raw = await req.text()` has no length cap before `parseFields` splits it. This is the one *unauthenticated, public* endpoint on the Worker: an attacker can POST up to Cloudflare's ingress body limit (tens of MB) of `&` or long values. `raw.split("&")` on a multi-MB body allocates millions of array elements plus Map entries — enough to blow the Worker isolate's 128 MB memory or CPU budget → 500s and isolate churn on a free-tier Worker. Telegram initData is a few hundred bytes; anything more is abuse.
|
||||
**Fix:**
|
||||
```ts
|
||||
async function handleSession(req: Request, env: Env): Promise<Response> {
|
||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||
const raw = await req.text();
|
||||
if (raw.length > 4096) {
|
||||
// register in D-14 vocabulary: still category-only (no payload echo)
|
||||
console.log("[initdata] rejected: malformed");
|
||||
return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
const result = await validateInitData(raw, env.BOT_TOKEN);
|
||||
...
|
||||
```
|
||||
(4 KB is generous — known-good real initData fixtures are ≪ 1 KB.)
|
||||
|
||||
### WR-02: Missing `WEBHOOK_SECRET` silently disables webhook authentication (fail-open)
|
||||
|
||||
**File:** `bot/src/webhook.ts:33-36`
|
||||
**Issue:** `webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET)` passes the secret through, but grammY's check is opt-in per call: `compareSecretToken` in `node_modules/grammy/out/convenience/webhook.js:24-27` returns `true` for **every** request when `secretToken === undefined`. If `BOT_TOKEN` is armed as a wrangler secret but `WEBHOOK_SECRET` is forgotten (a plausible prod path — no deploy script runs `wrangler secret put`), Telegram still sends the header against a `setWebhook`-registered secret, yet the Worker accepts *any* unsigned POST to `/telegram/webhook` as auth: anyone can inject fabricated updates/spam, and this is the identity-carrying path Phase 4's Mini App flow will build on. Today's phase fails-anything-but-500 for missing secrets elsewhere; the one auth gate must not be the exception.
|
||||
**Fix:**
|
||||
```ts
|
||||
if (url.pathname === "/telegram/webhook") {
|
||||
if (!env.WEBHOOK_SECRET) {
|
||||
// fail closed: an unarmed secret must not silently arm the endpoint
|
||||
return new Response("Server misconfigured", { status: 500 });
|
||||
}
|
||||
const bot = createBot(env);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### WR-03: D-07 rotate-on-new is not atomic — interleaved mints leave an orphan live token
|
||||
|
||||
**File:** `bot/src/kv/sessions.ts:49-57`
|
||||
**Issue:** `mintSession` is get-pointer → delete-old-session → put-new-session → put-pointer. Cloudflare KV has no transactions and is eventually consistent, so two `POST `/api/session`` replays of the same valid initData can interleave: both read the same pointer, both mint, the loser's `session:<token>` record is never deleted, and KV replication may serve a stale pointer across colos — both tokens are live for up to 900 s. The module comments claim the invariant is "structurally closed" (line 48: "one live token per chat") and `SR2` asserts "exactly one live record" — true only sequentially. Impact is bounded (any party able to mint within the 15-min freshness window already holds a valid identity by D-13's design), so this is not a privilege escalation, but the invariant the tests/docs pin is stronger than what KV delivers.
|
||||
**Fix (pick one and make the choice explicit in docs/tests):**
|
||||
- Reconcile after write: re-read `chat:<chat_id>` after both `put`s and `kv.delete` the pointer target if it is neither the new token nor already deleted.
|
||||
- Or downgrade the stated invariant truthfully: "rotation is best-effort under KV eventual consistency; every token dies within 900 s regardless", and note it in `sessions.ts`/`02-CONTEXT.md` so Phase 4 does not build a nonce-store assumption on top.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Dead code — unused `ParsedInitData` export, unreachable branch condition, unused read APIs
|
||||
|
||||
**File:** `bot/src/security/initdata.ts:53-60` / `bot/src/kv/sessions.ts:52` / `bot/src/kv/sessions.ts:61-86`
|
||||
**Issue:** (a) `ParsedInitData` is exported and never imported anywhere — `validateInitData` never emits it (the file's own header comment doesn't use the shape either). (b) `previousToken !== token` at sessions.ts:52 can never be false — `token` is a fresh `crypto.randomUUID()` minted two lines above, so the guard is unreachable code dressed as a check. (c) `getTokenForChat` and `getClaimByToken` have no production caller in this phase; acceptable as the Phase 4 access-layer API, but only if that intent is stated.
|
||||
**Fix:** Delete `ParsedInitData` and the dead `previousToken !== token` half-branch (keep `if (previousToken !== null) { ... }`). Add a one-line comment over the two read helpers: "consumed by the Phase 4 Mini App flow" — or their first real consumer from that phase.
|
||||
|
||||
### IN-02: Stale wrangler.jsonc comment contradicts the shipped namespace id
|
||||
|
||||
**File:** `bot/wrangler.jsonc:2` (vs `:9`)
|
||||
**Issue:** Line 2 still says the KV id is "filled in plan 03 task 1 after `npx wrangler kv namespace create`", but line 9 already carries the real id. A future reader trusts the comment and tries to re-create/replace an existing namespace — the exact setup churn the comment was guarding against.
|
||||
**Fix:** Replace the comment with the post-fact record: `// chat_sessions namespace created via wrangler kv namespace create (id below is live).`
|
||||
|
||||
### IN-03: Claimed 900 s freshness boundary is not actually tested
|
||||
|
||||
**File:** `bot/test/initdata.test.mjs:133-150` / `bot/src/security/initdata.ts:185`
|
||||
**Issue:** Both the implementation comment ("899 ok / 900 ok / 901 stale") and the T4 plan-header claim the exact-900 case, but the suite only signs 899 s and 901 s fixtures. For a security-critical boundary (D-13), the middle point is the one that pins the comparator direction (`>` vs `>=`); the code's `>` matches the stated claim today, but nothing would catch a `>=` regression.
|
||||
**Fix:**
|
||||
```js
|
||||
test("T4 freshness boundary: age 900 s → ok:true (boundary is inclusive)", async () => {
|
||||
const validateInitData = await loadValidator();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const res = await validateInitData(signedUser(now - 900), TEST_TOKEN);
|
||||
assert.equal(res.ok, true, `expected ok:true at exactly 900 s, got ${JSON.stringify(res)}`);
|
||||
});
|
||||
```
|
||||
|
||||
### IN-04: `.dev.vars` parser diverges from wrangler's dotenv semantics — and is duplicated in both scripts
|
||||
|
||||
**File:** `bot/scripts/register-commands.mjs:29-46` / `bot/scripts/set-webhook.mjs:24-41`
|
||||
**Issue:** The hand-rolled parser keeps quotes and inline comments as part of the value (`BOT_TOKEN="123:ABC"` sends a token wrapped in literal quotes; `WEBHOOK_URL=... # comment)` mangles the URL), whereas wrangler itself parses `.dev.vars` dotenv-style with quotes stripped. A user following dotenv habits gets opaque 401s from `npm run bot:commands` while local `wrangler dev` works — the two runtimes disagree about the same file. Additionally the identical 18-line `readDevVars` exists in both scripts — the repo's first copy-paste of a secrets-handling helper, the worst place for implementations to drift.
|
||||
**Fix:** Strip surrounding quotes and trailing inline comments in one shared `bot/scripts/lib/dev-vars.mjs`, imported by both scripts; or better, call out the assumed format in `.dev.vars.example` (`unquoted KEY=value`, no inline comments).
|
||||
|
||||
### IN-05: `handleSession` has no error handling around the mint — KV failure surfaces as an opaque 500 with no category log
|
||||
|
||||
**File:** `bot/src/webhook.ts:47-64`
|
||||
**Issue:** `validateInitData` itself never throws (all parse paths are caught), but `mintSession` unhooks that guarantee: a KV write failure during `/api/session` propagates as an unhandled Worker exception — generic 500 page, no D-14 category entry, nothing distinguishing "storage down" from a crash in review/Tail logs. Not a correctness hazard (fail-closed), just an observability gap in the phase that defined the category vocabulary.
|
||||
**Fix:**
|
||||
```ts
|
||||
try {
|
||||
const token = await mintSession(env.chat_sessions, { /* claim */ });
|
||||
return new Response(JSON.stringify({ token }), { status: 200, ... });
|
||||
} catch {
|
||||
console.log("[session] mint failed: storage"); // category-only (D-14)
|
||||
return new Response(JSON.stringify({ error: "storage" }), { status: 503, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Verified during review (not findings): validation vectors T1–T7 green; `tsc --noEmit` clean; `bot/.dev.vars` ignored and untracked; HMAC key derivation matches Telegram's official algorithm and the RFC-4231-pinned independent node:crypto path; every `kv.put` carries `expirationTtl`; the single `console.log` is category-only (D-14); no secrets or payload fields echoed in scripts or responses._
|
||||
|
||||
_Reviewed: 2026-10-22T19:59:00Z_
|
||||
_Reviewer: OpenCode (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
phase: 2
|
||||
slug: bot-skeleton-initdata-security
|
||||
status: ready
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: false
|
||||
created: 2026-10-22
|
||||
---
|
||||
|
||||
# Phase 2 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
|
||||
| **Config file** | none — plain `.mjs` (Wave 0 creates) |
|
||||
| **Quick run command** | `npm test -w bot` |
|
||||
| **Full suite command** | `npm test -w bot` (single runner covers vectors + privacy gates) |
|
||||
| **Estimated runtime** | ~2 seconds (pure crypto + mocks, no network) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `npm test -w bot`
|
||||
- **After every plan wave:** Run `npm test -w bot` + `tsc --noEmit` (bot workspace)
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-task Verification Map
|
||||
|
||||
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| {N}-01-01 | 01 | 1 | PRIV-02, D-08/D-11/D-12/D-15/D-17 | T-02-03 / T-02-04 | no committed secrets; TTL-only type surface scaffold | unit (harness + vector stubs live) | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
||||
| {N}-01-02 | 01 | 1 | Security (SC3), ONBD-03-adjacent | T-02-01 / T-02-02 | tampered/stale/replayed/malformed rejected; known-good HMAC passes | unit (vectors) | `npm test -w bot` | ❌ W0 (stubbed in 01-01, filled here) | ⬜ pending |
|
||||
| {N}-02-01 | 02 | 2 | PRIV-01/D-05 | T-02-04 | strings module has en + ms, no violation classes; TTL-only log categories (D-14) | unit | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
||||
| {N}-02-02 | 02 | 2 | ONBD-01, D-01..D-05 | — | handlers lean, no keyboards (D-02); alias co-routing (D-04); setMyCommands default+ms (D-03/D-04) | unit | `npm test -w bot` | ✅ (from 02-01 forward) | ⬜ pending |
|
||||
| {N}-02-02-manual | 02 | 2 | ONBD-01 | — | real Telegram client: /start, /help, /privacy, aliases | manual checkpoint | — | — | ⬜ pending |
|
||||
| {N}-03-01 | 03 | 3 | D-06/D-07/D-08, PRIV-02 | T-02-05 / T-02-06 | mint requires validated initData; rotate-on-new; claim is D-08-minimal; expirationTtl on every put | unit (mock KV) | `npm test -w bot` | ✅ | ⬜ pending |
|
||||
| {N}-03-02 | 03 | 3 | D-16 deploy gate | T-02-03 | wrangler dry-run clean; .dev.vars ignored; secrets not in git | integration (dry-run) + manual checklist | `npx wrangler deploy --dry-run && npm test -w bot` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `bot/test/runner.mjs` — minimal hand-rolled harness (theme/scripts/audit.test.mjs shape, node:assert)
|
||||
- [ ] `bot/test/initdata.test.mjs` — vector suite skeleton (known-good + tampered + stale + replay cases)
|
||||
- [ ] `bot/.gitignore` (+ `.dev.vars.example`) — ships in the same commit as the workspace init (D-17)
|
||||
- [ ] `npm i -w bot` — workspace package install (grammy, wrangler, typescript, workers-types, @types/node)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| /start /help /privacy + /mula /privasi reply correctly in real Telegram | ONBD-01, PRIV-01 | Requires a live Telegram client + deployed bot token; Bot UI cannot be driven from the runner | 1) `npm run deploy` (D-16) 2) /start the real bot → intro + menu 3) /help → menu matches D-03 4) /privacy → truthful copy 5) /mula /privasi → same flows 6) non-en client → ms menu (covers setMyCommands scope) |
|
||||
| Webhook 401 on bad secret-token header | V4/V2 security gate | grammY's verified constant-time check is library behavior, not our code | `curl -X POST $URL/telegram/webhook -d '{}'` (no header) → 401; with wrong value → 401 |
|
||||
| Deploy smoke + KV spot-check | PRIV-02, D-16 | Live storage inspection | after deploy: `npx wrangler kv key list --namespace chat_sessions` (via dev session) — only TTL-bounded keys; no coordinate values; keys expire after 900s |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 5s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
phase: 02-bot-skeleton-initdata-security
|
||||
verified: 2026-10-22T21:05:00Z
|
||||
status: passed
|
||||
score: 4/4 must-haves verified
|
||||
overrides_applied: 0
|
||||
re_verification:
|
||||
previous_status: none
|
||||
previous_score: n/a
|
||||
gaps_closed: []
|
||||
gaps_remaining: []
|
||||
regressions: []
|
||||
---
|
||||
|
||||
# Phase 2: Bot Skeleton + initData Security Verification Report
|
||||
|
||||
**Phase Goal:** Users can reach araharah through Telegram with a working command surface, and the backend's identity + privacy architecture is provably sound from the first deploy — the initData validator is the first backend artifact.
|
||||
**Verified:** 2026-10-22T21:05Z
|
||||
**Status:** passed
|
||||
**Re-verification:** No — initial verification (codebase + live deployment)
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth (ROADMAP SC) | Status | Evidence |
|
||||
|---|--------------------|--------|----------|
|
||||
| 1 | SC1/ONBD-01: /start gives a concise explanation plus an always-available command menu (/help, /privacy) that answers correctly | ✓ VERIFIED | Handlers `bot.command("start"/"mula", …)` reply `strings.startIntro` (one clinical sentence, text-only, no keyboard — CS4 grep gate green); live Telegram read-backs: `getMyCommands` (default) and `getMyCommands?language_code=ms` both return **exactly** `help` + `privacy`, `getChatMenuButton` → `commands`; user real-client walkthrough a–e all green and APPROVED (`02-03-APPROVAL.md`) |
|
||||
| 2 | SC2/PRIV-01: /privacy gives an honest visible statement of what araharah stores / never stores | ✓ VERIFIED | `privacy.ts` builds `privacyStored` + `privacyNotStored` from `strings.ts`; copy names exactly user id, chat id, language, ~15-min TTL — matching `sessions.ts` `SessionClaim {user_id, chat_id, issued_at, language_code}` and `SESSION_TTL_SECONDS=900`; no 5th claim field in code; user observed block as written (approval check c) |
|
||||
| 3 | SC3: tampered, stale, and replayed initData rejected with CI test vectors; known-good HMAC passes; only validated identities get a one-time token | ✓ VERIFIED | `npm test -w bot` → **34 passed, 0 failed** (T1–T7 security vectors incl. tamper/stale/899/901 boundary/order/malformed + RFC-4231 pin; WK1–WK5; sessions SM1/SM2/SR1–SR3; privacy PG1/PG2). Mint path is *only* after `ok:true` in `webhook.ts` (single `mintSession` call). Replay closes via D-07 rotate-on-new (CONTEXT decision; re-mint cannot yield an extra token — accepted locked decision, WR-03 invariant documented honestly) |
|
||||
| 4 | SC4/PRIV-02: storage spot-check shows TTL-only KV, zero persistent coordinates or profiles | ✓ VERIFIED | Live `wrangler kv key list --namespace-id 59f58a54…` → `[]` (empty = TTL-expired privacy-by-construction); every `kv.put` in `sessions.ts` carries `expirationTtl: SESSION_TTL_SECONDS` on the call line (PG1 grep test green); `wrangler.jsonc` has exactly ONE kv binding (`chat_sessions`), no D1/R2/DO anywhere; claim key-set equals D-08 enforced by SM1 |
|
||||
|
||||
**Score:** 4/4 truths verified
|
||||
|
||||
### Post-Review Hardening (WR-01/02/03) — Live Verification
|
||||
|
||||
| Check | Status | Evidence |
|
||||
|-------|--------|----------|
|
||||
| Deployed version includes all 3 fix commits | ✓ | Commits `8995498`/`3ea1fdd`/`af691ae` at 12:17–12:21 UTC; HEAD = `af691ae`; latest active deployment `b6ba6e58-37dd-4aa1…` created 12:22:03 UTC (after final commit — `wrangler deployments list`) |
|
||||
| WR-01 live (4 KB body cap) | ✓ | `POST /api/session` with 5 KB body → **413**; tiny garbage → 401 `{"error":"malformed"}` |
|
||||
| WR-02 fail-closed secret guard | ✓ | Code verified at `webhook.ts:50-53` (constant 500 before grammY accept-all path); WK5 test green; not live-testable read-only (would require rotating secrets — timing evidence + 413 smoke confirm post-fix bundle deployed) |
|
||||
| WR-03 honest invariant | ✓ | `sessions.ts` header documents best-effort rotate + reconciliation sweep; SR3 asserts the honest KV-limits outcome; IN-01b dead branch removed |
|
||||
|
||||
### Deferred Items (Step 9b filter of review findings)
|
||||
|
||||
| # | Item | Addressed In | Evidence |
|
||||
|---|------|-------------|----------|
|
||||
| 1 | Stale wrangler.jsonc comment (line 2: "filled in plan 03") contradicting the live id on line 9 (REVIEW IN-02, INFO) | Phase 7 | `02-REVIEW-FIX.md`: "Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05" — doc-comment hygiene, zero functional impact |
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `bot/src/security/initdata.ts` | validateInitData + rejection classes, no grammy dep | ✓ VERIFIED | 215 lines; SubtleCrypto HMAC, constant-time hex compare, "WebAppData" salt; consumed by `webhook.ts` |
|
||||
| `bot/src/kv/sessions.ts` | TTL-only KV layer, rotate-on-new | ✓ VERIFIED | 122 lines; 2 puts, both with `expirationTtl`; read helpers noted as Phase 4 API (IN-01c) |
|
||||
| `bot/src/webhook.ts` | Worker entry: webhook route + /api/session gate | ✓ VERIFIED | Two routes only + 404; WR-01 cap; WR-02 fail-closed; D-14 category-only logs (4 console lines, all category-only) |
|
||||
| `bot/src/i18n/strings.ts` + `bot/src/bot/{bot.ts,commands/*}` | Copy source of truth, en+ms, text-only handlers | ✓ VERIFIED | WIRED: commands import strings; bot.ts registers 5 commands; live menu read-back matches helpMenu |
|
||||
| `bot/src/config.ts` | Env contract | ✓ VERIFIED | BOT_TOKEN/WEBHOOK_SECRET/chat_sessions; REQUIRED comment added in WR-02 fix |
|
||||
| `bot/test/{harness,runner}.mjs` | Zero-dep D-12 harness | ✓ VERIFIED | `test: "node test/runner.mjs"` — ran clean, 34/0, `devDependencies` carry zero test libs |
|
||||
| `bot/test/{initdata,sessions,privacy,commands,webhook}.test.mjs` | Vector suites | ✓ VERIFIED | 34 cases green incl. T1–T7, WK1–WK5 (webhook suite added by review fix `8995498`) |
|
||||
| `bot/scripts/{register-commands,set-webhook}.mjs` | Menu registration + setWebhook w/ secret_token | ✓ VERIFIED | Descriptions derived from helpMenu (no drift); secrets read from `.dev.vars`, never echoed |
|
||||
| `bot/wrangler.jsonc` | main src/webhook.ts + chat_sessions binding (live id) | ✓ VERIFIED | id `59f58a54…` live (matches APPROVAL record + deployment) |
|
||||
| `.dev.vars` hygiene | Gitignored, untracked | ✓ VERIFIED | `git check-ignore` ok; `git ls-files` shows only `.dev.vars.example` |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|----|--------|---------|
|
||||
| webhook.ts | initdata.ts | `validateInitData` call → 401/413 on rejection, mint on ok | ✓ WIRED | Lines 99–113; live 401 smoke confirms |
|
||||
| webhook.ts | sessions.ts | `mintSession` only after ok:true | ✓ WIRED | Single mint path; WK1 asserts exactly two 900s KV writes |
|
||||
| webhook.ts | bot.ts / grammy | `webhookCallback(bot,"cloudflare-mod",undefined,undefined,env.WEBHOOK_SECRET)` | ✓ WIRED | Live unsigned/wrong-secret webhook → 401 both |
|
||||
| commands/*.ts | strings.ts | copy imported, no inline copy | ✓ WIRED | start/help/privacy all import `strings`; reply builders pure |
|
||||
| register-commands.mjs | strings.ts | menu descriptions derived from helpMenu | ✓ WIRED | Live getMyCommands text matches strings exactly |
|
||||
| sessions.ts | KVNamespace.put | every put carries expirationTtl | ✓ WIRED | PG1/PG2/SM2 green; live KV list empty post-TTL |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|--------------|--------|-------------------|--------|
|
||||
| webhook.ts handleSession | initData string | HTTP request body | Yes — validated against real bot-token HMAC | ✓ FLOWING |
|
||||
| privacy command | locale/strings | ctx.from.language_code → pickLocale | Yes (live user confirmed) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full test suite | `npm test -w bot` | `[runner] 34 passed, 0 failed`, exit 0 | ✓ PASS |
|
||||
| Typecheck | `npm run bot:typecheck` | exit 0 | ✓ PASS |
|
||||
| Webhook auth (unsigned / wrong header) | live POST /telegram/webhook | 401 / 401 | ✓ PASS |
|
||||
| /api/session garbage | live POST | 401 `{"error":"malformed"}` | ✓ PASS |
|
||||
| /api/session 5 KB body (WR-01) | live POST | 413 | ✓ PASS |
|
||||
| GET /api/session | live | 405 | ✓ PASS |
|
||||
| Unknown route | live | 404 | ✓ PASS |
|
||||
| Command menu (default + ms) | live getMyCommands ×2 | exactly /help + /privacy in both languages | ✓ PASS |
|
||||
| Menu button | live getChatMenuButton | type `commands` | ✓ PASS |
|
||||
| Webhook armed | live getWebhookInfo | url `/telegram/webhook`, pending_update_count 0, `allowed_updates:["message"]` | ✓ PASS |
|
||||
| KV TTL-only spot-check | live `wrangler kv key list` | `[]` (empty = all sessions expired ≤900s) | ✓ PASS |
|
||||
| Deployed = HEAD (post-fix bundle) | `wrangler deployments list` + git log | b6ba6e58 (12:22:03Z) created after af691ae (12:21:11Z); current deployments list shows b6ba6e58 active at 100% | ✓ PASS |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| ONBD-01 | 02-01, 02-03 | Open bot, /start gives concise explanation + always-available command menu | ✓ SATISFIED | Handlers + strings verified; live menu lists exactly /help /privacy both scopes; user-approved walkthrough (SC1) |
|
||||
| PRIV-01 | 02-01, 02-03 | Privacy promise visible in-product: /privacy command | ✓ SATISFIED (phase half) | /privacy + /privasi live-observed; noted: "trip ended, data deleted" messaging half completes in Phase 6 per REQUIREMENTS.md:127 |
|
||||
| PRIV-02 | 02-01, 02-02 | No persistent per-user history/profile — TTL-only ephemeral state | ✓ SATISFIED | Live KV `[]`; TTL-only on all write paths (PG1/PG2/SM tests); one binding in wrangler.jsonc; no other storage code in bot/src |
|
||||
|
||||
All phase requirement IDs (ONBD-01, PRIV-01, PRIV-02) appear in plan frontmatter and are mapped to Phase 2 in REQUIREMENTS.md:97-99 — no orphaned requirements. Roadmap Phase 2 defines exactly 4 success criteria (device-matrix criterion belongs to Phase 3's SC5) — all 4 covered above.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| bot/wrangler.jsonc | 2 | Stale "KV_ID_PLACEHOLDER" comment vs live id on line 9 | ℹ️ Info | Doc-only, deferred to Phase 7 (REVIEW IN-02) |
|
||||
| bot/src/security/initdata.ts | 53-60 | `ParsedInitData` exported but unused | ℹ️ Info | Type-only export, deliberate; documented in REVIEW IN-01(a) |
|
||||
| — (everything else) | — | TODO/FIXME/placeholder/console-echo of secrets/empty implementations: full grep of src/, scripts/, test/ returned **zero** | — | — |
|
||||
|
||||
The `getWebhookInfo` `last_error` timestamp (11:18:20 UTC) predates the final BOT_TOKEN secret re-upload (11:18:25 UTC deployment from the walkthrough remediation) and the user's subsequent live `/start` approval; `pending_update_count` is 0. Known resolved history, not a finding.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
**None outstanding.** The human-only gates of this phase were already resolved and are recorded as evidence:
|
||||
|
||||
- **Real-client walkthrough a–e** (D-16 blocking gate): user performed 2026-10-22 and said *"it works! Menu or / has /help and /privacy! approved!"* — `02-03-APPROVAL.md` (cross-checked live via Telegram API read-backs performed during this verification).
|
||||
- Secret provisioning (BotFather token, wrangler secrets): resolved during deploy chain; runtime behavior live-verified by smokes.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps. All four roadmap success criteria verified against code, tests, and the live deployment; the code-review's 3 warnings are fixed and the fix bundle is what is live (timing + WR-01 live smoke). The one un-replicable human gate (real Telegram client) is user-approved with recorded evidence.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-22T21:05Z_
|
||||
_Verifier: OpenCode (gsd-verifier)_
|
||||
_Verification basis: local code at af691ae + live deployment b6ba6e58; all Cloudflare/Telegram state touched read-only._
|
||||
@@ -0,0 +1,824 @@
|
||||
---
|
||||
phase: 03-mini-app-shell-geolocation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- web/package.json
|
||||
- web/tsconfig.json
|
||||
- web/vite.config.ts
|
||||
- web/index.html
|
||||
- web/test/runner.mjs
|
||||
- web/test/harness.mjs
|
||||
- bot/src/kv/location-pin.ts
|
||||
- bot/src/bot/handlers/location-fallback.ts
|
||||
- bot/src/bot/bot.ts
|
||||
- bot/src/i18n/strings.ts
|
||||
- bot/src/webhook.ts
|
||||
- bot/test/location-pin.test.mjs
|
||||
- bot/test/privacy.test.mjs
|
||||
- package.json
|
||||
autonomous: true
|
||||
requirements: [GEO-02, GEO-03]
|
||||
must_haves:
|
||||
decisions_implemented:
|
||||
- "D-01: TypeScript + Vite frontend, no UI framework, MapLibre GL JS v6 bundled — the workspace foundation Phases 4–6 build on"
|
||||
- "D-02: app source in a `web/` npm workspace; theme consumed as a build-time dependency, not CDN"
|
||||
truths:
|
||||
- "`web/` exists as an npm workspace with Vite + TypeScript configured and `npm test -w web` runs"
|
||||
- "A dev-signed initData string can be produced by a test helper (the keystone for every later web test)"
|
||||
- "`GET /api/location` validates a Bearer session token and returns only that token's own chat pin, or 401"
|
||||
- "`POST /api/location-fallback` validates the same token and asks the bot to send the location keyboard into THAT claim's chat, in the claim's language"
|
||||
- "The bot sends a ReplyKeyboardMarkup with request_location:true and records the shared pin in TTL-only KV"
|
||||
- "Every KV write in the new location-pin module carries an explicit expirationTtl on the same line"
|
||||
artifacts:
|
||||
- path: "web/package.json"
|
||||
provides: "@araharah/web workspace manifest with test/typecheck/dev/build scripts"
|
||||
- path: "web/tsconfig.json"
|
||||
provides: "DOM-targeted strict TS config mirroring bot/tsconfig.json flags (+ resolveJsonModule for the basemap style import)"
|
||||
- path: "web/test/runner.mjs"
|
||||
provides: "hand-rolled zero-dependency test runner (copied from bot/test/runner.mjs)"
|
||||
- path: "web/test/harness.mjs"
|
||||
provides: "zero-dependency test registry (copied from bot/test/harness.mjs)"
|
||||
- path: "bot/src/kv/location-pin.ts"
|
||||
provides: "TTL-only KV location-pin layer (put/get/delete by chat id)"
|
||||
- path: "bot/src/bot/handlers/location-fallback.ts"
|
||||
provides: "GEO-02 keyboard sender + message.location receiver"
|
||||
- path: "bot/src/webhook.ts"
|
||||
provides: "GET /api/location relay + POST /api/location-fallback keyboard trigger, both Bearer-gated"
|
||||
key_links:
|
||||
- from: "bot/src/webhook.ts"
|
||||
to: "bot/src/kv/sessions.ts"
|
||||
via: "getClaimByToken(env.chat_sessions, token) — derives chat_id from the CLAIM, never from a client-supplied id"
|
||||
pattern: "getClaimByToken"
|
||||
- from: "bot/src/webhook.ts"
|
||||
to: "bot/src/bot/handlers/location-fallback.ts"
|
||||
via: "handleLocationFallback calls requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)) — the ONLY caller, so the export is never dead code"
|
||||
pattern: "requestLocationFallback"
|
||||
- from: "bot/src/bot/handlers/location-fallback.ts"
|
||||
to: "bot/src/kv/location-pin.ts"
|
||||
via: "writePin(kv, chatId, {latitude, longitude}) after message.location arrives"
|
||||
pattern: "writePin"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Stand up the `web/` npm workspace shell (D-01, D-02) with the Wave-0 test runner, and build the
|
||||
server half of GEO-02: the TTL-only location-pin KV module, the bot's chat-keyboard fallback handler,
|
||||
and BOTH Worker routes that relay a chat-shared pin to the open Mini App — `GET /api/location`
|
||||
(the poll) and `POST /api/location-fallback` (the app→bot keyboard trigger).
|
||||
|
||||
Purpose: GEO-02 is impossible without a chat→WebView relay — Telegram has no push channel from
|
||||
chat to WebView, so the bot must write the pin to KV and the app must poll an authenticated endpoint
|
||||
(RESEARCH §Pattern 3). The trigger direction matters just as much: the denied card's ASK VIA CHAT
|
||||
CTA (plan 03-03) and the Desktop leg of the device matrix (plan 03-04) both need a route that makes
|
||||
the bot send the keyboard. Both directions land here.
|
||||
|
||||
Output: `web/` workspace + test runner; `bot/src/kv/location-pin.ts`; `bot/src/bot/handlers/location-fallback.ts`;
|
||||
`GET /api/location` + `POST /api/location-fallback` routes; `bot/test/location-pin.test.mjs`; extended `privacy.test.mjs`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
|
||||
**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 3 overruns
|
||||
its context budget, split it rather than pushing through — this plan touches 14 files across 3 tasks,
|
||||
which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
|
||||
A task that needs more than ~7 files should be decomposed, not absorbed.
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@AGENTS.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
|
||||
@bot/src/webhook.ts
|
||||
@bot/src/kv/sessions.ts
|
||||
@bot/src/bot/bot.ts
|
||||
@bot/src/bot/commands/start.ts
|
||||
@bot/src/i18n/strings.ts
|
||||
@bot/src/config.ts
|
||||
@bot/test/privacy.test.mjs
|
||||
@bot/test/commands.test.mjs
|
||||
@bot/test/webhook.test.mjs
|
||||
@bot/test/runner.mjs
|
||||
@bot/test/harness.mjs
|
||||
@bot/package.json
|
||||
@bot/tsconfig.json
|
||||
@package.json
|
||||
@theme/package.json
|
||||
|
||||
<interfaces>
|
||||
From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
|
||||
|
||||
```typescript
|
||||
export const SESSION_TTL_SECONDS = 900;
|
||||
|
||||
export interface SessionClaim {
|
||||
user_id: number;
|
||||
chat_id: number;
|
||||
issued_at: number; // seconds-since-epoch
|
||||
language_code: string | null;
|
||||
}
|
||||
|
||||
interface KvLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise<string>;
|
||||
export async function getClaimByToken(kv: MintSessionKv, token: string): PromiseSessionClaim | null;
|
||||
export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise<string | null>;
|
||||
```
|
||||
|
||||
From `bot/src/config.ts`:
|
||||
```typescript
|
||||
export interface Env {
|
||||
BOT_TOKEN: string;
|
||||
WEBHOOK_SECRET: string;
|
||||
chat_sessions: KVNamespace;
|
||||
}
|
||||
```
|
||||
|
||||
From `bot/src/webhook.ts` — the dispatch shape to extend:
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/telegram/webhook") { /* ... */ }
|
||||
if (url.pathname === "/api/session") { return handleSession(req, env); }
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
```
|
||||
|
||||
From `bot/test/webhook.test.mjs` — the dev-signing helper shape (copy this, do NOT reimplement HMAC):
|
||||
```javascript
|
||||
function dataCheckString(fields) {
|
||||
return fields
|
||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
||||
.slice()
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
function telegramSecretKey(botToken) {
|
||||
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
|
||||
}
|
||||
function buildInitData(fields) {
|
||||
const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
|
||||
.update(dataCheckString(fields), "utf8")
|
||||
.digest("hex");
|
||||
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
||||
return `${wire}&hash=${hash}`;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14)</name>
|
||||
<files>web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json</files>
|
||||
<read_first>
|
||||
- `package.json` (root — see current workspaces array and bot:* delegate script block)
|
||||
- `bot/package.json` (the workspace manifest shape to mirror exactly)
|
||||
- `bot/tsconfig.json` (the strictness flags to preserve)
|
||||
- `bot/test/runner.mjs` and `bot/test/harness.mjs` (copy verbatim)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Standard Stack (pinned versions)
|
||||
</read_first>
|
||||
<action>
|
||||
**Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@araharah/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node test/runner.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"capture": "node scripts/capture.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^6.15.0",
|
||||
"@araharah/theme": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.3.5",
|
||||
"typescript": "^5.9.3",
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
Pinned versions come from RESEARCH §Standard Stack (maplibre-gl 6.15.0, vite 8.3.5, typescript 5.9.3 — do NOT bump to 7.x, playwright ^1.50.0).
|
||||
|
||||
**Do NOT add `@types/telegram-web-app`.** RESEARCH §Don't Hand-Roll recommends it for the *full* `WebApp` surface, but this phase consumes exactly 12 members behind one narrow hand-rolled interface (`TelegramWebAppLike` in `web/src/telegram/webapp.ts`, plan 03-02 task 3). A narrow interface is required here because every test fake and every harness mock is a plain object literal matching it — pulling in the 500-member official `WebApp` type would force each fake to satisfy ~50 unimplemented members. This retires RESEARCH assumption A2 by construction: there is no upstream typing to drift from. Do not add the dependency later "for completeness."
|
||||
|
||||
**Step B — `web/tsconfig.json`.** Copy `bot/tsconfig.json` verbatim and change exactly THREE keys (everything else stays byte-identical):
|
||||
- `"lib": ["ES2023", "DOM", "DOM.Iterable"]` (was `["ES2023"]`)
|
||||
- `"types": ["vite/client"]` (was `["@cloudflare/workers-types"]`)
|
||||
- **`"resolveJsonModule": true` (ADDED — not present in `bot/tsconfig.json`)**. This third key is pre-authorized and REQUIRED: plan 03-03 task 2 imports `@araharah/theme/basemap/liberty-rams.json` as a module (`import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"`), which is a TypeScript error without it. Do NOT add `esModuleInterop`/`allowSyntheticDefaultImports` — Vite's bundler resolution + `resolveJsonModule` covers the default import.
|
||||
|
||||
Keep every other flag byte-identical: `"target": "ES2023"`, `"module": "ESNext"`, `"moduleResolution": "bundler"`, `"strict": true`, `"noUncheckedIndexedAccess": true`, `"verbatimModuleSyntax": true`, `"allowImportingTsExtensions": true`, `"noEmit": true`, `"skipLibCheck": true`, `"forceConsistentCasingInFileNames": true`, `"include": ["src"]`. Do NOT add `"jsx"` (no framework, D-01).
|
||||
|
||||
**Step C — `web/vite.config.ts`.**
|
||||
```typescript
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Step D — `web/index.html`.** Minimal shell entry. Exact viewport meta (viewport-fit=cover is the safe-area enabler, UI-SPEC §Safe-Area Handling):
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>araharah</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
Do NOT add a `<script src=".../telegram-web-app.js">` tag — Telegram injects `window.Telegram.WebApp` automatically inside the WebView (RESEARCH §State of the Art). Do NOT copy the mockups' Tailwind Play CDN or Google Fonts links — D-02/D-14 replace them with build-time `@araharah/theme` imports (that wiring lands in plan 03).
|
||||
|
||||
**Step E — `web/test/runner.mjs` and `web/test/harness.mjs`.** Copy `bot/test/runner.mjs` and `bot/test/harness.mjs` **verbatim** — they are directory-relative and workspace-agnostic. Update only the header comment block of `runner.mjs` to name the precedent chain: "Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1) and bot/test/ (Phase 2)."
|
||||
|
||||
**Step F — root `package.json`.** Add `"web"` to the `workspaces` array (after `"bot"`), and add these delegate scripts mirroring the existing `bot:*` block (D-02 ergonomics):
|
||||
```json
|
||||
"web:test": "npm test -w web",
|
||||
"web:typecheck": "npm run typecheck -w web",
|
||||
"web:dev": "npm run dev -w web",
|
||||
"web:build": "npm run build -w web",
|
||||
"web:capture": "npm run capture -w web"
|
||||
```
|
||||
Add the file header comment line to each new web/src file in later tasks: "`filename.ts` — one-line purpose (decision IDs)." then a dashed rule, then the contract in prose (PATTERNS §Shared Pattern 1).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm install 2>&1 | tail -5 && node -e "const p=require('./package.json'); if(!p.workspaces.includes('web')) throw new Error('web workspace missing'); ['web:test','web:typecheck','web:dev','web:build','web:capture'].forEach(k=>{if(!p.scripts[k]) throw new Error('missing script '+k)}); console.log('root OK')" && node web/test/runner.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `node -e "require('./package.json').workspaces.includes('web')"` exits 0
|
||||
- `grep -c '"web:' package.json` returns 5 (test, typecheck, dev, build, capture)
|
||||
- `diff <(tail -n +11 bot/test/runner.mjs) <(tail -n +11 web/test/runner.mjs)` produces no output (bodies identical past the header)
|
||||
- `diff bot/test/harness.mjs web/test/harness.mjs` produces no output (verbatim copy)
|
||||
- `grep -c 'viewport-fit=cover' web/index.html` returns 1
|
||||
- `grep -q 'telegram-web-app.js' web/index.html` must FAIL (no script tag for the Telegram SDK)
|
||||
- `grep -q '"noUncheckedIndexedAccess": true' web/tsconfig.json` exits 0
|
||||
- `grep -q '"resolveJsonModule": true' web/tsconfig.json` exits 0 (pre-authorized third key — required by 03-03's style-JSON import)
|
||||
- `node -e "const t=require('./web/tsconfig.json'); const keys=Object.keys(t.compilerOptions); console.log(keys.length)"` — the file has exactly the three keys changed above plus the inherited set; nothing else was touched
|
||||
- `npm test -w web` exits 0 with `[runner] 0 passed, 0 failed` (empty suite is a pass)
|
||||
</acceptance_criteria>
|
||||
<done>web/ workspace is installed, typecheckable, testable, and wired into root delegates; test runner matches repo convention verbatim.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: TTL-only location-pin KV module + privacy gate extension (GEO-03, PRIV-02)</name>
|
||||
<files>bot/src/kv/location-pin.ts, bot/test/privacy.test.mjs</files>
|
||||
<read_first>
|
||||
- `bot/src/kv/sessions.ts` (the entire contract shape to mirror — KvLike, put-line discipline, defensive read)
|
||||
- `bot/test/privacy.test.mjs` (the PG1/PG2 gate and the storageModules list at line 25)
|
||||
</read_first>
|
||||
<action>
|
||||
**Create `bot/src/kv/location-pin.ts`.** Copy `bot/src/kv/sessions.ts`'s header-comment style, `KvLike` interface, `KvLike` structural typing, named-TTL-constant discipline, and defensive read pattern verbatim. Concrete content:
|
||||
|
||||
Header comment (purpose + D-IDs + the storage contract prose — PATTERNS §Shared Pattern 1):
|
||||
```
|
||||
/*
|
||||
* location-pin.ts — TTL-only KV location-pin layer (GEO-02, GEO-03, PRIV-02).
|
||||
* ----------------------------------------------------------------------
|
||||
* Storage contract (privacy by construction — GEO-03, PRIV-02):
|
||||
* - EVERY write to `chat_sessions` carries an explicit `expirationTtl`
|
||||
* on its call line — no write path in this module can persist
|
||||
* without a TTL. privacy.test.mjs's PG1 gate pins this.
|
||||
* - The record is exactly Telegram's Location fields: latitude,
|
||||
* longitude, horizontal_accuracy (optional). Nothing else is ever
|
||||
* serialized. A 4th property means drift.
|
||||
* - The pin is keyed by chat_id, derived from the SESSION CLAIM
|
||||
* (never from a client-supplied id) — a token resolves to exactly
|
||||
* one chat_id, and the KV key is `loc:<chat_id>`.
|
||||
*
|
||||
* Key scheme:
|
||||
* loc:<chat_id> → JSON LocationPin record
|
||||
*
|
||||
* GEO-03 note: this pin lives only in KV with a TTL. The client never
|
||||
* persists a coordinate anywhere (D-07) — this is the ONLY place a
|
||||
* coordinate ever exists server-side, and it expires.
|
||||
*/
|
||||
```
|
||||
|
||||
Exported constants and types:
|
||||
```typescript
|
||||
export const PIN_TTL_SECONDS = 300;
|
||||
|
||||
export interface LocationPin {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
horizontal_accuracy: number | null;
|
||||
}
|
||||
|
||||
interface KvLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocationPinKv extends KvLike {}
|
||||
```
|
||||
|
||||
Functions (mirroring `sessions.ts` shapes):
|
||||
```typescript
|
||||
export async function writePin(kv: LocationPinKv, chatId: number, pin: LocationPin): Promise<void> {
|
||||
await kv.put(`loc:${chatId}`, JSON.stringify(pin), { expirationTtl: PIN_TTL_SECONDS });
|
||||
}
|
||||
|
||||
export async function getPin(kv: LocationPinKv, chatId: number): Promise<LocationPin | null> {
|
||||
const raw = await kv.get(`loc:${chatId}`);
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"latitude" in parsed &&
|
||||
"longitude" in parsed &&
|
||||
typeof (parsed as LocationPin).latitude === "number" &&
|
||||
typeof (parsed as LocationPin).longitude === "number" &&
|
||||
Math.abs((parsed as LocationPin).latitude) <= 90 &&
|
||||
Math.abs((parsed as LocationPin).longitude) <= 180
|
||||
) {
|
||||
return parsed as LocationPin;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPin(kv: LocationPinKv, chatId: number): Promise<void> {
|
||||
await kv.delete(`loc:${chatId}`);
|
||||
}
|
||||
```
|
||||
The `Math.abs(...) <= 90 / <= 180` range check is the V5 Input Validation mitigation from RESEARCH §Security Domain — reject absurd coordinates rather than flying the camera to null island.
|
||||
|
||||
**Extend `bot/test/privacy.test.mjs`.** At line 25, change:
|
||||
```javascript
|
||||
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
|
||||
```
|
||||
to:
|
||||
```javascript
|
||||
const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
|
||||
```
|
||||
That single edit brings the new module under the existing PG1 (same-line `expirationTtl`, named constant) and PG2 (no persistence-shaped exports) gates automatically.
|
||||
|
||||
**Test-fake note for the later test file:** the KV fake in `bot/test/webhook.test.mjs` already logs `expirationTtl` per put — reuse that shape verbatim in `bot/test/location-pin.test.mjs` (task 3).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -5 && grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -v '^ \*\|^/\*\|^$' bot/src/kv/location-pin.ts | grep -c '\.put('` returns 1 and that line contains `expirationTtl: PIN_TTL_SECONDS`
|
||||
- `grep -n 'kv/location-pin.ts' bot/test/privacy.test.mjs` exits 0 (module is in the scanned list)
|
||||
- `grep -q 'export const PIN_TTL_SECONDS = 300;' bot/src/kv/location-pin.ts` exits 0
|
||||
- `npm test -w bot` exits 0 and reports `[runner] N passed, 0 failed` with N >= 15 (Phase 2's suite plus the extended gate)
|
||||
- `node -e "const s=require('fs').readFileSync('bot/src/kv/location-pin.ts','utf8'); if(!/location\s*:\s*\`loc:/.test(s)) throw new Error('key scheme wrong'); if(!/Math\.abs/.test(s)) throw new Error('range check missing')" ` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>location-pin.ts is TTL-only by construction, range-validated on read, and covered by the existing privacy gate.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: GEO-02 bot half — chat keyboard fallback + `GET /api/location` route + tests</name>
|
||||
<files>bot/src/bot/handlers/location-fallback.ts, bot/src/bot/bot.ts, bot/src/i18n/strings.ts, bot/src/webhook.ts, bot/test/location-pin.test.mjs</files>
|
||||
<read_first>
|
||||
- `bot/src/bot/bot.ts` (the registration pattern)
|
||||
- `bot/src/bot/commands/start.ts` (handler + i18n import + register pattern)
|
||||
- `bot/src/i18n/strings.ts` (BotStrings interface + en/ms key-set parity)
|
||||
- `bot/src/webhook.ts` (dispatch pattern lines 39-63, method gate line 84, auth+service+response pattern lines 99-113)
|
||||
- `bot/src/kv/sessions.ts` (`getClaimByToken`)
|
||||
- `bot/src/kv/location-pin.ts` (written in task 2 — read your own output)
|
||||
- `bot/test/commands.test.mjs` (CS4 keyboard gate — you must decide whether to extend it)
|
||||
- `bot/test/webhook.test.mjs` (fake KV + makeEnv + handler import pattern)
|
||||
</read_first>
|
||||
<action>
|
||||
**Step A — `bot/src/i18n/strings.ts`.** Add two keys to the `BotStrings` interface (after `helpMenu`), both with doc-comments:
|
||||
```typescript
|
||||
/** GEO-02: chat-keyboard fallback prompt — bot tells the user to share a pin. */
|
||||
awaitingPin: string;
|
||||
/** GEO-02: the reply-keyboard button label (request_location: true). */
|
||||
shareLocationButton: string;
|
||||
```
|
||||
Add to `strings.en`:
|
||||
```typescript
|
||||
awaitingPin: "Send me a pin in this chat and I'll use it as your starting point.",
|
||||
shareLocationButton: "Share my location",
|
||||
```
|
||||
Add to `strings.ms`:
|
||||
```typescript
|
||||
awaitingPin: "Hantar saya pin dalam chat ini dan saya akan gunakannya sebagai titik mula anda.",
|
||||
shareLocationButton: "Kongsi lokasi saya",
|
||||
```
|
||||
CS1 (en/ms key-set parity) will verify these stay in step.
|
||||
|
||||
**Step B — `bot/src/bot/handlers/location-fallback.ts`** (create the directory). Two exported functions plus one private handler.
|
||||
|
||||
```typescript
|
||||
/*
|
||||
* location-fallback.ts — GEO-02 chat-keyboard location fallback.
|
||||
* -------------------------------------------------------------------
|
||||
* When in-app location fails (denied permission, or Desktop Telegram
|
||||
* which has NO geolocation at all — Telegram-Mini-Apps issue #77), the
|
||||
* bot sends a ReplyKeyboardMarkup with request_location: true. The
|
||||
* user taps it, Telegram's native location picker opens, and the bot
|
||||
* receives the shared pin as a normal Message with a `location` field.
|
||||
*
|
||||
* That pin is written to TTL-only KV (kv/location-pin.ts) keyed by
|
||||
* chat_id. The open Mini App polls GET /api/location (webhook.ts) to
|
||||
* pick it up — Telegram has NO push channel from chat to WebView, so
|
||||
* the relay is server-mediated by necessity.
|
||||
*
|
||||
* D-02 structural note: this handler is the ONE deliberate exception
|
||||
* to the Phase-2 "plain text only" command-surface rule. The GEO-02
|
||||
* keyboard is required by the platform; it lives in handlers/ and is
|
||||
* NOT part of commandSources in commands.test.mjs's CS4 gate (which
|
||||
* scans only bot.ts + commands/*). See the CS4 decision recorded below.
|
||||
*/
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { pickLocale, strings } from "../../i18n/strings.ts";
|
||||
import type { Locale } from "../../i18n/strings.ts";
|
||||
import { writePin, type LocationPinKv } from "../../kv/location-pin.ts";
|
||||
|
||||
/** Send the awaiting-pin prompt + location keyboard (GEO-02). */
|
||||
export async function requestLocationFallback(bot: Bot, chatId: number, locale: Locale): Promise<void> {
|
||||
await bot.chat.sendMessage(chatId, strings[locale].awaitingPin, {
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a user-shared pin to TTL-only KV (GEO-03, PRIV-02). */
|
||||
export async function recordSharedPin(kv: LocationPinKv, chatId: number, loc: { latitude: number; longitude: number; horizontal_accuracy?: number }): Promise<void> {
|
||||
await writePin(kv, chatId, {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
horizontal_accuracy: loc.horizontal_accuracy ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Register the GEO-02 message handler. Filters on ctx.message?.location. */
|
||||
export function registerLocationFallback(bot: Bot, kv: LocationPinKv): void {
|
||||
bot.on("message", async (ctx: Context) => {
|
||||
const loc = ctx.message?.location;
|
||||
if (!loc) return;
|
||||
const chatId = ctx.chat?.id;
|
||||
if (chatId === undefined) return;
|
||||
await recordSharedPin(kv, chatId, loc);
|
||||
await ctx.reply("Got it — your starting point is set.", { link_preview_options: { is_disabled: true } });
|
||||
});
|
||||
}
|
||||
```
|
||||
**CS4 decision (explicit, per PATTERNS §bot-strings note):** the new handler lives in `bot/src/bot/handlers/`, which is NOT in `commandSources` (that array is `["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"]`). CS4 therefore keeps passing untouched — do NOT add handlers/ to that array, and do NOT delete the gate. Record the rationale in the handler's header comment (done above).
|
||||
|
||||
**Step C — `bot/src/bot/bot.ts`.** Update the import block and `createBot` to thread KV through. Use this exact shape (the optional `opts.baseFetch` seam exists so `bot/test/location-pin.test.mjs` LP12/LP13 can observe the outgoing `sendMessage`):
|
||||
```typescript
|
||||
import { registerLocationFallback } from "./handlers/location-fallback.ts";
|
||||
// ...
|
||||
export function createBot(env: Env, opts: { baseFetch?: typeof fetch } = {}): Bot {
|
||||
const bot = new Bot(env.BOT_TOKEN, opts.baseFetch ? { client: { baseFetch: opts.baseFetch } } : {});
|
||||
registerStart(bot); // /start + /mula (D-04)
|
||||
registerHelp(bot); // /help
|
||||
registerPrivacy(bot); // /privacy + /privasi (D-04)
|
||||
registerLocationFallback(bot, env.chat_sessions); // GEO-02 chat-keyboard fallback
|
||||
return bot;
|
||||
}
|
||||
```
|
||||
Update the header comment to note the GEO-02 handler's keyboard is the one structural exception to the D-02 text-only rule, and that `requestLocationFallback` has exactly ONE caller — `handleLocationFallback` in `webhook.ts` (Step D). If a second caller appears later, that is a design change, not a refactor.
|
||||
|
||||
**Step D — `bot/src/webhook.ts`.** Add BOTH route branches and handlers. The second route is the app→bot keyboard trigger (GEO-02) — without it, `requestLocationFallback` in Step B is dead code with no caller, and the ASK VIA CHAT CTA in plan 03-03 and the Desktop leg in plan 03-04 have nothing to POST to.
|
||||
|
||||
Dispatch (insert after the `/api/session` branch, before the 404):
|
||||
```typescript
|
||||
if (url.pathname === "/api/location") {
|
||||
return handleLocation(req, env);
|
||||
}
|
||||
if (url.pathname === "/api/location-fallback") {
|
||||
return handleLocationFallback(req, env);
|
||||
}
|
||||
```
|
||||
|
||||
First handler — the relay (as previously specified):
|
||||
```typescript
|
||||
/** GEO-02 relay: the Mini App polls here with its memory-held Bearer token
|
||||
* to pick up a pin the user shared in chat. V4 Access Control (RESEARCH
|
||||
* §Security Domain): the bearer token is resolved via getClaimByToken; a
|
||||
* missing/expired token is a 401. The KV key is derived from the CLAIM's
|
||||
* chat_id — NEVER from a client-supplied chat id, so one user's token
|
||||
* can never read another user's pin. */
|
||||
async function handleLocation(req: Request, env: Env): Promise<Response> {
|
||||
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||||
if (!match || !match[1]) {
|
||||
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const token = match[1];
|
||||
const claim = await getClaimByToken(env.chat_sessions, token);
|
||||
if (claim === null) {
|
||||
console.log("[location] rejected: unauthorized"); // category ONLY (D-14)
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const pin = await getPin(env.chat_sessions, claim.chat_id);
|
||||
if (pin === null) {
|
||||
return new Response(JSON.stringify({ pin: null }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ pin }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Second handler — the keyboard trigger:
|
||||
```typescript
|
||||
/** GEO-02 trigger: the Mini App's denied card taps ASK VIA CHAT and POSTs
|
||||
* here; the Worker asks the BOT to send the reply keyboard in the user's
|
||||
* own chat. Same V4 access control as handleLocation — the target chat_id
|
||||
* comes from the resolved CLAIM, never from the request body, so one
|
||||
* user's token can never push a keyboard into another user's chat.
|
||||
*
|
||||
* The locale comes from `claim.language_code`, which mintSession already
|
||||
* recorded from the validated initData — the Worker does NOT re-derive it
|
||||
* from client input, and no language is accepted from the request body. */
|
||||
async function handleLocationFallback(req: Request, env: Env): Promise<Response> {
|
||||
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/);
|
||||
const token = match && match[1] ? match[1] : null;
|
||||
const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token);
|
||||
if (claim === null) {
|
||||
console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14)
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code));
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
} catch {
|
||||
// Telegram rejected the send (user blocked the bot, chat not found, rate
|
||||
// limit). Category only — never the chat id, never the error text (D-14).
|
||||
console.log("[location-fallback] failed: telegram-send");
|
||||
return new Response(JSON.stringify({ error: "unavailable" }), {
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Imports to add at the top of `bot/src/webhook.ts`: `getClaimByToken` joins the existing `sessions.ts` import; `getPin` comes from a new `import { getPin } from "./kv/location-pin.ts"`; `requestLocationFallback` and `createBot` come from the bot module (`createBot` from `./bot/bot.ts`, `requestLocationFallback` from `./bot/handlers/location-fallback.ts`); `pickLocale` joins the `i18n/strings.ts` import. **Check for a circular-import hazard:** `bot/src/bot/bot.ts` must not import from `webhook.ts` (it does not — `createBot` is the leaf dependency, so `webhook.ts` depends on it, not the reverse). Verify with `npm run typecheck -w bot`.
|
||||
|
||||
Update the file header comment's route list to document all four routes (`/telegram/webhook`, `/api/session`, `/api/location`, `/api/location-fallback`).
|
||||
|
||||
**Step E — `bot/test/location-pin.test.mjs`** (create). Copy `bot/test/runner.mjs`-style header, import `{ test }` from `./harness.mjs`, `strict as assert` from `node:assert`, and copy the `makeKV()` fake from `bot/test/webhook.test.mjs` verbatim. Cases:
|
||||
|
||||
```javascript
|
||||
test("LP1 writePin writes exactly one TTL-carrying key loc:<chat_id>", async () => {
|
||||
const kv = makeKV();
|
||||
await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
|
||||
assert.equal(kv.ttlLog.length, 1);
|
||||
assert.equal(kv.ttlLog[0].key, "loc:777");
|
||||
assert.strictEqual(kv.ttlLog[0].expirationTtl, 300);
|
||||
assert.ok(!kv.ttlLog[0].key.includes("user"), "pin key is chat-scoped, not user-id scoped");
|
||||
});
|
||||
|
||||
test("LP2 getPin round-trips a valid record and returns null for absent", async () => {
|
||||
const kv = makeKV();
|
||||
assert.equal(await getPin(kv, 777), null);
|
||||
await writePin(kv, 777, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 12 });
|
||||
const pin = await getPin(kv, 777);
|
||||
assert.equal(pin.latitude, 3.1513);
|
||||
assert.equal(pin.longitude, 101.6958);
|
||||
assert.strictEqual(pin.horizontal_accuracy, 12);
|
||||
});
|
||||
|
||||
test("LP3 getPin rejects out-of-range coordinates (null island guard)", async () => {
|
||||
const kv = makeKV();
|
||||
await kv.put("loc:1", JSON.stringify({ latitude: 999, longitude: 101, horizontal_accuracy: null }), { expirationTtl: 300 });
|
||||
assert.equal(await getPin(kv, 1), null, "absurd latitude must be rejected on read");
|
||||
await kv.put("loc:2", JSON.stringify({ latitude: 3, longitude: -999, horizontal_accuracy: null }), { expirationTtl: 300 });
|
||||
assert.equal(await getPin(kv, 2), null, "absurd longitude must be rejected on read");
|
||||
});
|
||||
|
||||
test("LP4 getPin returns null on malformed JSON (defensive read)", async () => {
|
||||
const kv = makeKV();
|
||||
await kv.put("loc:3", "{not json", { expirationTtl: 300 });
|
||||
assert.equal(await getPin(kv, 3), null);
|
||||
});
|
||||
|
||||
test("LP5 GET /api/location rejects a missing bearer token with 401", async () => {
|
||||
const kv = makeKV();
|
||||
const req = new Request("https://host/api/location", { method: "GET" });
|
||||
const res = await handler(req, makeEnv(kv));
|
||||
assert.strictEqual(res.status, 401);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.error, "unauthorized");
|
||||
});
|
||||
|
||||
test("LP6 GET /api/location rejects an unknown token with 401", async () => {
|
||||
const kv = makeKV();
|
||||
const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: "Bearer deadbeef-1234" } });
|
||||
const res = await handler(req, makeEnv(kv));
|
||||
assert.strictEqual(res.status, 401);
|
||||
});
|
||||
|
||||
test("LP7 GET /api/location returns {pin:null} for a valid token with no pin yet", async () => {
|
||||
const kv = makeKV();
|
||||
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
|
||||
const token = await getTokenForChat(kv, 777);
|
||||
const req = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token}` } });
|
||||
const res = await handler(req, makeEnv(kv));
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.strictEqual(body.pin, null);
|
||||
});
|
||||
|
||||
test("LP8 GET /api/location returns the pin for the token's OWN chat only", async () => {
|
||||
const kv = makeKV();
|
||||
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
|
||||
await mintSession(kv, { user_id: 99, chat_id: 888, issued_at: 1700000000, language_code: "en" });
|
||||
const token777 = await getTokenForChat(kv, 777);
|
||||
const token888 = await getTokenForChat(kv, 888);
|
||||
await writePin(kv, 888, { latitude: 3.1513, longitude: 101.6958, horizontal_accuracy: 8 });
|
||||
const reqA = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token777}` } });
|
||||
const bodyA = await (await handler(reqA, makeEnv(kv))).json();
|
||||
assert.strictEqual(bodyA.pin, null, "user 42's token must NOT read user 99's pin (V4 access control)");
|
||||
const reqB = new Request("https://host/api/location", { method: "GET", headers: { authorization: `Bearer ${token888}` } });
|
||||
const bodyB = await (await handler(reqB, makeEnv(kv))).json();
|
||||
assert.strictEqual(bodyB.pin.latitude, 3.1513);
|
||||
});
|
||||
|
||||
test("LP9 GET /api/location with a non-GET method returns 405", async () => {
|
||||
const res = await handler(new Request("https://host/api/location", { method: "POST" }), makeEnv(makeKV()));
|
||||
assert.strictEqual(res.status, 405);
|
||||
});
|
||||
|
||||
test("LP10 POST /api/location-fallback with a missing bearer token returns 401", async () => {
|
||||
const res = await handler(new Request("https://host/api/location-fallback", { method: "POST" }), makeEnv(makeKV()));
|
||||
assert.strictEqual(res.status, 401);
|
||||
assert.strictEqual((await res.json()).error, "unauthorized");
|
||||
});
|
||||
|
||||
test("LP11 POST /api/location-fallback with an unknown token returns 401", async () => {
|
||||
const req = new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: "Bearer deadbeef-1234" } });
|
||||
assert.strictEqual((await handler(req, makeEnv(makeKV()))).status, 401);
|
||||
});
|
||||
|
||||
test("LP12 POST /api/location-fallback sends the location keyboard to the token's OWN chat", async () => {
|
||||
const kv = makeKV();
|
||||
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "en" });
|
||||
const token = await getTokenForChat(kv, 777);
|
||||
const calls = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
calls.push(JSON.parse(String(init.body)));
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } });
|
||||
};
|
||||
try {
|
||||
const res = await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${token}` } }), makeEnv(kv));
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0].chat_id, 777, "keyboard must go to the CLAIM's chat, never a client-supplied id (V4)");
|
||||
assert.strictEqual(calls[0].reply_markup.keyboard[0][0].request_location, true);
|
||||
} finally { globalThis.fetch = realFetch; }
|
||||
});
|
||||
|
||||
test("LP13 POST /api/location-fallback honours claim.language_code", async () => {
|
||||
const kv = makeKV();
|
||||
await mintSession(kv, { user_id: 42, chat_id: 777, issued_at: 1700000000, language_code: "ms" });
|
||||
await mintSession(kv, { user_id: 7, chat_id: 555, issued_at: 1700000000, language_code: "en" });
|
||||
const calls = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_url, init) => { calls.push(JSON.parse(String(init.body))); return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { headers: { "content-type": "application/json" } }); };
|
||||
try {
|
||||
const tokMs = await getTokenForChat(kv, 777);
|
||||
await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokMs}` } }), makeEnv(kv));
|
||||
const tokEn = await getTokenForChat(kv, 555);
|
||||
await handler(new Request("https://host/api/location-fallback", { method: "POST", headers: { authorization: `Bearer ${tokEn}` } }), makeEnv(kv));
|
||||
} finally { globalThis.fetch = realFetch; }
|
||||
assert.strictEqual(calls[0].text, strings.ms.awaitingPin, "ms claim must receive ms copy");
|
||||
assert.strictEqual(calls[1].text, strings.en.awaitingPin, "en claim must receive en copy");
|
||||
});
|
||||
|
||||
test("LP14 POST /api/location-fallback with a non-POST method returns 405", async () => {
|
||||
assert.strictEqual((await handler(new Request("https://host/api/location-fallback", { method: "GET" }), makeEnv(makeKV()))).status, 405);
|
||||
});
|
||||
```
|
||||
Imports needed at the top of the test file:
|
||||
```javascript
|
||||
const { writePin, getPin } = await import("../src/kv/location-pin.ts");
|
||||
const { mintSession, getTokenForChat } = await import("../src/kv/sessions.ts");
|
||||
const { strings } = await import("../src/i18n/strings.ts");
|
||||
const handler = await import("../src/webhook.ts").then((m) => m.default.fetch);
|
||||
```
|
||||
Copy `makeEnv` from `bot/test/webhook.test.mjs` verbatim: `function makeEnv(kv) { return { BOT_TOKEN: TEST_TOKEN, WEBHOOK_SECRET: "wk-test-secret", chat_sessions: kv }; }` with `const TEST_TOKEN = "12345:TESTTOKEN";` at module top.
|
||||
|
||||
LP12/LP13 observe the outgoing Telegram call by stubbing `globalThis.fetch` (grammY's default client calls through it) for the duration of the case, returning `{ ok: true, result: { message_id: 1 } }`; restore it in a `finally`. </action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -20 && npm run typecheck -w bot</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w bot` exits 0 and reports `14 passed` for the `LP` prefix (LP1–LP14)
|
||||
- `npm run typecheck -w bot` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' bot/src/bot/handlers/location-fallback.ts | grep -c 'request_location: true'` returns 1
|
||||
- `grep -q 'registerLocationFallback(bot, env.chat_sessions)' bot/src/bot/bot.ts` exits 0
|
||||
- `grep -q 'url.pathname === "/api/location"' bot/src/webhook.ts` exits 0
|
||||
- `grep -q 'url.pathname === "/api/location-fallback"' bot/src/webhook.ts` exits 0 (the app→bot keyboard trigger exists — `requestLocationFallback` is NOT dead code)
|
||||
- `grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'getClaimByToken(env.chat_sessions' | head -1` returns 2 (both routes resolve chat_id from the CLAIM, never client input)
|
||||
- `grep -v '^ \*\|^/\*\|^$' bot/src/webhook.ts | grep -c 'requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code))' | head -1` returns 1 (locale from the claim, never from the request body)
|
||||
- `grep -v '^ \*\|^/\*\|^$' bot/src/bot/bot.ts | grep -c 'opts.baseFetch' | head -1` returns >= 1 (the test transport seam exists; production calls `createBot(env)` with one arg)
|
||||
- `grep -q 'commandSources = \["bot.ts", "commands/start.ts", "commands/help.ts", "commands/privacy.ts"\]' bot/test/commands.test.mjs` exits 0 (CS4 scope unchanged — handlers/ not scanned)
|
||||
- `grep -c 'awaitingPin' bot/src/i18n/strings.ts` returns 3 (interface + en + ms)
|
||||
</acceptance_criteria>
|
||||
<done>The bot can request a shared pin and record it in TTL-only KV, and BOTH server routes the Mini App needs exist and are proven: `GET /api/location` (claim-scoped relay) and `POST /api/location-fallback` (claim-scoped keyboard trigger with locale from the claim). 14 LP tests cover the TTL contract, range guard, cross-user isolation on both routes, and the en/ms locale split.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Telegram client → Worker webhook | Untrusted update payloads arrive; the secret-token compare is the gate (Phase 2). |
|
||||
| Mini App (browser) → `GET /api/location` | Bearer session token in a header; the KV key is derived from the resolved claim. |
|
||||
| Mini App (browser) → `POST /api/location-fallback` | Bearer session token in a header; the target chat_id and locale are derived from the resolved claim, never from the request body. |
|
||||
| Bot update → KV pin write | A message's `location` payload becomes a stored coordinate; range validation gates it. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-01 | Information Disclosure | `GET /api/location` — another user's pin read | mitigate | Resolve `Authorization: Bearer <token>` through `getClaimByToken(env.chat_sessions, token)`; derive the KV key `loc:<chat_id>` from the **claim**, never from a request parameter. Missing/expired token → 401 `{error:"unauthorized"}`. Pinned by test LP8 (user 42's token cannot read user 99's pin). |
|
||||
| T-03-02 | Information Disclosure | Coordinate persisted beyond TTL | mitigate | Every `.put(` in `location-pin.ts` carries `expirationTtl: PIN_TTL_SECONDS` (300) on the same line; `privacy.test.mjs` PG1 gate re-scans the module automatically after the storageModules edit. No profile/history/trip store exists (PG2). |
|
||||
| T-03-03 | Tampering | Malicious webhook update injecting a fabricated pin | mitigate | Only Telegram-authenticated updates reach grammY — the `X-Telegram-Bot-Api-Secret-Token` constant-time compare runs BEFORE any bot processing (Phase 2, WR-02). The handler filters on `ctx.message?.location` only. |
|
||||
| T-03-04 | Tampering / DoS | Out-of-range coordinates in a shared pin | mitigate | `getPin` rejects `\|latitude\| > 90` or `\|longitude\| > 180` and non-numeric values on read (V5 Input Validation), so a corrupt record can never fly the client camera to null island. Test LP3. |
|
||||
| T-03-05 | Information Disclosure | Category-only logging violated | mitigate | The two 401 paths log the literal string `"[location] rejected: unauthorized"` — never the token, the chat id, or any coordinate. |
|
||||
| T-03-06 | Repudiation | A chat pin served long after it was shared | mitigate | 300s TTL bounds exposure; `clearPin` exists for an explicit sweep and the Mini App deletes its copy from memory as soon as it consumes the pin. |
|
||||
| T-03-26 | Elevation of Privilege | One user's session token pushing a location keyboard into ANOTHER user's chat via `POST /api/location-fallback` | mitigate | The handler resolves the token through `getClaimByToken` and passes `claim.chat_id` to `requestLocationFallback`; the request body is never read, so there is no client-supplied chat id to tamper with. Missing/expired token → 401. Pinned by test LP10/LP11 (401) and LP12 (the send goes to the claim's chat, asserting the `sendMessage` body's `chat_id`). |
|
||||
| T-03-27 | Spoofing | Client-supplied language overriding the claim's locale | mitigate | `pickLocale(claim.language_code)` reads only the server-recorded claim value that `mintSession` captured from the HMAC-validated initData. The handler accepts no `lang`/locale field from the request. Pinned by test LP13 (ms claim → ms copy, en claim → en copy). |
|
||||
| T-03-28 | Denial of Service | Unauthenticated flood of the keyboard trigger | mitigate | The route is POST-only (405 otherwise) and every request that reaches `requestLocationFallback` has already resolved a live, unexpired session claim via `getClaimByToken`, so an anonymous flood cannot reach Telegram's API at all. A Telegram-side failure (blocked bot, chat not found, rate limit) is caught and returned as a category-only 502 — the error text is never surfaced to the client or the log. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w bot` green (all Phase-2 cases plus the 14 new LP cases)
|
||||
- `npm run typecheck -w bot` clean — in particular, no circular import between `webhook.ts` and `bot/bot.ts`
|
||||
- `npm test -w web` green (empty suite passes — later plans add cases)
|
||||
- `npm install` resolves the `web` workspace with no peer conflicts
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- GEO-02 server half complete in BOTH directions: the bot requests a pin via reply keyboard and records it TTL-only, the app can trigger the keyboard via `POST /api/location-fallback`, and it can poll the result via `GET /api/location` — both routes claim-scoped
|
||||
- GEO-03 server half complete: the only coordinate store is TTL-expiring KV, covered by an automated same-line TTL gate
|
||||
- The `web/` workspace exists with the repo-convention hand-rolled test runner, so every later plan has a place to put tests
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,853 @@
|
||||
---
|
||||
phase: 03-mini-app-shell-geolocation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [03-01]
|
||||
files_modified:
|
||||
- web/src/location/types.ts
|
||||
- web/src/location/telegram.ts
|
||||
- web/src/location/webview.ts
|
||||
- web/src/location/chat-pin.ts
|
||||
- web/src/location/chain.ts
|
||||
- web/src/session/client.ts
|
||||
- web/src/session/store.ts
|
||||
- web/src/telegram/webapp.ts
|
||||
- web/src/telegram/theme.ts
|
||||
- web/src/telegram/backbutton.ts
|
||||
- web/src/telegram/safe-area.ts
|
||||
- web/src/mock/telegram-mock.ts
|
||||
- web/test/location-chain.test.mjs
|
||||
- web/test/no-storage.test.mjs
|
||||
- web/test/theme.test.mjs
|
||||
autonomous: true
|
||||
requirements: [GEO-01, GEO-02, GEO-03, PX-02]
|
||||
must_haves:
|
||||
decisions_implemented:
|
||||
- "D-03: session token memory-only (module scope); revalidate on every fresh boot and on 900s TTL stall"
|
||||
- "D-05: gated-streaming cadence — one-shot fixes in Phase 3, no hard-coded continuous loop (pluggable for Phase 6)"
|
||||
- "D-07: coordinates live in memory only; zero persistence across every storage API (grep gate + runtime probe)"
|
||||
- "D-06 / D-04 (chat-keyboard link): the denied card is deliberate — retry + chat 'send location' fallback, never a silent banner"
|
||||
truths:
|
||||
- "A Telegram LocationManager fix, a WebView fix, and a chat-shared pin all normalize to one `Fix` shape"
|
||||
- "A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing"
|
||||
- "A missing `window.Telegram.WebApp` entirely (plain browser) falls through without a TypeError"
|
||||
- "`getLocation()` returning `null` (the denial signal) advances to the next source — never an error state"
|
||||
- "`getLocation()` that never calls back resolves `null` after the wrapper timeout, so boot never wedges in ACQUIRING"
|
||||
- "Two byte-identical successive fixes are detected as STALE and route to the navigator nudge, not to success"
|
||||
- "The chat-pin poll is bounded at 60s, then returns to the denied card — AWAITING PIN can never persist forever"
|
||||
- "The session token lives in module scope only; zero storage API is referenced anywhere in web/src"
|
||||
- "Telegram `colorScheme` (or `header_bg_color` luminance fallback) sets `data-tg-theme`, and `themeChanged` touches nothing else"
|
||||
artifacts:
|
||||
- path: "web/src/location/types.ts"
|
||||
provides: "Fix type + Source enum + LocationState machine types"
|
||||
- path: "web/src/location/telegram.ts"
|
||||
provides: "LocationManager.init/getLocation wrapper with hard timeout + staleness detection"
|
||||
- path: "web/src/location/webview.ts"
|
||||
provides: "navigator.geolocation one-shot read (also the Android freshness nudge)"
|
||||
- path: "web/src/location/chat-pin.ts"
|
||||
provides: "bounded 60s poll of GET /api/location with Bearer token"
|
||||
- path: "web/src/location/chain.ts"
|
||||
provides: "the D-04 three-source state machine with freshness as a separate axis"
|
||||
- path: "web/src/session/store.ts"
|
||||
provides: "module-scope in-memory token holder + TTL staleness check"
|
||||
- path: "web/src/telegram/webapp.ts"
|
||||
provides: "typed window.Telegram.WebApp accessor + isVersionAtLeast gate"
|
||||
- path: "web/src/telegram/theme.ts"
|
||||
provides: "PX-02 colorScheme -> data-tg-theme switch (attribute set is its only statement)"
|
||||
- path: "web/src/mock/telegram-mock.ts"
|
||||
provides: "D-11 fake WebApp + dev-signed initData + sample location streams"
|
||||
- path: "web/test/no-storage.test.mjs"
|
||||
provides: "GEO-03 banned-token grep gate over web/src"
|
||||
key_links:
|
||||
- from: "web/src/location/chain.ts"
|
||||
to: "web/src/location/telegram.ts"
|
||||
via: "requestTelegramFix → stale check → nudgeAndRetryTelegram"
|
||||
pattern: "looksStale|nudgeAndRetryTelegram"
|
||||
- from: "web/src/location/chat-pin.ts"
|
||||
to: "/api/location"
|
||||
via: "fetch with Authorization: Bearer <module-scope token>"
|
||||
pattern: "Authorization.*Bearer"
|
||||
- from: "web/src/location/chat-pin.ts"
|
||||
to: "/api/location-fallback"
|
||||
via: "requestChatFallback POSTs with the same Bearer token — the pathname is a contract with plan 03-01's handleLocationFallback, not a guess"
|
||||
pattern: "api/location-fallback"
|
||||
- from: "web/src/telegram/theme.ts"
|
||||
to: "document.documentElement"
|
||||
via: "dataset.tgTheme = resolveBranch(wa) — the ONLY statement applyTheme runs"
|
||||
pattern: "dataset\\.tgTheme"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the client-side location acquisition core: three source adapters normalizing to one `Fix`
|
||||
type, the D-04 chain state machine with freshness as a separate axis from availability, the
|
||||
memory-only session client/store, the four Telegram platform wrapper modules, and the D-11
|
||||
Mock-Telegram harness — with the automated gates for GEO-01, GEO-02 (client half), GEO-03, and PX-02.
|
||||
|
||||
Purpose: this is the phase's highest-risk work (RESEARCH Summary findings 1–3: no `getLiveData()`
|
||||
exists, the Android stale-fix inverts the naive chain reading, Desktop has no geolocation at all).
|
||||
Every one of those is proven here against injected fakes, so no real device is needed until the
|
||||
exit gate.
|
||||
|
||||
**Task count note (revision):** this plan carries FOUR tasks rather than the usual 2–3. The split
|
||||
is deliberate — the single alternative was one task owning 8 files (4 wrappers + the Web-Crypto
|
||||
mock + 3 test files), which put the plan exactly on the 15-file scope threshold and concentrated a
|
||||
tallest-risk chunk (the harness, which the capture gate in plan 03-04 depends on) into a single
|
||||
unit of work. Tasks 1–2 are the location core; task 3 is the platform wrappers; task 4 is the
|
||||
harness plus the four gates.
|
||||
|
||||
Output: `web/src/location/{types,telegram,webview,chat-pin,chain}.ts`; `web/src/session/{client,store}.ts`;
|
||||
`web/src/telegram/{webapp,theme,backbutton,safe-area}.ts`; `web/src/mock/telegram-mock.ts`;
|
||||
four test files.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
|
||||
**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 4 overruns
|
||||
its context budget, split it rather than pushing through — this plan touches 15 files across 4 tasks,
|
||||
which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
|
||||
A task that needs more than ~7 files should be decomposed, not absorbed.
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@AGENTS.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
|
||||
@bot/src/security/initdata.ts
|
||||
@bot/src/webhook.ts
|
||||
@bot/src/kv/sessions.ts
|
||||
@bot/test/webhook.test.mjs
|
||||
@theme/src/theme.css
|
||||
@web/tsconfig.json
|
||||
@web/package.json
|
||||
|
||||
<interfaces>
|
||||
The Worker contract this plan consumes (`bot/src/webhook.ts`, built in plan 03-01):
|
||||
|
||||
```
|
||||
POST /api/session
|
||||
request body: the verbatim Telegram.WebApp.initData string (≤ 4096 chars)
|
||||
200 → { "token": "<uuid>" }
|
||||
401 → { "error": "malformed" | "tampered" | "stale" }
|
||||
413 → { "error": "malformed" }
|
||||
|
||||
GET /api/location
|
||||
header: Authorization: Bearer <token>
|
||||
200 → { "pin": null } or { "pin": { latitude, longitude, horizontal_accuracy } }
|
||||
401 → { "error": "unauthorized" }
|
||||
405 → non-GET
|
||||
|
||||
POST /api/location-fallback
|
||||
header: Authorization: Bearer <token> (no request body is read)
|
||||
200 → { "ok": true }
|
||||
401 → { "error": "unauthorized" }
|
||||
405 → non-POST
|
||||
502 → { "error": "unavailable" } (Telegram rejected the keyboard send)
|
||||
```
|
||||
|
||||
Dev-signed initData helper (copy this shape from `bot/test/webhook.test.mjs` into
|
||||
`web/src/mock/telegram-mock.ts`; do NOT reimplement the HMAC from scratch):
|
||||
|
||||
```javascript
|
||||
function dataCheckString(fields) {
|
||||
return fields
|
||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
||||
.slice()
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
function telegramSecretKey(botToken) {
|
||||
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
|
||||
}
|
||||
function buildInitData(fields) {
|
||||
const hash = createHmac("sha256", telegramSecretKey(botToken))
|
||||
.update(dataCheckString(fields), "utf8")
|
||||
.digest("hex");
|
||||
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
||||
return `${wire}&hash=${hash}`;
|
||||
}
|
||||
```
|
||||
|
||||
The Telegram `Fix` source normalization (RESEARCH §Pattern 1 — copy verbatim):
|
||||
- Telegram `LocationData` → `lat: loc.latitude`, `lng: loc.longitude`,
|
||||
`accuracyM: loc.horizontal_accuracy ?? null`, `headingDeg: loc.course ?? null`, `ts: Date.now()`,
|
||||
`source: 'telegram'`
|
||||
- WebView `GeolocationPosition` → `accuracyM: pos.coords.accuracy`, `headingDeg: pos.coords.heading ?? null`
|
||||
- Bot chat pin → `accuracyM: pin.horizontal_accuracy`, `headingDeg: null`, `source: 'chat-pin'`
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: `Fix` type + three source adapters (telegram / webview / chat-pin)</name>
|
||||
<files>web/src/location/types.ts, web/src/location/telegram.ts, web/src/location/webview.ts, web/src/location/chat-pin.ts</files>
|
||||
<read_first>
|
||||
- `bot/src/security/initdata.ts` (the repo's minimal-typing + injectable-options + header-comment style)
|
||||
- `bot/src/webhook.ts` (the `/api/location` contract this plan consumes)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Pattern 1 and §Pattern 3 (the code shapes)
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/location/types.ts`** — the phase's shared contract. Header comment cites D-04/D-05/D-07.
|
||||
```typescript
|
||||
export type FixSource = "telegram" | "webview" | "chat-pin";
|
||||
|
||||
export type Fix = {
|
||||
lat: number;
|
||||
lng: number;
|
||||
accuracyM: number | null;
|
||||
headingDeg: number | null;
|
||||
ts: number;
|
||||
source: FixSource;
|
||||
};
|
||||
|
||||
export type LocationState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "validating" }
|
||||
| { kind: "acquiring"; since: number }
|
||||
| { kind: "awaiting-pin"; since: number }
|
||||
| { kind: "live"; fix: Fix }
|
||||
| { kind: "stale"; fix: Fix }
|
||||
| { kind: "denied"; reason: "no-permission" | "unsupported" | "timeout" };
|
||||
|
||||
export const KL_CENTER: [number, number] = [101.6958, 3.1513];
|
||||
```
|
||||
Exported as a **type-only** module (no runtime storage, no closures over Fix instances at module scope).
|
||||
|
||||
**`web/src/location/telegram.ts`** — copy RESEARCH lines 268-317 as the starting point, then adapt:
|
||||
```typescript
|
||||
import type { Fix } from "./types.ts";
|
||||
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
|
||||
|
||||
/** NULL-SAFE by contract: `getWebApp()` returns `TelegramWebAppLike | null`, so
|
||||
* this must accept null and answer false — Desktop Telegram and the plain
|
||||
* browser both have no WebApp object at all (issue #77), and a TypeError here
|
||||
* would crash the boot before the chain can fall through to the next link. */
|
||||
export function telegramLocationSupported(wa: TelegramWebAppLike | null): boolean {
|
||||
if (!wa) return false;
|
||||
return typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0");
|
||||
}
|
||||
|
||||
export async function requestTelegramFix(
|
||||
wa: TelegramWebAppLike,
|
||||
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
||||
): Promise<Fix | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
const lm = wa.LocationManager;
|
||||
if (!lm) return null;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (data: unknown | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (!data) { resolve(null); return; }
|
||||
const loc = data as { latitude: number; longitude: number; horizontal_accuracy?: number; course?: number };
|
||||
resolve({
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
accuracyM: loc.horizontal_accuracy ?? null,
|
||||
headingDeg: loc.course ?? null,
|
||||
ts: (options.nowMs ?? Date.now)(),
|
||||
source: "telegram",
|
||||
});
|
||||
};
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
if (lm.isInited) lm.getLocation(finish);
|
||||
else lm.init(() => lm.getLocation(finish));
|
||||
});
|
||||
}
|
||||
|
||||
export function looksStale(prev: Fix | null, next: Fix): boolean {
|
||||
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
|
||||
return haversineMeters(prev, next) < 0.01;
|
||||
}
|
||||
|
||||
export function haversineMeters(a: Fix, b: Fix): number {
|
||||
const R = 6371008.8;
|
||||
const toRad = (d: number): number => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const s =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export async function nudgeAndRetryTelegram(
|
||||
wa: TelegramWebAppLike,
|
||||
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
|
||||
lastAccepted: Fix | null,
|
||||
options: { timeoutMs?: number; nowMs?: () => number } = {},
|
||||
): Promise<Fix | null> {
|
||||
const first = await requestTelegramFix(wa, options);
|
||||
if (first === null) return null;
|
||||
if (!looksStale(lastAccepted, first)) return first;
|
||||
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result DISCARDED (issue #56)
|
||||
return requestTelegramFix(wa, options);
|
||||
}
|
||||
```
|
||||
The nudge's return value is deliberately discarded — that is the entire point of the Android
|
||||
workaround (a WebView read refreshes the native cache the NEXT telegram read then returns).
|
||||
|
||||
**`web/src/location/webview.ts`**:
|
||||
```typescript
|
||||
import type { Fix } from "./types.ts";
|
||||
|
||||
export function webviewLocationSupported(): boolean {
|
||||
return typeof navigator !== "undefined" && typeof navigator.geolocation?.getCurrentPosition === "function";
|
||||
}
|
||||
|
||||
export function readWebviewFix(options: { timeoutMs?: number; nowMs?: () => number } = {}): Promise<Fix | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
if (!webviewLocationSupported()) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (fix: Fix | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(fix);
|
||||
};
|
||||
const timer = setTimeout(() => done(null), timeoutMs);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => done({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
accuracyM: pos.coords.accuracy ?? null,
|
||||
headingDeg: pos.coords.heading ?? null,
|
||||
ts: (options.nowMs ?? Date.now)(),
|
||||
source: "webview",
|
||||
}),
|
||||
() => done(null),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 },
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
`maximumAge: 0` is load-bearing: D-05 requires the freshest fix, and a cached position would
|
||||
silently defeat the freshness axis.
|
||||
|
||||
**`web/src/location/chat-pin.ts`**:
|
||||
```typescript
|
||||
import type { Fix } from "./types.ts";
|
||||
|
||||
export const CHAT_PIN_POLL_MS = 2000;
|
||||
export const CHAT_PIN_TIMEOUT_MS = 60000;
|
||||
|
||||
export async function fetchChatPin(token: string): Promise<Fix | null> {
|
||||
const res = await fetch("/api/location", {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body: unknown = await res.json();
|
||||
const pin = (body as { pin?: { latitude?: number; longitude?: number; horizontal_accuracy?: number | null } }).pin;
|
||||
if (!pin || typeof pin.latitude !== "number" || typeof pin.longitude !== "number") return null;
|
||||
return {
|
||||
lat: pin.latitude,
|
||||
lng: pin.longitude,
|
||||
accuracyM: pin.horizontal_accuracy ?? null,
|
||||
headingDeg: null,
|
||||
ts: Date.now(),
|
||||
source: "chat-pin",
|
||||
};
|
||||
}
|
||||
|
||||
export async function pollChatPin(
|
||||
token: string,
|
||||
options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
|
||||
): Promise<Fix | null> {
|
||||
const timeoutMs = options.timeoutMs ?? CHAT_PIN_TIMEOUT_MS;
|
||||
const pollMs = options.pollMs ?? CHAT_PIN_POLL_MS;
|
||||
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const fix = await fetchChatPin(token);
|
||||
if (fix !== null) return fix;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requestChatFallback(token: string): Promise<void> {
|
||||
// POST /api/location-fallback — implemented in plan 03-01 task 3 Step D as
|
||||
// handleLocationFallback. The pathname is a CONTRACT with 03-01, not a
|
||||
// guess; if 03-01 ever renames the route, both plans change together.
|
||||
const res = await fetch("/api/location-fallback", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("location-fallback-request-failed");
|
||||
}
|
||||
```
|
||||
The route is gated identically to the poll: a 401 means the session expired and the caller must revalidate (D-03), not retry blindly. `requestChatFallback` throws so `main.ts`'s ASK VIA CHAT handler can fall back to re-running `createSession` rather than leaving the user on a card whose button appears to do nothing.
|
||||
|
||||
Note in this file's header comment that the bounded poll (60s) is what keeps `AWAITING PIN` from wedging forever (RESEARCH §Pattern 3). </action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run typecheck -w web` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'setTimeout' | head -1` — at least 1 occurrence (the hard timeout that prevents Pitfall 3)
|
||||
- `grep -q 'maximumAge: 0' web/src/location/webview.ts` exits 0
|
||||
- `grep -q 'CHAT_PIN_TIMEOUT_MS = 60000' web/src/location/chat-pin.ts` exits 0
|
||||
- `grep -q 'source: "chat-pin"' web/src/location/chat-pin.ts` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'await readWebviewFix' | head -1` returns 1 (nudge result discarded — not returned)
|
||||
- `grep -q 'haversineMeters(prev, next) < 0.01' web/src/location/telegram.ts` exits 0
|
||||
- `grep -q 'export function telegramLocationSupported(wa: TelegramWebAppLike | null)' web/src/location/telegram.ts` exits 0 AND `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'if (!wa) return false;' | head -1` returns 1 — a null WebApp (plain browser) must not TypeError
|
||||
</acceptance_criteria>
|
||||
<done>Three source adapters exist, each returning the same `Fix` shape, each with a hard timeout, the telegram adapter is null-safe on a missing WebApp, and it carries the staleness detector plus the Android nudge.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: `chain.ts` state machine + `session/` memory-only store + `/api/session` client</name>
|
||||
<files>web/src/location/chain.ts, web/src/session/store.ts, web/src/session/client.ts</files>
|
||||
<read_first>
|
||||
- `web/src/location/types.ts`, `web/src/location/telegram.ts`, `web/src/location/webview.ts`, `web/src/location/chat-pin.ts` (your own task-1 output — match their exported signatures exactly)
|
||||
- `bot/src/webhook.ts` (the `/api/session` request/response contract)
|
||||
- `bot/src/kv/sessions.ts` (the 900s TTL constant you must mirror by name, not as a bare literal)
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/location/chain.ts`** — the D-04 state machine. Freshness is a separate axis from availability.
|
||||
|
||||
```typescript
|
||||
import type { Fix, LocationState } from "./types.ts";
|
||||
import { nudgeAndRetryTelegram, telegramLocationSupported } from "./telegram.ts";
|
||||
import { readWebviewFix, webviewLocationSupported } from "./webview.ts";
|
||||
import { pollChatPin } from "./chat-pin.ts";
|
||||
|
||||
export interface ChainDeps {
|
||||
wa: TelegramWebAppLike | null; // null off-Telegram (plain browser) — telegramLocationSupported handles it
|
||||
getToken: () => string | null;
|
||||
readWebview?: (o?: { timeoutMs?: number }) => Promise<Fix | null>;
|
||||
pollPin?: (token: string, o?: { timeoutMs?: number }) => Promise<Fix | null>;
|
||||
nowMs?: () => number;
|
||||
telegramTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function acquireFix(deps: ChainDeps, lastAccepted: Fix | null): Promise<{ fix: Fix | null; state: LocationState }> {
|
||||
const now = deps.nowMs ?? Date.now;
|
||||
const readWebview = deps.readWebview ?? readWebviewFix;
|
||||
const pollPin = deps.pollPin ?? pollChatPin;
|
||||
|
||||
// LINK 1 — Telegram-native (GEO-01 primary). Version-gated FIRST, and
|
||||
// staleness is routed to the nudge, never to success (RESEARCH Pitfall 2).
|
||||
if (telegramLocationSupported(deps.wa)) {
|
||||
const fix = await nudgeAndRetryTelegram(deps.wa, readWebview, lastAccepted, {
|
||||
timeoutMs: deps.telegramTimeoutMs ?? 8000,
|
||||
nowMs: now,
|
||||
});
|
||||
if (fix !== null) return { fix, state: { kind: "live", fix } };
|
||||
}
|
||||
|
||||
// LINK 2 — WebView navigator.geolocation.
|
||||
if (webviewLocationSupported()) {
|
||||
const fix = await readWebview({ timeoutMs: 8000 });
|
||||
if (fix !== null) return { fix, state: { kind: "live", fix } };
|
||||
}
|
||||
|
||||
// LINK 3 — chat-keyboard pin relay (GEO-02). The Desktop Telegram path:
|
||||
// Desktop has NO geolocation at all (issue #77), so this is not a
|
||||
// degraded backup there — it is the ONLY origin path.
|
||||
const token = deps.getToken();
|
||||
if (token !== null) {
|
||||
const fix = await pollPin(token, { timeoutMs: 60000 });
|
||||
if (fix !== null) return { fix, state: { kind: "live", fix } };
|
||||
return { fix: null, state: { kind: "denied", reason: "timeout" } };
|
||||
}
|
||||
return { fix: null, state: { kind: "denied", reason: "no-permission" } };
|
||||
}
|
||||
|
||||
export function isFixStale(fix: Fix, nowMs: number): boolean {
|
||||
return nowMs - fix.ts > 45000;
|
||||
}
|
||||
|
||||
export function fixAgeSeconds(fix: Fix, nowMs: number): number {
|
||||
return Math.max(0, Math.round((nowMs - fix.ts) / 1000));
|
||||
}
|
||||
```
|
||||
The 45000ms threshold is the UI-SPEC's 45s signal-lost trigger. `fixAgeSeconds` feeds the
|
||||
`FIX Ns AGO` strip telemetry — the honesty surface for Pitfall 2. Header comment must record
|
||||
that Phase 6 chooses between bounded `getLocation()` polling and `watchPosition()`; Phase 3
|
||||
exposes a `Fix`-shaped abstraction with a **pluggable cadence** and deliberately hard-codes
|
||||
no continuous-mode interval (RESEARCH Open Question 2).
|
||||
|
||||
**`web/src/session/store.ts`** — module-scope memory only. Header comment states what is and isn't stored, cites D-03/D-07, and names the gate (`no-storage.test.mjs`).
|
||||
```typescript
|
||||
export const SESSION_TTL_SECONDS = 900;
|
||||
|
||||
let token: string | null = null;
|
||||
let issuedAtSeconds: number | null = null;
|
||||
|
||||
export function setSession(nextToken: string, issuedAtSec: number = Math.floor(Date.now() / 1000)): void {
|
||||
token = nextToken;
|
||||
issuedAtSeconds = issuedAtSec;
|
||||
}
|
||||
|
||||
export function getSession(): string | null {
|
||||
return token;
|
||||
}
|
||||
|
||||
export function isSessionStale(nowSeconds: number = Math.floor(Date.now() / 1000)): boolean {
|
||||
if (token === null || issuedAtSeconds === null) return true;
|
||||
return nowSeconds - issuedAtSeconds >= SESSION_TTL_SECONDS;
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
token = null;
|
||||
issuedAtSeconds = null;
|
||||
}
|
||||
```
|
||||
There is NO persistence call anywhere in this file — no localStorage, no sessionStorage, no
|
||||
cookie, no IndexedDB. That absence is the D-07 invariant the grep gate pins.
|
||||
|
||||
**`web/src/session/client.ts`**:
|
||||
```typescript
|
||||
import { setSession } from "./store.ts";
|
||||
|
||||
export type SessionResult =
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "malformed" | "tampered" | "stale" | "network" };
|
||||
|
||||
export async function createSession(initData: string): Promise<SessionResult> {
|
||||
try {
|
||||
const res = await fetch("/api/session", { method: "POST", body: initData });
|
||||
if (res.status === 401) {
|
||||
const body: unknown = await res.json().catch(() => ({ error: "malformed" }));
|
||||
const reason = (body as { error?: string }).error;
|
||||
return { ok: false, reason: reason === "tampered" || reason === "stale" ? reason : "malformed" };
|
||||
}
|
||||
if (!res.ok) return { ok: false, reason: "malformed" };
|
||||
const body: unknown = await res.json();
|
||||
const t = (body as { token?: unknown }).token;
|
||||
if (typeof t !== "string" || t.length === 0) return { ok: false, reason: "malformed" };
|
||||
setSession(t);
|
||||
return { ok: true, token: t };
|
||||
} catch {
|
||||
return { ok: false, reason: "network" };
|
||||
}
|
||||
}
|
||||
```
|
||||
The request body is the **verbatim** `initData` string — never `initDataUnsafe` (RESEARCH §Security V2). No `credentials` option, no header carrying initData.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run typecheck -w web` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/session/store.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
|
||||
- `grep -q 'export const SESSION_TTL_SECONDS = 900;' web/src/session/store.ts` exits 0 (named constant, not a bare 900 literal at a call site)
|
||||
- `grep -q 'initDataUnsafe' web/src/session/client.ts` must FAIL (verbatim initData only)
|
||||
- `grep -q 'nowMs - fix.ts > 45000' web/src/location/chain.ts` exits 0 (the 45s signal-lost threshold)
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'setInterval|watchPosition' | head -1` returns 0 (D-05 cadence stays pluggable — no hard-coded continuous loop in Phase 3)
|
||||
</acceptance_criteria>
|
||||
<done>The chain tries telegram → webview → chat-pin in D-04 order, routes staleness to the nudge, and the session token lives only in a module-scope variable.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: Telegram platform wrappers (`webapp.ts`, `theme.ts`, `backbutton.ts`, `safe-area.ts`)</name>
|
||||
<files>web/src/telegram/webapp.ts, web/src/telegram/theme.ts, web/src/telegram/backbutton.ts, web/src/telegram/safe-area.ts</files>
|
||||
<read_first>
|
||||
- `bot/src/security/initdata.ts` (minimal hand-rolled Telegram typing precedent — lines 29-51, 62-77)
|
||||
- `web/src/location/telegram.ts` (the `TelegramWebAppLike | null` import you must honour — task 1 already depends on this interface)
|
||||
- `theme/src/theme.css` lines 100-109 (`--shell-bg` / `--shell-on-surface` — the PX-02 switch target)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/telegram/webapp.ts`** — narrow typed accessor. Do NOT pull in the whole Telegram surface at every call site; wrap it in one file. Note (RESEARCH §Standard Stack): `@types/telegram-web-app` is deliberately NOT a dependency — plan 03-01 dropped it because this narrow interface is what every test fake and harness mock must satisfy, and the official 500-member `WebApp` type would force each fake to stub ~50 members. This hand-rolled `TelegramWebAppLike` IS the contract.
|
||||
```typescript
|
||||
export interface TelegramWebAppLike {
|
||||
initData: string;
|
||||
colorScheme?: "light" | "dark";
|
||||
themeParams?: { header_bg_color?: string };
|
||||
version?: string;
|
||||
platform?: string;
|
||||
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
isVersionAtLeast(version: string): boolean;
|
||||
close(): void;
|
||||
onEvent?(event: string, cb: () => void): void;
|
||||
offEvent?(event: string, cb: () => void): void;
|
||||
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
|
||||
LocationManager?: {
|
||||
isInited: boolean;
|
||||
isLocationAvailable?: boolean;
|
||||
isAccessRequested?: boolean;
|
||||
isAccessGranted?: boolean;
|
||||
init(cb: () => void): void;
|
||||
getLocation(cb: (data: unknown | null) => void): void;
|
||||
openSettings?(): void;
|
||||
};
|
||||
}
|
||||
|
||||
export function getWebApp(): TelegramWebAppLike | null {
|
||||
const w = window as unknown as { Telegram?: { WebApp?: TelegramWebAppLike } };
|
||||
return w.Telegram?.WebApp ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
**`web/src/telegram/theme.ts`** — copy RESEARCH §Code Examples "Theme slot switching" verbatim, including the luminance fallback:
|
||||
```typescript
|
||||
import type { TelegramWebAppLike } from "./webapp.ts";
|
||||
|
||||
type ThemeBranch = "light" | "dark";
|
||||
|
||||
export function resolveBranch(wa: TelegramWebAppLike): ThemeBranch {
|
||||
if (wa.colorScheme === "dark" || wa.colorScheme === "light") return wa.colorScheme;
|
||||
const hex = wa.themeParams?.header_bg_color;
|
||||
if (typeof hex === "string" && /^#[0-9a-f]{6}$/i.test(hex)) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return lum >= 0.5 ? "light" : "dark";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
export function applyTheme(wa: TelegramWebAppLike): void {
|
||||
// The ONLY statement a theme change is allowed to touch. MapLibre is
|
||||
// deliberately absent from this function (D-10 / RESEARCH Pitfall 7).
|
||||
document.documentElement.dataset.tgTheme = resolveBranch(wa);
|
||||
}
|
||||
|
||||
export function watchTheme(wa: TelegramWebAppLike): void {
|
||||
applyTheme(wa);
|
||||
wa.onEvent?.("themeChanged", () => applyTheme(wa));
|
||||
}
|
||||
```
|
||||
`resolveBranch` is exported (the luminance test needs it directly in `theme.test.mjs`).
|
||||
|
||||
**`web/src/telegram/backbutton.ts`** (D-10 / UI-SPEC §BackButton — total function, no dead-ends):
|
||||
```typescript
|
||||
import type { TelegramWebAppLike } from "./webapp.ts";
|
||||
|
||||
export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean): void {
|
||||
wa.BackButton?.hide(); // Phase 3 has ONE shell state (the map) — nothing to pop out of
|
||||
wa.BackButton?.onClick(() => {
|
||||
if (hasOverlay()) return;
|
||||
wa.close(); // Telegram semantics: back at the root closes the app
|
||||
});
|
||||
}
|
||||
```
|
||||
An empty / no-op handler is forbidden — the copy-gate test asserts this file contains `close()`.
|
||||
|
||||
**`web/src/telegram/safe-area.ts`** (version-gated, `env()` union with `WebApp.safeAreaInset`, per-edge max):
|
||||
```typescript
|
||||
import type { TelegramWebAppLike } from "./webapp.ts";
|
||||
|
||||
export interface SafeInsets { top: number; bottom: number; left: number; right: number }
|
||||
|
||||
function envInsets(): SafeInsets {
|
||||
const read = (v: string): number => {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText = `position:absolute;visibility:hidden;padding-${v}:env(safe-area-inset-${v},0px)`;
|
||||
document.body.appendChild(probe);
|
||||
const px = parseFloat(getComputedStyle(probe).getPropertyValue(`padding-${v}`)) || 0;
|
||||
probe.remove();
|
||||
return px;
|
||||
};
|
||||
return { top: read("top"), bottom: read("bottom"), left: read("left"), right: read("right") };
|
||||
}
|
||||
|
||||
export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets {
|
||||
const base = envInsets();
|
||||
const tg = wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0") ? wa.safeAreaInset : null;
|
||||
if (!tg) return base; // old-client fallback = pure env()
|
||||
return {
|
||||
top: Math.max(base.top, tg.top ?? 0),
|
||||
bottom: Math.max(base.bottom, tg.bottom ?? 0),
|
||||
left: Math.max(base.left, tg.left ?? 0),
|
||||
right: Math.max(base.right, tg.right ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySafeArea(insets: SafeInsets): void {
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty("--safe-top", `${insets.top}px`);
|
||||
root.setProperty("--safe-bottom", `${insets.bottom}px`);
|
||||
root.setProperty("--safe-left", `${insets.left}px`);
|
||||
root.setProperty("--safe-right", `${insets.right}px`);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20 && node -e "const fs=require('fs'); for (const f of ['webapp','theme','backbutton','safe-area']) { if (!fs.existsSync('web/src/telegram/'+f+'.ts')) throw new Error('missing '+f); } console.log('4 wrappers present')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run typecheck -w web` exits 0
|
||||
- `grep -q 'export interface TelegramWebAppLike' web/src/telegram/webapp.ts` exits 0 AND `grep -q 'return w.Telegram?.WebApp ?? null;' web/src/telegram/webapp.ts` exits 0 (getWebApp can return null — that is the contract the chain must handle)
|
||||
- `grep -q 'close()' web/src/telegram/backbutton.ts` exits 0 (total function, no dead-ends)
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'setStyle' | head -1` returns 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'dataset.tgTheme' | head -1` returns 1 (the single statement a theme change may touch)
|
||||
- `grep -q 'isVersionAtLeast("8.0")' web/src/telegram/safe-area.ts` exits 0
|
||||
- `grep -q 'WebApp.safeAreaInset\|safeAreaInset' web/src/telegram/safe-area.ts` exits 0
|
||||
- `grep -rc 'node_modules' web/src/telegram/*.ts 2>/dev/null; grep -q '@types/telegram-web-app' web/package.json; test $? -ne 0` — the dropped package must NOT be reintroduced
|
||||
</acceptance_criteria>
|
||||
<done>Four narrow Telegram platform wrappers exist behind one hand-rolled interface; the theme switch's only statement is the data-attribute set, and getWebApp can honestly return null.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 4: Mock-Telegram harness + the four RED gates (GEO-01, GEO-02 client, GEO-03, PX-02)</name>
|
||||
<files>web/src/mock/telegram-mock.ts, web/test/location-chain.test.mjs, web/test/no-storage.test.mjs, web/test/theme.test.mjs</files>
|
||||
<read_first>
|
||||
- `web/src/telegram/webapp.ts` (task 3 output — `installMockWebApp` must return a `TelegramWebAppLike`)
|
||||
- `web/src/location/types.ts` + `web/src/location/chain.ts` (task 1–2 output — the tests import these)
|
||||
- `web/src/telegram/theme.ts` (task 3 output — `theme.test.mjs` imports `resolveBranch`/`applyTheme`/`watchTheme`)
|
||||
- `bot/test/webhook.test.mjs` (the signing helper + fake-KV patterns to mirror)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/mock/telegram-mock.ts`** — the keystone (D-11). Dev/build-tagged only.
|
||||
```typescript
|
||||
/*
|
||||
* telegram-mock.ts — the D-11 Mock-Telegram harness.
|
||||
*
|
||||
* Injects a fake `window.Telegram.WebApp` into the SAME window the app
|
||||
* runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects
|
||||
* Mini App method calls from any origin other than the registered Mini
|
||||
* App domain, so an iframe harness would silently fail (RESEARCH Pitfall 6).
|
||||
*
|
||||
* Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts
|
||||
* (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it
|
||||
* unmodified against `bot/.dev.vars`. Import the token from the env; never
|
||||
* hardcode a real bot token, never log it.
|
||||
*
|
||||
* This module ships nothing to production: `no-storage.test.mjs` and the
|
||||
* build-artifact gate both assert it is excluded from `web/dist`.
|
||||
*/
|
||||
import { createHmac } from "node:crypto"; // dev/build only — Vite tree-shakes from the browser bundle path
|
||||
```
|
||||
**Important build constraint:** `node:crypto` cannot be bundled for the browser. Implement `buildInitData` with the **Web Crypto** API (`crypto.subtle.importKey` + `sign` with HMAC-SHA-256, plus `TextEncoder`), which is async; expose `export async function makeDevInitData(botToken, user)` and let the runner page await it. Do NOT import `node:crypto` in this file. The test suite (`web/test/*.test.mjs`) uses its own `node:crypto` helper copied from `bot/test/webhook.test.mjs` — that runs in Node, not the browser.
|
||||
|
||||
Fake WebApp shape:
|
||||
```typescript
|
||||
export interface MockOptions {
|
||||
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
|
||||
version?: string;
|
||||
colorScheme?: "light" | "dark";
|
||||
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
|
||||
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
|
||||
grantLocation?: boolean;
|
||||
}
|
||||
|
||||
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { /* returns the fake and assigns (window as any).Telegram = { WebApp } */ }
|
||||
export function uninstallMockWebApp(): void { /* delete window.Telegram */ }
|
||||
export function emitThemeChanged(): void { /* fire the registered themeChanged listener */ }
|
||||
```
|
||||
The fake's `LocationManager.getLocation(cb)` invokes `cb(options.telegramFix)` on a `setTimeout(…, 0)` when `grantLocation` is true, and `cb(null)` otherwise. `isVersionAtLeast(v)` returns true when `options.version` (default `"8.0"`) is >= v by dotted-numeric compare. `platform` defaults to `"android"`.
|
||||
|
||||
**`web/test/location-chain.test.mjs`** — GEO-01 + GEO-02 client half, against injected fakes (never a real MapLibre instance — ESM caveat). Cases, each named with its requirement:
|
||||
```
|
||||
GEO1 telegram init+getLocation yields a Fix with source 'telegram'
|
||||
GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing
|
||||
GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null
|
||||
falls through to the webview link (plain-browser leg, no window.Telegram at all)
|
||||
GEO1 getLocation returning null (denied) advances to the webview source, not to an error
|
||||
GEO1 getLocation that never calls back resolves null after the wrapper timeout
|
||||
GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge
|
||||
GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)
|
||||
GEO2 pollChatPin returns the pin when the endpoint yields one
|
||||
GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)
|
||||
GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with identical copy keys
|
||||
```
|
||||
Inject the fake by passing a plain object literal matching `TelegramWebAppLike` directly — do NOT construct a DOM. For `webviewLocationSupported`, the tests must set `globalThis.navigator = { geolocation: { getCurrentPosition(...) {} } }` (or delete it) per case in a `beforeEach`-style helper, and restore afterwards.
|
||||
|
||||
**`web/test/no-storage.test.mjs`** — GEO-03 grep gate, inverted form of `bot/test/privacy.test.mjs`:
|
||||
```javascript
|
||||
const BANNED = [
|
||||
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
|
||||
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
|
||||
];
|
||||
```
|
||||
Scan every file under `web/src/` matching `**/*.{ts,html}` and assert no BANNED token appears. **Important:** the header comment of `web/src/mock/telegram-mock.ts` mentions those names in prose — the gate must scan for *code* occurrences only, so strip block comments and line comments before scanning (use a small regex pass removing `/* … */` and `// …`), and skip the mock module's *documentation*. Concretely: strip comments from the source text, THEN assert none of the BANNED tokens appear. Also add a case asserting `web/src/session/store.ts` contains no assignment to any storage object. Copy the path-scaffolding pattern from `bot/test/privacy.test.mjs` lines 23-27.
|
||||
|
||||
**`web/test/theme.test.mjs`** — PX-02:
|
||||
```
|
||||
PX02 colorScheme 'dark' sets data-tg-theme="dark"; 'light' sets "light"
|
||||
PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)
|
||||
PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set
|
||||
PX02 watchTheme re-applies on the themeChanged event
|
||||
```
|
||||
For the third case, grep the shipped `web/src/telegram/theme.ts` source for `setStyle` and assert it does NOT appear (source-introspection style, `bot/test/commands.test.mjs` CS4 precedent) — that makes the Pitfall-7 invariant greppable.
|
||||
|
||||
**DOM stub — mandatory.** Cases 1, 2 and 4 call `applyTheme`/`watchTheme`, which touch `document.documentElement.dataset`. The Node runner has no DOM. Install a minimal document stub in a `beforeEach`-style helper and remove it in the matching teardown (same discipline as the `navigator` stub in `location-chain.test.mjs`):
|
||||
```javascript
|
||||
const realDocument = globalThis.document;
|
||||
function installDocumentStub() {
|
||||
const dataset = {};
|
||||
globalThis.document = { documentElement: { dataset, style: { setProperty() {} } } };
|
||||
return dataset;
|
||||
}
|
||||
// per case:
|
||||
const ds = installDocumentStub();
|
||||
try { applyTheme(wa); assert.strictEqual(ds.tgTheme, "dark"); }
|
||||
finally { globalThis.document = realDocument; }
|
||||
```
|
||||
The stub must expose `documentElement.dataset` as a plain writable object (that IS the assertion target) and `documentElement.style.setProperty` as a no-op, because `safe-area.ts`'s `applySafeArea` may be imported transitively. Save and restore `globalThis.document` in every case — a leaked stub would silently make a later test pass or fail for the wrong reason.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` where N >= 17 (10 GEO + 2 GEO-03 + 4 PX-02 + at least 1 storage case)
|
||||
- `npm run typecheck -w web` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/mock/telegram-mock.ts | grep -c 'node:crypto' | head -1` returns 0 (Web Crypto only — node:crypto would break the browser bundle)
|
||||
- `grep -q 'import { createHmac } from "node:crypto"' web/src/mock/telegram-mock.ts; test $? -ne 0` — must FAIL (no node import in a browser-bundled module)
|
||||
- `grep -q 'globalThis.document' web/test/theme.test.mjs` exits 0 (the DOM stub is installed and restored)
|
||||
- `grep -q 'globalThis.navigator' web/test/location-chain.test.mjs` exits 0 (the navigator stub follows the same discipline)
|
||||
- `node -e "const s=require('fs').readFileSync('web/src/location/chain.ts','utf8'); if(s.includes('localStorage')) throw new Error('GEO-03 violation'); console.log('chain clean')"`
|
||||
- `node -e "const s=require('fs').readFileSync('web/src/location/telegram.ts','utf8'); if(!/telegramLocationSupported\(wa: TelegramWebAppLike \| null\)/.test(s)) throw new Error('null-guard missing'); console.log('null-guard OK')"`
|
||||
</acceptance_criteria>
|
||||
<done>The Mock-Telegram harness exists behind a narrow interface and the four requirement gates are green against injected fakes; no real device is needed to exercise the chain, the theme switch, or the storage prohibition.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser → `/api/session` and `/api/location` | Same-origin fetches carrying the memory token; the Worker validates. |
|
||||
| WebView/OS → location APIs | `LocationManager` and `navigator.geolocation` both return device data treated as unverified input. |
|
||||
| Telegram client → page | `themeParams`, `colorScheme`, `safeAreaInset` are read as *signals*, never as consumed values. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-07 | Information Disclosure | Coordinate or token reaching any persistent store (D-03/D-07/GEO-03) | mitigate | `web/src/session/store.ts` holds the token in a module-scope `let`; no persistence call exists in the file. `web/test/no-storage.test.mjs` scans every `web/src/**/*.{ts,html}` file (comments stripped) for `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `DeviceStorage`, `SecureStorage`, `CloudStorage` and fails the build on any hit. |
|
||||
| T-03-08 | Spoofing | Client-side bypass of the session gate | mitigate | `createSession` posts the verbatim `initData` and treats a non-200 or a non-string `token` as failure — no client-side shortcut accepts an unvalidated identity. HMAC validation stays entirely server-side; `web/` contains no crypto. |
|
||||
| T-03-09 | Repudiation | A stale fix displayed as `LIVE` | mitigate | `looksStale()` compares successive telegram fixes (< 0.01 m ⇒ cache never refreshed); `isFixStale(fix, nowMs)` drives the 45s signal-lost state and `fixAgeSeconds()` renders `FIX Ns AGO`. The app never claims freshness it has not measured. |
|
||||
| T-03-10 | Denial of Service | `getLocation()` never invoking its callback | mitigate | `requestTelegramFix` carries an internal `setTimeout(… , 8000)` that resolves `null`; `readWebviewFix` carries `maximumAge: 0` plus its own timeout. Neither wrapper can leave the boot choreography in `ACQUIRING` indefinitely (RESEARCH Pitfall 3). |
|
||||
| T-03-11 | Information Disclosure | Telegram `themeParams` values consumed as design values | accept | `resolveBranch` reads `header_bg_color` only as a 0–1 luminance signal for the light/dark branch; no Telegram hex ever reaches a CSS custom property. Residual risk is nil — worst case is a wrong branch, which the `prefers-color-scheme` default already covers. |
|
||||
| T-03-12 | Elevation of Privilege | Cross-origin `Telegram.WebApp.*` calls (Bot API 10.2, enforced 2026-07-20) | mitigate | `getWebApp()` reads `window.Telegram` from the app's own document; `telegram-mock.ts` injects into the SAME window, never an iframe. No iframe, no CDN shell, no preview subdomain may host the app. |
|
||||
| T-03-29 | Denial of Service | `acquireFix({ wa: null })` throwing a TypeError before the chain can fall through | mitigate | `getWebApp()` is typed `TelegramWebAppLike \| null` and `telegramLocationSupported` accepts null, returning `false` on `if (!wa)`. The plain-browser leg of the device matrix (plan 03-04) has no `window.Telegram` at all, so this guard is load-bearing, not defensive padding. Proven by a test case and by a source grep on the signature. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w web` green with at least 17 cases across GEO-01, GEO-02, GEO-03, PX-02
|
||||
- `npm run typecheck -w web` clean
|
||||
- No storage API token appears anywhere in `web/src` (comment-stripped grep gate)
|
||||
- The `themeChanged` handler's only statement is the data-attribute set (grep-gateable)
|
||||
- `telegramLocationSupported(null)` is false, not a TypeError (proven by test and by a source grep)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- GEO-01: the telegram source, the version gate, the null-WebApp guard, the null-denial branch, the timeout, and the stale-detector all proven against fakes
|
||||
- GEO-02 (client half): bounded chat-pin poll proven to return null rather than wedge, and `requestChatFallback` targets the route 03-01 actually implements (`POST /api/location-fallback`)
|
||||
- GEO-03: mechanical grep gate over the entire client source tree
|
||||
- PX-02: colorScheme + luminance fallback + the map-untouched invariant all proven (with a document stub installed and restored)
|
||||
- Phase 6 can pick poll-vs-watchPosition later without a rewrite (pluggable cadence, no hard-coded interval)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,784 @@
|
||||
---
|
||||
phase: 03-mini-app-shell-geolocation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [03-02]
|
||||
files_modified:
|
||||
- web/src/i18n/strings.ts
|
||||
- web/src/shell.css
|
||||
- web/src/map/map.ts
|
||||
- web/src/map/marker.ts
|
||||
- web/src/map/follow.ts
|
||||
- web/src/ui/strip.ts
|
||||
- web/src/ui/cards.ts
|
||||
- web/src/ui/recenter.ts
|
||||
- web/src/main.ts
|
||||
- web/test/copy-gate.test.mjs
|
||||
- web/test/session.test.mjs
|
||||
autonomous: true
|
||||
requirements: [GEO-01, GEO-02, GEO-03, PX-02]
|
||||
must_haves:
|
||||
decisions_implemented:
|
||||
- "D-06: denied/no-geolocation shows the deliberate Rams denied card with retry + chat fallback (never a blank map or silent banner)"
|
||||
- "D-09: boot choreography renders staged deliberate states (validating → acquiring → live), none blank"
|
||||
- "D-10: Telegram theme flips re-theme shell surfaces only; the map style object stays Rams-constant"
|
||||
truths:
|
||||
- "The basemap mounts and renders tiles BEFORE the location chain starts — boot never shows a blank map"
|
||||
- "Every boot stage (validating → acquiring → live) renders Rams furniture over a live map; no stage is blank"
|
||||
- "All user-facing copy lives in web/src/i18n/strings.ts and matches the UI-SPEC table verbatim in en + ms"
|
||||
- "The map style is never re-set on theme flip — only data-tg-theme on <html> changes"
|
||||
- "The position marker renders a dot, an accuracy circle capped at 100m, and a heading cone hidden when heading is unknown"
|
||||
- "Recenter flyTo is 400ms with 96px camera padding; the control is 40x40 with a >=44px hit target"
|
||||
- "No route line, search furniture, or layer toggle appears anywhere in the boot output (D-08)"
|
||||
artifacts:
|
||||
- path: "web/src/i18n/strings.ts"
|
||||
provides: "the UI-SPEC copy table verbatim, en + ms, plus pickLocale"
|
||||
- path: "web/src/shell.css"
|
||||
provides: "token-only shell layout, dark-slot activation, strip/card/control/marker geometry"
|
||||
- path: "web/src/map/map.ts"
|
||||
provides: "MapLibre v6 bootstrap with setWorkerUrl + GPUInitializationError gate + READY signal"
|
||||
- path: "web/src/map/marker.ts"
|
||||
provides: "position dot + accuracy circle + heading cone as a DOM Marker"
|
||||
- path: "web/src/map/follow.ts"
|
||||
provides: "follow/free toggle with 96px camera padding"
|
||||
- path: "web/src/ui/strip.ts"
|
||||
provides: "status strip: 8px lamp + state word + telemetry"
|
||||
- path: "web/src/ui/cards.ts"
|
||||
provides: "boot / denied / signal-lost cards composed from theme classes"
|
||||
- path: "web/src/main.ts"
|
||||
provides: "boot choreography: mount map then session then chain, staged states"
|
||||
key_links:
|
||||
- from: "web/src/main.ts"
|
||||
to: "web/src/map/map.ts"
|
||||
via: "bootMap() runs FIRST, then createSession(), then acquireFix() — the order is the anti-blank-map invariant"
|
||||
pattern: "bootMap"
|
||||
- from: "web/src/ui/cards.ts"
|
||||
to: "web/src/i18n/strings.ts"
|
||||
via: "every card body/headline imported from strings — zero inline copy"
|
||||
pattern: "strings"
|
||||
- from: "web/src/ui/cards.ts"
|
||||
to: "/api/location-fallback"
|
||||
via: "the ASK VIA CHAT button handler in main.ts calls requestChatFallback(getSession()!) — the route plan 03-01 implements as handleLocationFallback; this is the Desktop Telegram path (issue #77)"
|
||||
pattern: "requestChatFallback"
|
||||
- from: "web/src/shell.css"
|
||||
to: "theme/dist/rams.css"
|
||||
via: "shell.css references only var(--color-*)/var(--spacing-*)/var(--type-*) — never a literal hex"
|
||||
pattern: "var"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Rams shell: the copy module (UI-SPEC table verbatim, en + ms), the token-only stylesheet,
|
||||
the MapLibre v6 bootstrap with its WebGL2 gate, the position marker and follow/recenter camera
|
||||
controls, the status strip and state cards, and the boot choreography that mounts the map first and
|
||||
runs the staged validating → acquiring → live sequence.
|
||||
|
||||
Purpose: this is where the shell becomes visible. It satisfies the phase's design contract
|
||||
(D-08 map-first single screen, D-09 never-blank staged boot, D-10 map-constant-under-theme-flip,
|
||||
PX-02 chrome re-slots) and produces the artifacts the capture harness and the device-matrix gate
|
||||
photograph.
|
||||
|
||||
Output: `web/src/i18n/strings.ts`; `web/src/shell.css`; `web/src/map/{map,marker,follow}.ts`;
|
||||
`web/src/ui/{strip,cards,recenter}.ts`; `web/src/main.ts`; `copy-gate.test.mjs`; `session.test.mjs`.
|
||||
</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-UI-SPEC.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
|
||||
@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
|
||||
@theme/src/components.css
|
||||
@theme/src/tokens.css
|
||||
@theme/src/theme.css
|
||||
@theme/basemap/index.html
|
||||
@stitch_minimalist_route_navigator/navigate_map_overview/code.html
|
||||
@web/src/location/types.ts
|
||||
@web/src/location/chain.ts
|
||||
@web/src/session/client.ts
|
||||
@web/src/session/store.ts
|
||||
@web/src/telegram/theme.ts
|
||||
@web/src/telegram/backbutton.ts
|
||||
@web/src/telegram/safe-area.ts
|
||||
|
||||
<design_tokens>
|
||||
Available CSS custom properties (from `theme/src/tokens.css`, consumed verbatim — never restate a value):
|
||||
|
||||
```
|
||||
--color-surface: #fff8f0 --color-surface-dark: #121212
|
||||
--color-on-surface: #1e1b14 --color-inverse-on-surface: #f7f0e3
|
||||
--color-inverse-surface: #333028 --color-soft-beige: #D9D2C6
|
||||
--color-chassis-subtle: #CCC4B5 --color-matte-black: #1A1A1A
|
||||
--color-pure-white: #FFFFFF --color-signal-orange: #EB8008
|
||||
--color-functional-red: #ED3F1C --color-olive-green: #736B1E
|
||||
--color-on-surface-variant: #564335 --color-surface-dim: #e0d9cd
|
||||
--spacing-space-xs: 0.25rem --spacing-space-sm: 0.5rem --spacing-space-md: 1rem
|
||||
--spacing-space-lg: 1.5rem --spacing-space-xl: 2rem --spacing-margin: 1rem
|
||||
--type-body-md / --type-headline-md / --type-label-sm / --type-label-lg (+ -tracking companions)
|
||||
--shell-bg / --shell-on-surface (Phase 1 D-04 theme slots — the PX-02 switch target)
|
||||
```
|
||||
|
||||
Consumed component classes (from `theme/src/components.css` — compose, do NOT restyle):
|
||||
`.btn-primary`, `.btn-secondary`, `.card`, `.card-header`, `.card-ref`, `.card-content`,
|
||||
`.text-headline-md`, `.text-body-md`, `.text-body-sm`, `.text-label-sm`, `.text-label-lg`.
|
||||
</design_tokens>
|
||||
|
||||
<copy_table>
|
||||
Copy verbatim from `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section
|
||||
"Copywriting Contract". Register labels (`BOOT-01 //`, `GEO //`, `SYS //`) stay English under both locales.
|
||||
</copy_table>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: `i18n/strings.ts` copy module (UI-SPEC table verbatim, en + ms) + copy gate</name>
|
||||
<files>web/src/i18n/strings.ts, web/test/copy-gate.test.mjs</files>
|
||||
<read_first>
|
||||
- `bot/src/i18n/strings.ts` (the exact structural shape: interface, then Locale type, then Record, then pickLocale)
|
||||
- `bot/test/commands.test.mjs` lines 36-41 and 59-68 (the CS1 parity gate and CS3 copy-gate style)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` section "Copywriting Contract" (the copy table — copy it exactly)
|
||||
</read_first>
|
||||
<action>
|
||||
**Create `web/src/i18n/strings.ts`.** Structural copy of `bot/src/i18n/strings.ts`: interface `ShellStrings`, `export type Locale = "en" | "ms"`, `export const strings: Record<Locale, ShellStrings>`, `export function pickLocale`. Reuse `pickLocale`'s ms-prefix rule verbatim:
|
||||
```typescript
|
||||
export function pickLocale(telegramLanguageCode?: string | null): Locale {
|
||||
const code = telegramLanguageCode ?? "";
|
||||
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
|
||||
}
|
||||
```
|
||||
|
||||
Also export `localeFromInitData` in the same file (its full implementation is specified in task 3 step 11 — write it here so task 1 owns the module's complete public surface):
|
||||
```typescript
|
||||
/** Derive the locale from the RAW initData string (RESEARCH §Security V2 —
|
||||
* the signed string, never initDataUnsafe). Telegram's wire format is
|
||||
* user=<url-encoded JSON>&auth_date=...&query_id=...&signature=...&hash=...,
|
||||
* so the first query component is `user`. A malformed or absent field
|
||||
* falls back to "en" — never throws, never blocks boot. */
|
||||
export function localeFromInitData(initData: string): Locale {
|
||||
try {
|
||||
const userField = initData.split("&")[0] ?? "";
|
||||
if (!userField.startsWith("user=")) return "en";
|
||||
const user = JSON.parse(decodeURIComponent(userField.slice(5))) as { language_code?: string };
|
||||
return pickLocale(user.language_code);
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
```
|
||||
`POST /api/session` returns only `{ token }`, so the claim's `language_code` is NOT reachable client-side — parsing `initData` locally is the only honest derivation. Parsing `initData` (not `initDataUnsafe`) does not weaken the security model: the value only chooses a locale, the HMAC validation still happens server-side, and the worst case of a forged locale is Malay copy in an English chat.
|
||||
|
||||
The `ShellStrings` interface (25 keys):
|
||||
```typescript
|
||||
export interface ShellStrings {
|
||||
bootValidatingRegister: string;
|
||||
bootValidatingHeadline: string;
|
||||
bootValidatingBody: string;
|
||||
bootAcquiringRegister: string;
|
||||
bootAcquiringHeadline: string;
|
||||
bootAcquiringBody: string;
|
||||
deniedRegister: string;
|
||||
ctaTryAgain: string;
|
||||
ctaAskViaChat: string;
|
||||
deniedHeadline: string;
|
||||
deniedBody: string;
|
||||
deniedHint: string;
|
||||
lostRegister: string;
|
||||
lostHeadline: string;
|
||||
lostBody: string;
|
||||
lostHint: string;
|
||||
chatFallbackWaiting: string;
|
||||
stripValidating: string;
|
||||
stripAcquiring: string;
|
||||
stripLive: string;
|
||||
stripSignalLost: string;
|
||||
stripLocationOff: string;
|
||||
stripAwaitingPin: string;
|
||||
attribution: string;
|
||||
ariaRecenter: string;
|
||||
}
|
||||
```
|
||||
(That is 25 keys — the copy gate asserts whatever the final count is, identical across locales. `deniedRegister` and `lostRegister` exist because `cards.ts` renders the register texts `GEO // LOCATION` and `SYS // POSITION`; without these keys they would be inline copy in `cards.ts`, violating this plan's own no-inline-copy rule AND the Copy Gate success criterion — and COPY5's banned list would not have caught them.)
|
||||
|
||||
Exact `en` values (copy character-for-character, including em-dashes and arrows):
|
||||
```typescript
|
||||
en: {
|
||||
bootValidatingRegister: "BOOT-01 // SESSION",
|
||||
bootValidatingHeadline: "Validating session",
|
||||
bootValidatingBody: "Confirming your Telegram identity. Takes a few seconds and stores nothing.",
|
||||
bootAcquiringRegister: "BOOT-02 // LOCATION",
|
||||
bootAcquiringHeadline: "Acquiring location",
|
||||
bootAcquiringBody: "Finding where you are. Your position is never written to storage.",
|
||||
deniedRegister: "GEO // LOCATION",
|
||||
ctaTryAgain: "TRY AGAIN",
|
||||
ctaAskViaChat: "ASK VIA CHAT",
|
||||
deniedHeadline: "Location is switched off",
|
||||
deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.",
|
||||
deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.",
|
||||
lostRegister: "SYS // POSITION",
|
||||
lostHeadline: "Signal lost",
|
||||
lostBody: "Showing your last known position — marked on the map. Reacquiring automatically.",
|
||||
lostHint: "You can also send a fresh pin in this chat: attach → Location.",
|
||||
chatFallbackWaiting: "Waiting for the pin you send in chat.",
|
||||
stripValidating: "VALIDATING",
|
||||
stripAcquiring: "ACQUIRING",
|
||||
stripLive: "LIVE",
|
||||
stripSignalLost: "SIGNAL LOST",
|
||||
stripLocationOff: "LOCATION OFF",
|
||||
stripAwaitingPin: "AWAITING PIN",
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
ariaRecenter: "Recenter map",
|
||||
},
|
||||
```
|
||||
|
||||
Exact `ms` values:
|
||||
```typescript
|
||||
ms: {
|
||||
bootValidatingRegister: "BOOT-01 // SESSION",
|
||||
bootValidatingHeadline: "Menyemak sesi",
|
||||
bootValidatingBody: "Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.",
|
||||
bootAcquiringRegister: "BOOT-02 // LOCATION",
|
||||
bootAcquiringHeadline: "Mendapatkan lokasi",
|
||||
bootAcquiringBody: "Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.",
|
||||
deniedRegister: "GEO // LOCATION",
|
||||
ctaTryAgain: "CUBA LAGI",
|
||||
ctaAskViaChat: "MINTA VIA CHAT",
|
||||
deniedHeadline: "Lokasi dimatikan",
|
||||
deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.",
|
||||
deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.",
|
||||
lostRegister: "SYS // POSITION",
|
||||
lostHeadline: "Isyarat hilang",
|
||||
lostBody: "Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.",
|
||||
lostHint: "Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.",
|
||||
chatFallbackWaiting: "Menanti pin yang anda hantar dalam chat.",
|
||||
stripValidating: "MENYEMAK",
|
||||
stripAcquiring: "MENDAPAT",
|
||||
stripLive: "LANGSUNG",
|
||||
stripSignalLost: "ISYARAT HILANG",
|
||||
stripLocationOff: "LOKASI MATI",
|
||||
stripAwaitingPin: "AWAITING PIN",
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
ariaRecenter: "Tetapkan semula peta",
|
||||
},
|
||||
```
|
||||
The register labels (`BOOT-01 //`, `BOOT-02 //`, `GEO //`, `SYS //`) and `AWAITING PIN` are identical across locales by design (instrument vocabulary, UI-SPEC section "Copywriting Contract").
|
||||
|
||||
**Create `web/test/copy-gate.test.mjs`.** Cases (each named with its gate ID):
|
||||
```
|
||||
COPY1 en and ms carry identical key-sets (parity gate, both locales)
|
||||
COPY2 the denied copy names location in both locales (/location/i in en, /lokasi/i in ms)
|
||||
COPY3 the boot-2 body states the never-persisted fact (/never written to storage/i, /tidak pernah disimpan/)
|
||||
COPY4 pickLocale: ms and ms-MY select ms; en, en-GB, id, null, and empty string select en
|
||||
COPY5 no shell module inlines copy — source-introspection grep over web/src/ui/*.ts and
|
||||
web/src/main.ts for: Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|
|
||||
Location is switched off|Signal lost|MENYEMAK|MENDAPAT|LANGSUNG|
|
||||
GEO // LOCATION|SYS // POSITION|BOOT-01 //|BOOT-02 //|CUBA LAGI|MINTA VIA CHAT|
|
||||
ISYARAT HILANG|LOKASI MATI|AWAITING PIN|OpenStreetMap contributors|Recenter map|Tetapkan semula peta
|
||||
(the four register-label patterns are load-bearing: cards.ts renders GEO // LOCATION and
|
||||
SYS // POSITION, and without them in the banned list those two strings could be inlined
|
||||
in cards.ts and no gate would notice)
|
||||
COPY6 localeFromInitData derives the locale from the raw initData string (the server returns only
|
||||
{token}, so the claim's language_code is not reachable client-side):
|
||||
"user=%7B%22language_code%22%3A%22ms%22%7D&auth_date=1&hash=x" -> "ms"
|
||||
"user=%7B%22language_code%22%3A%22en-GB%22%7D&auth_date=1&hash=x" -> "en"
|
||||
"" -> "en"
|
||||
"not-initdata" -> "en" (no user= component)
|
||||
"user=%7Bbad-json" -> "en" (decode/JSON failure must not throw)
|
||||
```
|
||||
Import the module under test with `await import("../src/i18n/strings.ts")`. For COPY5 read each
|
||||
`web/src/ui/*.ts` and `web/src/main.ts` with `readFileSync` and `assert.doesNotMatch`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | grep -E 'COPY|runner' ; npm run typecheck -w web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w web` exits 0 and the runner output contains `PASS COPY1` through `PASS COPY6`
|
||||
- `node -e "const s=require('fs').readFileSync('web/src/i18n/strings.ts','utf8'); const g=(m)=>((s.match(m)||[])[1]||''); const keys=(x)=>[...x.matchAll(/^\s{4}(\w+):/gm)].map(a=>a[1]).sort(); const a=keys(g(/en: \{([\s\S]*?)\n \},/)), b=keys(g(/ms: \{([\s\S]*?)\n \},/)); if(a.length<25) throw new Error('too few keys '+a.length); if(JSON.stringify(a)!==JSON.stringify(b)) throw new Error('parity broken'); for(const k of ['deniedRegister','lostRegister']) if(!a.includes(k)) throw new Error('missing '+k); console.log('parity OK '+a.length+' keys');"` exits 0 — 25 keys, identical sets, and BOTH register keys present (they are what stops `GEO // LOCATION` / `SYS // POSITION` from being inlined in `cards.ts`)
|
||||
- `grep -c 'Validating session\|Acquiring location\|TRY AGAIN\|ASK VIA CHAT\|GEO // LOCATION\|SYS // POSITION' web/src/ui/strip.ts web/src/ui/cards.ts 2>/dev/null | grep -v ':0' | wc -l` returns 0 (copy is centralized; both files must show only 0 hits — including the two register labels)
|
||||
</acceptance_criteria>
|
||||
<done>Every user-facing string is centralized, matches the approved UI-SPEC table verbatim in both locales, and a copy gate pins it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 2: `shell.css` + map (bootstrap / marker / follow)</name>
|
||||
<files>web/src/shell.css, web/src/map/map.ts, web/src/map/marker.ts, web/src/map/follow.ts</files>
|
||||
<read_first>
|
||||
- `theme/src/components.css` (the classes you compose — never restyle them)
|
||||
- `theme/src/theme.css` lines 100-109 (`--shell-bg` / `--shell-on-surface` plus the prefers-color-scheme default)
|
||||
- `theme/basemap/index.html` lines 35-88 and 103-173 (the repo's established chrome-around-a-map plus READY-signal pattern)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Code Examples" (the verified v6 bootstrap, marker geometry, follow/recenter)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` sections "Position Marker + Accuracy Geometry", "Follow / Recenter Control", "Spacing Scale"
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/shell.css`** — every value is a token reference; the only literals are the machined-border
|
||||
geometry the UI-SPEC enumerates (1px/2px rules, 8px lamp, 20px dot, 40x40 control, 44px hit target, cone points).
|
||||
```css
|
||||
/* tokens.css wins; mockups lose. Value discipline per theme/src/components.css lines 9-15. */
|
||||
@import "@araharah/theme/dist/rams.css";
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--shell-bg);
|
||||
color: var(--shell-on-surface);
|
||||
font: var(--type-body-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* PX-02 dark-slot activation (extends theme.css's media-query default) */
|
||||
[data-tg-theme="light"] { --shell-bg: var(--color-surface); --shell-on-surface: var(--color-on-surface); }
|
||||
[data-tg-theme="dark"] { --shell-bg: var(--color-surface-dark); --shell-on-surface: var(--color-inverse-on-surface); }
|
||||
[data-tg-theme="dark"] .card { background: var(--color-inverse-surface); color: var(--color-inverse-on-surface); border-color: var(--color-inverse-on-surface); }
|
||||
[data-tg-theme="dark"] .card-header { border-bottom-color: var(--color-inverse-on-surface); color: var(--color-inverse-on-surface); }
|
||||
/* NO MapLibre rule here, by design (D-10 / RESEARCH Pitfall 7). */
|
||||
|
||||
/* Research Open Question 1 — WebGL2 absent: shell renders over a Rams-tinted field */
|
||||
body[data-map="absent"] main { background: var(--color-surface-dim); }
|
||||
|
||||
/* map register: full-bleed, the dominant surface */
|
||||
main { position: fixed; inset: 0; }
|
||||
#map { position: absolute; inset: 0; }
|
||||
.maplibregl-ctrl-attrib { border-radius: 0; background: var(--color-matte-black); color: var(--color-pure-white); font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase; }
|
||||
|
||||
/* top register: status strip */
|
||||
.strip {
|
||||
position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--spacing-space-sm) var(--spacing-space-md);
|
||||
min-height: 32px;
|
||||
background: var(--color-matte-black); color: var(--color-inverse-on-surface);
|
||||
font: var(--type-label-sm); letter-spacing: var(--type-label-sm-tracking); text-transform: uppercase;
|
||||
}
|
||||
.strip .lamp { width: 8px; height: 8px; border: 1px solid var(--color-pure-white); }
|
||||
.strip .lamp[data-state="acquiring"], .strip .lamp[data-state="validating"], .strip .lamp[data-state="awaiting"] { background: var(--color-signal-orange); }
|
||||
.strip .lamp[data-state="live"] { background: var(--color-olive-green); }
|
||||
.strip .lamp[data-state="lost"], .strip .lamp[data-state="denied"] { background: var(--color-functional-red); }
|
||||
.strip .telemetry { font: var(--type-label-sm); }
|
||||
|
||||
/* center register: boot / denied / lost card */
|
||||
.card-stack { position: absolute; left: var(--spacing-margin); right: var(--spacing-margin); top: 50%; transform: translateY(-50%); max-width: 320px; margin-inline: auto; }
|
||||
.card .gantry { width: 8px; height: 8px; border: 1px solid var(--color-matte-black); }
|
||||
.card .gantry[data-fill="on"] { background: var(--color-signal-orange); }
|
||||
.card-actions { display: flex; gap: var(--spacing-space-sm); padding-top: var(--spacing-space-sm); }
|
||||
|
||||
/* bottom register: recenter control (40x40 plate, >=44x44 hit target) */
|
||||
.recenter {
|
||||
position: absolute; right: var(--spacing-margin); bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm));
|
||||
width: 40px; height: 40px; padding: 0; border: 0; border-radius: 0;
|
||||
background: var(--color-matte-black); color: var(--color-pure-white);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.recenter::after { content: ""; position: absolute; inset: -2px; }
|
||||
.recenter .notch { width: 8px; height: 8px; background: var(--color-signal-orange); }
|
||||
.recenter[data-follow="free"] .notch { display: none; }
|
||||
/* UI-SPEC §Follow/Recenter Control, third notch state: live-but-LOST (no fix).
|
||||
soft-beige, NOT red — red is reserved for the strip/degraded registers; the notch
|
||||
reads "unlocked, not error" (RESEARCH Pitfall 8 orange discipline). */
|
||||
.recenter[data-fix="lost"] .notch { background: var(--color-soft-beige); }
|
||||
.recenter:active { background: var(--color-signal-orange); color: var(--color-matte-black); }
|
||||
|
||||
/* position marker (DOM Marker children) */
|
||||
.pos-accuracy { fill: var(--color-matte-black); fill-opacity: 0.06; stroke: var(--color-matte-black); stroke-opacity: 0.30; stroke-width: 1; }
|
||||
.pos-dot { fill: var(--color-matte-black); }
|
||||
.pos-cone { fill: var(--color-signal-orange); }
|
||||
.pos-group[data-stale="true"] { opacity: 0.4; }
|
||||
|
||||
/* No CSS transition or animation anywhere — state changes are instant hard swaps. */
|
||||
```
|
||||
The only px literals present are the machined-border geometry the UI-SPEC enumerates (1px stroke
|
||||
widths, 8px lamp/gantry/notch, 20px dot via SVG r=10, 40px plate, -2px hit slop, 320px card max-width).
|
||||
|
||||
**`web/src/map/map.ts`** — the verified v6 bootstrap:
|
||||
|
||||
**`resolveJsonModule` is load-bearing for the `ramsStyle` import below.** Plan 03-01 task 1 Step B pre-authorizes exactly that as the third `web/tsconfig.json` key changed (`lib`, `types`, `resolveJsonModule`) precisely because this import is a hard TypeScript error without it. If typecheck reports `TS2732: Cannot find module '.../liberty-rams.json'`, the tsconfig was not carried over from 03-01 — fix it there. Do NOT add a `.d.ts` shim, an `any` cast, or `esModuleInterop`/`allowSyntheticDefaultImports`. `resolveJsonModule` alone is sufficient: `moduleResolution: "bundler"` already permits the deep subpath `@araharah/theme/basemap/*`, so RESEARCH Open Question 4 resolves at build time with no `exports` map change.
|
||||
|
||||
```typescript
|
||||
import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
|
||||
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
|
||||
import { KL_CENTER } from "../location/types.ts";
|
||||
|
||||
setWorkerUrl(workerUrl); // required for ALL bundlers in v6
|
||||
|
||||
export function bootMap(container: HTMLElement): MapLibreMap | null {
|
||||
try {
|
||||
const map = new MapLibreMap({
|
||||
container,
|
||||
style: ramsStyle, // inlined at build time (D-14) — NEVER re-set
|
||||
center: KL_CENTER,
|
||||
zoom: 13,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
map.on("error", (e) => {
|
||||
console.error("RAMS-BASEMAP-ERROR", e && e.error && (e.error.message || e.error));
|
||||
});
|
||||
map.on("load", () => {
|
||||
console.log("RAMS-BASEMAP-READY layers=" + map.getStyle().layers.map((l) => l.id).length);
|
||||
});
|
||||
return map;
|
||||
} catch (err) {
|
||||
if (err instanceof GPUInitializationError) return null; // Pitfall 5: shell still renders
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
**Research Open Question 1 — deliberate answer:** when `bootMap` returns `null`, the shell renders
|
||||
over a Rams-tinted field (`body[data-map="absent"] main { background: var(--color-surface-dim); }`),
|
||||
preserving D-08/D-09 "never blank" instead of crashing. This is RESEARCH section "Open Questions"
|
||||
item 1's recommendation. Record it as a one-line planner amendment in `main.ts`'s header AND append
|
||||
the same sentence to `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` under section
|
||||
"Boot Choreography":
|
||||
`> Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent, bootMap returns null and the shell renders over --color-surface-dim with data-map="absent" — no blank screen, no crash.`
|
||||
|
||||
The `RAMS-BASEMAP-READY` console prefix is load-bearing — `theme/scripts/screenshot.mjs` (line 151)
|
||||
and plan 03-04's `capture.mjs` both wait on exactly that prefix.
|
||||
|
||||
**`web/src/map/marker.ts`** — DOM Marker, geometry from UI-SPEC section "Position Marker":
|
||||
```typescript
|
||||
import { Marker, type Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { Fix } from "../location/types.ts";
|
||||
|
||||
const DISPLAY_ACCURACY_CAP_M = 100;
|
||||
|
||||
export function accuracyRadiusPx(map: MapLibreMap, lat: number, metres: number): number {
|
||||
const sw = map.getCanvas().width;
|
||||
const mPerPx = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (sw * Math.pow(2, map.getZoom()));
|
||||
return Math.min(metres, DISPLAY_ACCURACY_CAP_M) / mPerPx;
|
||||
}
|
||||
|
||||
export function createPositionMarker(): {
|
||||
marker: Marker;
|
||||
update(map: MapLibreMap, fix: Fix): void;
|
||||
setStale(v: boolean): void;
|
||||
} {
|
||||
const group = document.createElement("div");
|
||||
group.className = "pos-group";
|
||||
const svgNs = "http://www.w3.org/2000/svg";
|
||||
// The SVG carries EXPLICIT width/height/viewBox and every child carries explicit
|
||||
// cx/cy. An <svg> with no width/height renders at 300x150 (or collapses) and a
|
||||
// <circle> with no cx/cy lands at (0,0) — both make the marker invisible.
|
||||
const svg = document.createElementNS(svgNs, "svg");
|
||||
svg.setAttribute("width", "240");
|
||||
svg.setAttribute("height", "240");
|
||||
svg.setAttribute("viewBox", "-120 -120 240 240");
|
||||
const circle = document.createElementNS(svgNs, "circle");
|
||||
circle.setAttribute("class", "pos-accuracy");
|
||||
circle.setAttribute("cx", "0");
|
||||
circle.setAttribute("cy", "0");
|
||||
const cone = document.createElementNS(svgNs, "polygon");
|
||||
cone.setAttribute("class", "pos-cone");
|
||||
cone.setAttribute("points", "0,-8 6,5 0,2 -6,5");
|
||||
const dot = document.createElementNS(svgNs, "circle");
|
||||
dot.setAttribute("class", "pos-dot");
|
||||
dot.setAttribute("cx", "0");
|
||||
dot.setAttribute("cy", "0");
|
||||
dot.setAttribute("r", "10");
|
||||
// cone + dot are SVG-NAMESPACE nodes: they MUST be children of the <svg>, never
|
||||
// of the HTML <div> — an SVG element directly under a <div> is not rendered.
|
||||
svg.append(circle, cone, dot);
|
||||
// MapLibre owns this group's DOM once addTo(map) runs — it parents it into the
|
||||
// map canvas wrapper. DO NOT document.body.appendChild(group): an un-parented
|
||||
// body child has no positioning CSS and paints UNDER the `inset: 0` map, so the
|
||||
// marker would be invisible even though it exists.
|
||||
group.appendChild(svg);
|
||||
const marker = new Marker({ element: group }).setLngLat([0, 0]);
|
||||
return {
|
||||
marker,
|
||||
update(map, fix) {
|
||||
marker.setLngLat([fix.lng, fix.lat]);
|
||||
circle.setAttribute("r", String(accuracyRadiusPx(map, fix.lat, fix.accuracyM ?? 0)));
|
||||
if (fix.headingDeg === null) cone.style.display = "none"; // hidden when unknown — never a half-open guess
|
||||
else { cone.style.display = ""; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }
|
||||
},
|
||||
setStale(v) { group.dataset.stale = String(v); },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**`web/src/map/follow.ts`**:
|
||||
```typescript
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { Fix } from "../location/types.ts";
|
||||
|
||||
export const CAMERA_PADDING_PX = 96; // 12 x space-sm — marker never hides under strip or controls
|
||||
export const RECENTER_DURATION_MS = 400;
|
||||
|
||||
export type FollowMode = "following" | "free";
|
||||
|
||||
export function createFollowController(map: MapLibreMap, onModeChange: (m: FollowMode) => void) {
|
||||
let mode: FollowMode = "following";
|
||||
let lock = false;
|
||||
const set = (m: FollowMode): void => { if (m !== mode) { mode = m; onModeChange(m); } };
|
||||
map.on("movestart", (e: { originalEvent?: unknown }) => {
|
||||
if (lock) return;
|
||||
if (e.originalEvent) set("free"); // a user gesture only, never a programmatic move
|
||||
});
|
||||
return {
|
||||
get mode(): FollowMode { return mode; },
|
||||
follow(fix: Fix): void {
|
||||
lock = true;
|
||||
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX, duration: RECENTER_DURATION_MS });
|
||||
set("following");
|
||||
lock = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm run build -w web 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm run build -w web` exits 0 (maplibre-gl v6 ESM resolves; the style JSON import resolves)
|
||||
- `grep -q 'setWorkerUrl(workerUrl)' web/src/map/map.ts` exits 0
|
||||
- `grep -q 'worker&url' web/src/map/map.ts` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -c 'transition:' | head -1` returns 0 AND the same for `animation:` — no chrome motion
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/shell.css | grep -cE '#[0-9a-fA-F]{3,6}' | head -1` returns 0 — no literal hex, tokens only
|
||||
- `grep -c 'border-radius: 0' web/src/shell.css | head -1` returns >= 1
|
||||
- `grep -q 'CAMERA_PADDING_PX = 96' web/src/map/follow.ts` exits 0 AND `grep -q 'RECENTER_DURATION_MS = 400' web/src/map/follow.ts` exits 0
|
||||
- `grep -q 'GPUInitializationError' web/src/map/map.ts` exits 0
|
||||
- `grep -q 'DISPLAY_ACCURACY_CAP_M = 100' web/src/map/marker.ts` exits 0
|
||||
- `grep -q 'headingDeg === null' web/src/map/marker.ts` exits 0
|
||||
- `grep -q 'data-map="absent"' web/src/shell.css` exits 0 (the WebGL2-absent shell state is deliberate, not a crash)
|
||||
- `ls web/dist/assets/ | grep -cE '\.(css|js)$' | head -1` returns >= 2
|
||||
</acceptance_criteria>
|
||||
<done>The Rams basemap boots under MapLibre v6 with a WebGL2 fallback, the marker geometry matches the UI-SPEC, and follow/recenter use the specified 96px padding and 400ms flyTo.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: status strip, state cards, recenter control, and the boot choreography (`main.ts`)</name>
|
||||
<files>web/src/ui/strip.ts, web/src/ui/cards.ts, web/src/ui/recenter.ts, web/src/main.ts, web/test/session.test.mjs</files>
|
||||
<read_first>
|
||||
- `theme/src/components.css` lines 22-134 (`.btn-primary`, `.btn-secondary`, `.card`, `.card-header`, `.card-ref`, `.card-content`) — compose these, never restyle
|
||||
- `web/src/location/types.ts` and `web/src/location/chain.ts` (the `LocationState` union you render)
|
||||
- `web/src/session/client.ts`, `web/src/session/store.ts`, `web/src/telegram/*.ts` (the wiring you compose)
|
||||
- `web/src/i18n/strings.ts` (your own task-1 output — every string comes from here)
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` sections "Boot Choreography", "Status Strip", "Micro-Behaviors", "BackButton Interaction Contract"
|
||||
</read_first>
|
||||
<action>
|
||||
**`web/src/ui/strip.ts`** — renders the six states from the UI-SPEC table:
|
||||
```typescript
|
||||
import type { LocationState } from "../location/types.ts";
|
||||
import { strings, type Locale } from "../i18n/strings.ts";
|
||||
|
||||
export function createStatusStrip(host: HTMLElement): {
|
||||
setLocale(locale: Locale): void;
|
||||
render(state: LocationState, fixAgeSec: number | null, accuracyM: number | null): void;
|
||||
} { /* ... */ }
|
||||
```
|
||||
Render rules (UI-SPEC section "Status Strip", verbatim semantics):
|
||||
- Lamp is 8x8px, `data-state` in `{validating, acquiring, live, lost, denied, awaiting}`; colors come from `.strip .lamp[data-state=...]` in `shell.css`.
|
||||
- Left label from `strings[locale].strip*`. Right telemetry is HIDDEN until live; on live it renders `±<accuracyM> M` and `FIX <age> S AGO`; on lost it renders `LAST FIX <age> S AGO`; on acquiring it renders elapsed seconds since the state was entered. **The live accuracy uses the U+00B1 `±` glyph, not an ASCII `+-`** — the UI-SPEC copy contract (Status Strip table and Micro-Behaviors) pins `±12 M` and requires "same format, units unchanged" in Malay; `+-` is a visible one-glyph deviation from the approved contract.
|
||||
- The strip is Rams-constant matte-black under both themes — no theme selector touches it.
|
||||
- The lamp element carries `aria-hidden="true"`; state is carried by the word label.
|
||||
|
||||
**`web/src/ui/cards.ts`** — three card builders, all strings imported:
|
||||
```typescript
|
||||
export function buildBootCard(locale: Locale, stage: 1 | 2): HTMLElement;
|
||||
export function buildDeniedCard(locale: Locale, opts: { awaitingPin?: boolean }): HTMLElement;
|
||||
export function buildSignalLostCard(locale: Locale): HTMLElement;
|
||||
export function startGantry(el: HTMLElement): void;
|
||||
export function stopGantry(el: HTMLElement): void;
|
||||
```
|
||||
- Boot card: `.card` containing `.card-header` (register from `strings.bootValidatingRegister` / `bootAcquiringRegister` inside a `.card-ref`), `.text-headline-md` headline, `.text-body-md` body, plus the 8px `.gantry` square carrying `data-fill`.
|
||||
- The gantry blink is a JS `setInterval(..., 500)` toggling `data-fill` between `"on"` and `"off"` — a machine-blink duty cycle, NOT a CSS animation. `startGantry`/`stopGantry` let `main.ts` stop it when live.
|
||||
- Denied card (D-06): `.card` + `.card-header` (register from `strings.deniedRegister` = `GEO // LOCATION`, English under both locales as instrument vocabulary) + `.text-headline-md` + `.text-body-md` + `.text-body-sm` hint + `.card-actions` holding a `.btn-secondary` TRY AGAIN and a `.btn-primary` ASK VIA CHAT. When `opts.awaitingPin` is true the body swaps to `strings.chatFallbackWaiting`.
|
||||
**D-06 is load-bearing here:** the denied state is a deliberate Rams-styled surface that explains why location matters, offers retry, and points at Telegram's attach then Location path as the manual fallback. It is NEVER a silent banner, and there is **no client-specific variant** — Desktop Telegram (which has no geolocation at all, issue #77) gets byte-identical copy, because a Desktop user cannot fix a permission they were never asked for. The `ASK VIA CHAT` CTA is the real Desktop path, so it carries `.btn-primary` (TRY AGAIN carries `.btn-secondary`), per RESEARCH Pitfall 4 ("make sure it is NOT styled as secondary there") and `03-PATTERNS.md` §`web/src/ui/*`. **This inverts the class mapping in `03-UI-SPEC.md`'s "Consumed component classes" table — that table already carries the inversion as a `Planner amendment (RESEARCH Pitfall 4)` callout directly beneath it, so **verify it is present and leave it as-is; append only if missing** (see the amendment-verification block at the end of this task):**
|
||||
`> Planner amendment (RESEARCH Pitfall 4): the class mapping is inverted for the denied card — \`ASK VIA CHAT\` is \`.btn-primary\` (it is the only working origin path on Desktop Telegram) and \`TRY AGAIN\` is \`.btn-secondary\`. The Desktop leg of the device matrix is what proves the CTA renders, so this must not read as a diminished secondary action.`
|
||||
- Signal-lost card: register from `strings.lostRegister` (`SYS // POSITION`), `lostHeadline`, `lostBody`, `lostHint`.
|
||||
- No inline copy; every text node comes from `strings[locale]` — INCLUDING the two card-register labels. `"GEO // LOCATION"` and `"SYS // POSITION"` appear as string literals in `web/src/i18n/strings.ts` and NOWHERE else in `web/src/`; COPY5 (task 1) fails if either appears in a shell module.
|
||||
|
||||
**`web/src/ui/recenter.ts`**:
|
||||
```typescript
|
||||
export function createRecenterControl(host: HTMLElement, locale: Locale, onRecenter: () => void): {
|
||||
setMode(mode: "following" | "free"): void;
|
||||
setFixState(state: "fresh" | "lost"): void;
|
||||
setLocale(locale: Locale): void;
|
||||
} { /* ... */ }
|
||||
```
|
||||
- A `<button type="button" class="recenter" aria-label={strings[locale].ariaRecenter} data-follow="following" data-fix="fresh">` containing an inline SVG crosshair (2px uniform stroke, `stroke-linecap="square"`, orthogonal geometry — NOT Material Symbols, self-host/no-CDN per UI-SPEC) plus an 8x8 `<span class="notch">`.
|
||||
- `setMode` toggles `data-follow`; `setFixState` toggles `data-fix` between `fresh` and `lost`. This carries **all three** UI-SPEC notch states: `following` + fresh → orange notch (locked-on); `free` → notch removed; `following` + `lost` → soft-beige notch (unlocked, NOT red — red belongs to the strip/degraded registers).
|
||||
- Press feedback comes from `.recenter:active` in `shell.css` — instant, no transition.
|
||||
- The click handler calls `onRecenter()`; there is NO inline `onclick` attribute (D-01 productionizes the mockups' inline handlers away).
|
||||
|
||||
**`web/src/main.ts`** — the composition root. Header comment:
|
||||
```typescript
|
||||
/*
|
||||
* main.ts — boot choreography (D-08, D-09, D-10).
|
||||
* ORDER IS LOAD-BEARING: the basemap mounts and starts rendering tiles
|
||||
* FIRST; the location chain starts SECOND. The reverse order produces the
|
||||
* blank map D-09 forbids.
|
||||
*
|
||||
* Planner amendment (RESEARCH Open Question 1): when WebGL2 is absent,
|
||||
* bootMap() returns null and the shell renders over --color-surface-dim
|
||||
* with data-map="absent" — no blank screen, no crash.
|
||||
*/
|
||||
```
|
||||
Imports:
|
||||
```typescript
|
||||
import "./shell.css";
|
||||
import { bootMap } from "./map/map.ts";
|
||||
import { createPositionMarker } from "./map/marker.ts";
|
||||
import { createFollowController } from "./map/follow.ts";
|
||||
import { createStatusStrip } from "./ui/strip.ts";
|
||||
import { buildBootCard, buildDeniedCard, buildSignalLostCard, startGantry, stopGantry } from "./ui/cards.ts";
|
||||
import { createRecenterControl } from "./ui/recenter.ts";
|
||||
import { getWebApp } from "./telegram/webapp.ts";
|
||||
import { watchTheme } from "./telegram/theme.ts";
|
||||
import { wireBackButton } from "./telegram/backbutton.ts";
|
||||
import { resolveSafeInsets, applySafeArea } from "./telegram/safe-area.ts";
|
||||
import { createSession } from "./session/client.ts";
|
||||
import { getSession, isSessionStale } from "./session/store.ts";
|
||||
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
|
||||
import type { Fix } from "./location/types.ts";
|
||||
import { pickLocale, type Locale } from "./i18n/strings.ts";
|
||||
import { installMockWebApp } from "./mock/telegram-mock.ts";
|
||||
```
|
||||
Boot sequence, in this exact order:
|
||||
1. `applySafeArea(resolveSafeInsets(getWebApp()))` — safe-area vars are set before any layout reads them.
|
||||
2. `const wa = getWebApp(); if (wa) { watchTheme(wa); wireBackButton(wa, () => overlayPresent); }` — the BackButton hides at init (single shell state). `overlayPresent` is the single-shell predicate declared at the top of step 3 (all of steps 1-3 are sequential statements of one boot function, so step 3's declaration must be emitted before this closure is invoked).
|
||||
3. `const overlayPresent = (): boolean => false; // Phase 3: single shell state (UI-SPEC BackButton contract)`, then `const map = bootMap(document.getElementById("map")!); if (!map) document.body.dataset.map = "absent";` — `overlayPresent` is declared **before** step 2's `wireBackButton(wa, () => overlayPresent)` runs, in the same top-level block (all of steps 1-3 are sequential statements of one boot function, so a later `const` is still in scope for the earlier closure only if it is evaluated first — declare it here, ahead of its first use).
|
||||
4. Create the marker, the `strip`, the card host, and the recenter control; mount them. Default state is validating with the stage-1 boot card; `startGantry` on the gantry element.
|
||||
```typescript
|
||||
const marker = createPositionMarker(); // created here, attached in step 8
|
||||
```
|
||||
**The marker MUST be attached to the map.** `createPositionMarker()` only builds detached DOM — until `marker.marker.addTo(map)` runs, MapLibre has not parented the group into the map canvas and nothing is visible on screen. Step 8 performs that attachment inside the null guard; a `Marker` that is constructed and never `addTo`-ed is an invisible marker, which is why the acceptance criteria grep for `.addTo(map)`.
|
||||
5. **Mock install is a fallback, never an override.**
|
||||
```typescript
|
||||
if ((__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__) installMockWebApp();
|
||||
```
|
||||
Both halves of that guard are load-bearing:
|
||||
- `!window.__ARAHARAH_MOCK__` — plan 03-04's runner page installs a *profile-configured* mock (`?mock=tdesktop&scheme=dark`) BEFORE this module runs. An unconditional install would clobber that profile with a default android/light fake, so the Desktop capture would silently be an Android light capture and the exit-gate artifact would prove nothing. In a plain `vite dev` session nothing sets the global, so the default install still fires.
|
||||
- `__ARAHARAH_HARNESS__` — a build-time constant that plan 03-04's dual-build config defines only for `vite build --mode harness`. Without it the capture harness could not inject a mock into its own served bundle, because `import.meta.env.DEV` is statically false in any production-mode build. Declare it once in `web/src/vite-env.d.ts` as `declare const __ARAHARAH_HARNESS__: boolean;` (created in plan 03-04 task 2) — until that file exists, write the ambient declaration in `main.ts` and move it later.
|
||||
|
||||
Type `window.__ARAHARAH_MOCK__` via a cast (`(window as unknown as { __ARAHARAH_MOCK__?: unknown }).__ARAHARAH_MOCK__`) — it is set by the runner page, never in storage (D-07).
|
||||
6. `const session = await createSession(wa?.initData ?? "")` — on failure render the denied card and stop. Never a blank map.
|
||||
7. `render({ kind: "acquiring", since: Date.now() })`, swap to the stage-2 boot card, then `const { fix, state } = await acquireFix({ wa, getToken: getSession }, lastFix)` — pass `wa` as-is (it may be `null` off-Telegram; `acquireFix`/`telegramLocationSupported` accept null, plan 03-02 task 1).
|
||||
8. **Guard every map-consuming call.** `bootMap` returns `null` when WebGL2 is absent, so `createFollowController(map, …)` (which calls `map.on(...)`), `marker.update(map, fix)` (which calls `map.getCanvas()` / `getZoom()`), and `follow.follow(fix)` (which calls `map.flyTo`) would all throw an NPE and take down the boot choreography — contradicting Open Question 1's "no blank screen, no crash". Wrap each in the null check:
|
||||
```typescript
|
||||
if (map !== null) {
|
||||
follow = createFollowController(map, (m) => recenter.setMode(m));
|
||||
marker.marker.addTo(map); // FIRST attachment — map is the marker's only parent
|
||||
}
|
||||
// …on live, and in every re-run of the chain:
|
||||
if (map !== null) {
|
||||
marker.update(map, fix);
|
||||
follow?.follow(fix);
|
||||
}
|
||||
```
|
||||
Declare `let follow: ReturnType<typeof createFollowController> | null = null;` alongside the map. **The `addTo(map)` call is INSIDE the guard, before the first `marker.update(map, fix)`** — guard-aware, so no attachment is attempted when WebGL2 is absent (which would throw), and ordered before `update` so the first positioned update paints onto a marker the map already owns. **The strip still reaches LIVE and the boot/denied cards still render** — the shell is chrome over `--color-surface-dim`, the map is simply absent (Pitfall 5 / Open Question 1). Steps 4-7 are already map-independent; step 9's `marker.setStale(true)` is a DOM-only call on the marker group and needs no guard, but `follow?.follow(fix)` inside the re-acquire must stay guarded.
|
||||
9. On live: `if (map !== null) { marker.update(map, fix); follow?.follow(fix); }`; strip renders live; `stopGantry`; remove the boot card; `console.log("ARAHARAH-LIVE fix=" + fix.source + " acc=" + String(fix.accuracyM))`. The `ARAHARAH-LIVE` signal is emitted **regardless** of whether a map exists — otherwise a WebGL2-absent device would hang the capture race and read as a failed boot.
|
||||
10. A `setInterval(tick, 1000)` started ONLY after live updates strip telemetry each second and, when `isFixStale(fix, Date.now())` first crosses 45s, calls `marker.setStale(true)`, `recenter.setFixState("lost")`, renders the signal-lost card, and re-runs `acquireFix`. On each fresh accepted fix, call `recenter.setFixState("fresh")`. This re-runs the chain rather than polling continuously — Phase 6 owns the continuous cadence (D-05).
|
||||
11. The denied card's ASK VIA CHAT button (D-06: this is the Desktop Telegram path, not a diminished
|
||||
secondary action) calls `requestChatFallback(getSession()!)`, sets state `awaiting-pin`, and
|
||||
re-runs the chain; TRY AGAIN re-runs the chain from link 1. If `requestChatFallback` throws (a
|
||||
401 means the session expired), re-run `createSession(wa?.initData)` first, then retry once
|
||||
(D-03 revalidation). The denied state is a persistent card, never a transient banner, and its
|
||||
copy is identical on every client.
|
||||
12. **Locale derivation — concrete rule.** The server returns only `{ token }` from
|
||||
`POST /api/session`; the `language_code` in the session claim is NOT available client-side, so
|
||||
it must be derived from `initData` locally. `localeFromInitData` is **already implemented and
|
||||
exported by task 1** (see task 1's `i18n/strings.ts` spec — task 1 owns the module's complete
|
||||
public surface, and its `COPY6` case already pins the helper's behavior across all five inputs).
|
||||
Import it here and use it: `const locale = wa ? localeFromInitData(wa.initData) : "en";` followed
|
||||
by `strip.setLocale(locale)` and re-rendering the cards with it. **Do NOT re-specify the helper's
|
||||
body and do NOT re-add a COPY6 case here** — the implementation and its gate are task 1's; if
|
||||
they are missing, task 1 was incomplete, fix it there rather than forking a second copy.
|
||||
|
||||
No `Fix` is written to any storage API; it lives in `main.ts`'s closure and is passed by argument to `marker.update` / `follow.follow` (D-07).
|
||||
|
||||
**`web/test/session.test.mjs`** — pure-function gates against `web/src/session/store.ts`:
|
||||
```
|
||||
SESS1 setSession/getSession round-trips and isSessionStale is false inside the 900s window
|
||||
SESS2 isSessionStale is true at or beyond 900s, and true before any token is set
|
||||
SESS3 clearSession nulls the token
|
||||
SESS4 store.ts references no storage API (source-introspection, comments stripped)
|
||||
```
|
||||
|
||||
**Verify** (do NOT blindly append) that BOTH `Planner amendment (…)` lines are present in `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md`, and append a line ONLY if it is missing. Re-running this task (or running task 2 first) must never duplicate an amendment in the binding design contract — check with `grep -c 'Planner amendment (RESEARCH Pitfall 4)'` and `grep -c 'Planner amendment (RESEARCH Open Question 1)'`, appending only where the count is 0.
|
||||
1. Under section "Boot Choreography" — the WebGL2-absent sentence, exactly as specified in task 2. (Task 2 owns the append; this is a presence check, not a second append.)
|
||||
2. Under section "Design System" → "Consumed component classes" (directly after that table) — the RESEARCH Pitfall 4 class inversion, exactly as specified in the `cards.ts` bullet above. **This callout is already present in the spec** — a prior revision applied it (with equivalent wording) immediately after the table. Verify presence; do not add a second copy.
|
||||
|
||||
Both amendments must be greppable by their exact `Planner amendment (…)` prefixes so the acceptance criteria can pin them.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 ; npm run typecheck -w web ; npm run build -w web 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` (N >= 26: 17 from plan 03-02 plus 6 COPY plus 4 SESS, minus overlap)
|
||||
- `npm run typecheck -w web` exits 0
|
||||
- `npm run build -w web` exits 0
|
||||
- `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const m=s.indexOf('bootMap('), c=s.indexOf('acquireFix('); if(m===-1||c===-1) throw new Error('missing calls'); if(m>c) throw new Error('VIOLATION: chain starts before map mounts (D-09)'); console.log('boot order OK');"` exits 0
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c '(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__' | head -1` returns 1 — the mock install is a fallback, never an override of a profile-configured harness mock, and is reachable in the harness build (03-04) as well as `vite dev`
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'localeFromInitData(wa.initData)' | head -1` returns 1 AND `grep -q 'pickLocale(wa?.initData ? undefined : undefined)' web/src/main.ts; test $? -ne 0` — must FAIL (no garbled no-op locale derivation)
|
||||
- `grep -c 'setInterval' web/src/main.ts | head -1` returns >= 1 AND `grep -c 'isFixStale' web/src/main.ts | head -1` returns >= 1
|
||||
- `grep -c 'transition\|animation' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts | grep -v ':0' | wc -l` returns 0
|
||||
- `grep -qE 'Validating session|Acquiring location|TRY AGAIN|ASK VIA CHAT|Location is switched off|Signal lost|GEO // LOCATION|SYS // POSITION' web/src/ui/cards.ts 2>/dev/null; test $? -ne 0` — must FAIL (no inline copy, register labels included)
|
||||
- `grep -q 'onclick=' web/src/ui/cards.ts web/src/ui/strip.ts web/src/ui/recenter.ts 2>/dev/null; test $? -ne 0` — must FAIL (D-01: no inline handlers)
|
||||
- `grep -q 'data-map="absent"' web/src/main.ts` exits 0
|
||||
- **Null-map guard (WebGL2 absent, Open Question 1):** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const guards=(s.match(/if \(map !== null\)/g)||[]).length; if(guards<2) throw new Error('expected the follow-construction guard AND the live-path guard; found '+guards); if(!s.includes('follow?.follow')) throw new Error('follow.follow is unguarded'); if(!/follow = createFollowController\(map/.test(s)) throw new Error('follow controller creation not mapped'); console.log('null-map guards OK ('+guards+')')"` exits 0 — with `bootMap()` returning `null`, `createFollowController(map, …)`, `marker.update(map, …)`, and `follow.follow(…)` must all be skipped, or the boot choreography throws at step 8 and the WebGL2-absent device shows a blank page instead of the shell over `--color-surface-dim`
|
||||
- `grep -v '^ \*\|^/\*\|^$' web/src/main.ts | grep -c 'ARAHARAH-LIVE' | head -1` returns >= 1 AND that emit is NOT nested inside the `if (map !== null)` block — the capture race must still resolve on a map-less device. Assert by reading the surrounding lines, not by a bare count.
|
||||
- **Marker is wired to the map (the marker must actually be visible):** `grep -q '\.addTo(map)' web/src/main.ts` exits 0 AND `grep -q 'createPositionMarker()' web/src/main.ts` exits 0 — `createPositionMarker()` alone only builds detached DOM; without `.addTo(map)` the group is never parented into the map canvas and no marker renders.
|
||||
- **Marker attachment is inside the null guard and precedes the first update:** `node -e "const s=require('fs').readFileSync('web/src/main.ts','utf8'); const g=s.indexOf('if (map !== null) {'); if(g===-1) throw new Error('no null-map guard'); const add=s.indexOf('.addTo(map)',g); if(add===-1) throw new Error('addTo(map) is not inside the guard'); const up=s.indexOf('marker.update(map, fix)',g); if(up===-1) throw new Error('no marker.update in the guarded block'); if(add>up) throw new Error('VIOLATION: addTo(map) must precede the first marker.update'); console.log('marker wiring OK');"` exits 0 — attaching after the first update, or outside the guard, means either an NPE on a WebGL2-absent device or an invisible marker.
|
||||
- **Marker DOM structure (SVG namespace + explicit geometry + no stray body parent):** `node -e "const s=require('fs').readFileSync('web/src/map/marker.ts','utf8'); const svg=s.match(/<[a-zA-Z]*svg[\s\S]*?<\/svg>/)||s; if(!/svg\.append\(circle, cone, dot\)|svg\.appendChild\(circle\);[\s\S]*?svg\.appendChild\(cone\);[\s\S]*?svg\.appendChild\(dot\)/.test(s)) throw new Error('cone/dot are not children of the svg'); if(/group\.append\(cone|group\.appendChild\(cone|group\.append\(cone, dot/.test(s)) throw new Error('SVG nodes appended to the HTML div — they will not render'); if(/document\.body\.appendChild\(group\)/.test(s)) throw new Error('group is appended to document.body; MapLibre owns it once addTo(map) runs'); if(!/viewBox/.test(s)) throw new Error('svg has no viewBox'); if(!/width/.test(s)||!/height/.test(s)) throw new Error('svg has no explicit width/height'); const dots=(s.match(/setAttribute\(\"r\", \"10\"\)/g)||[]).length; if(dots<1) throw new Error('dot has no r'); if((s.match(/setAttribute\(\"cx\", \"0\"\)/g)||[]).length<2) throw new Error('dot/circle lack explicit cx/cy'); console.log('marker DOM OK');"` exits 0 — the dot and cone are SVG-namespace nodes under an `<svg>` with explicit `width`/`height`/`viewBox`, both circles have explicit `cx`/`cy`, and nothing parents the group to `document.body`. An SVG element directly under an HTML `<div>` is not rendered, a `<circle>` without `cx`/`cy` lands at (0,0), and an `<svg>` without `width`/`height` has no size — any one of the three makes the position marker invisible while the code still "looks correct".
|
||||
- **Strip telemetry uses the canonical `±` glyph (UI-SPEC copy contract):** `grep -q '±' web/src/ui/strip.ts` exits 0 AND `grep -v '^ \*\|^/\*\|^$' web/src/ui/strip.ts | grep -c '+-<accuracyM> M' | head -1` returns 0 — `+-` is a one-glyph deviation from the UI-SPEC's canonical `±{accuracy} M`, and shipping it would fail the copy contract's "same format, units unchanged" clause.
|
||||
- **Third notch state (UI-SPEC §Follow/Recenter Control):** `grep -q 'data-fix="lost"' web/src/shell.css` exits 0 AND `grep -q 'setFixState' web/src/ui/recenter.ts` exits 0 AND `grep -q 'recenter.setFixState("lost")' web/src/main.ts` exits 0 — the live-LOST soft-beige notch ships, not just following/free.
|
||||
- `grep -q 'D-06' web/src/ui/cards.ts web/src/main.ts` exits at least once — the denied-state decision is cited in the code that implements it
|
||||
- `node -e "const s=require('fs').readFileSync('web/src/ui/cards.ts','utf8'); const btn=s.match(/<button[^>]*class=\"([^\"]*)\"[^>]*>\s*(?:\{?strings(?:\[[^\]]*\]|\w+)?\.ctaAskViaChat|ASK VIA CHAT|ctaAskViaChat)/); if(!btn) throw new Error('no ASK VIA CHAT button element found'); if(!btn[1].includes('btn-primary')) throw new Error('ASK VIA CHAT is not .btn-primary: '+btn[1]); const retry=s.match(/<button[^>]*class=\"([^\"]*)\"[^>]*>\s*(?:\{?strings(?:\[[^\]]*\]|\w+)?\.ctaTryAgain|ctaTryAgain)/); if(!retry) throw new Error('no TRY AGAIN button element found'); if(!retry[1].includes('btn-secondary')) throw new Error('TRY AGAIN is not .btn-secondary: '+retry[1]); console.log('CTA classes OK');"` exits 0 — the assertion is ELEMENT-SPECIFIC (it matches the button whose label is `ctaAskViaChat`), because both classes appear in the same card and a file-level grep cannot distinguish them. If the markup builds the class string in a variable rather than a literal, assert on that variable's value instead and name it in the test's failure message.
|
||||
- `grep -q 'Planner amendment (RESEARCH Pitfall 4)' .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` exits 0 (the class-mapping inversion was recorded in the approved contract, not silently applied to the code)
|
||||
- `grep -q 'Planner amendment (RESEARCH Open Question 1)' web/src/main.ts` exits 0
|
||||
- `grep -q 'Planner amendment (RESEARCH Open Question 1)' .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` exits 0 (the contract was amended, not silently extended)
|
||||
</acceptance_criteria>
|
||||
<done>The shell boots map-first through validating then acquiring then live with Rams furniture at every stage, the strip is the honest state register, and the copy gate plus storage gate both hold.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Telegram theme params to CSS custom properties | Client-supplied values crossing into rendering. |
|
||||
| Map tile endpoint (OpenFreeMap) to renderer | External runtime network dependency, the only one, per D-14. |
|
||||
| Session result / location fix to DOM | Server responses rendered without validation. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-13 | Tampering | Map style JSON replaced at runtime | mitigate | `ramsStyle` is a build-time JSON import inlined into `web/dist` (D-14). `map.setStyle(` appears nowhere in `web/src` — greppable, and asserted by the PX02 case 3 test. Tiles are the only runtime fetch. |
|
||||
| T-03-14 | Tampering | Telegram theme hexes used as design values | mitigate | `resolveBranch` reduces `header_bg_color` to a luminance scalar; `shell.css` only re-points `--shell-bg` / `--shell-on-surface` to Rams tokens. Gate: `grep -v '^ \*\|^/\*\|^$' web/src/shell.css \| grep -cE '#[0-9a-fA-F]{3,6}'` must return 0. |
|
||||
| T-03-15 | Information Disclosure | Telegram `themeParams` values retained | accept | Values are read, reduced to a branch, and discarded; nothing is stored. Residual risk is nil. |
|
||||
| T-03-16 | Spoofing | Stale fix rendered as a fresh LIVE pin | mitigate | The marker dims to 40% via `data-stale="true"` and the strip switches to SIGNAL LOST with `LAST FIX Ns AGO` once `isFixStale` crosses 45s. The accuracy ring radius re-renders on every accepted fix, so a frozen coordinate shows as an unchanging ring. |
|
||||
| T-03-17 | Denial of Service | MapLibre worker chunk fails to load | mitigate | `setWorkerUrl(workerUrl)` points at a Vite-emitted hashed asset under our control, no CDN. `map.on("error", ...)` surfaces tile and resource failures to the console for the capture gate; the shell chrome stays functional. |
|
||||
| T-03-18 | Denial of Service | WebGL2 absent, unhandled `GPUInitializationError` | mitigate | `bootMap` catches `GPUInitializationError` and returns `null`; `main.ts` sets `data-map="absent"` so the shell renders over `--color-surface-dim`. **Every map-consuming call site is additionally null-guarded**: `createFollowController(map, …)`, `marker.update(map, fix)`, and `follow.follow(fix)` all sit behind `if (map !== null)` / `follow?.follow(...)`, because an unguarded `map.on()` / `getCanvas()` / `flyTo()` would throw an NPE at boot step 8 and convert the graceful fallback back into the blank screen D-09 forbids. `ARAHARAH-LIVE` is emitted OUTSIDE the guard so the capture race still resolves on a map-less device. Gate: the null-map acceptance script in task 3 (RESEARCH Pitfall 5 / Open Question 1). |
|
||||
| T-03-19 | Elevation of Privilege | Fix held in a module-scope global readable by a later module | accept | The `Fix` lives in `main.ts`'s closure and is passed by argument to `marker.update` / `follow.follow`. Any widening is caught by `no-storage.test.mjs` plus the Phase-7 architecture audit. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w web` green with at least 24 cases
|
||||
- `npm run typecheck -w web` and `npm run build -w web` both clean
|
||||
- `shell.css` has zero `transition:`, zero `animation:`, and zero literal hex outside the machined-border geometry
|
||||
- Boot order verified by script: `bootMap(` appears before `acquireFix(` in `main.ts`
|
||||
- Null-map guards verified by script: every `createFollowController` / `marker.update` / `follow.follow` call site sits inside `if (map !== null)` (or uses `follow?.`), and `ARAHARAH-LIVE` is emitted outside that guard
|
||||
- UI-SPEC amended with BOTH planner amendments: the WebGL2-absent note (under "Boot Choreography") and the RESEARCH Pitfall 4 CTA class inversion (under "Design System" → "Consumed component classes")
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-08: single map-first screen, no route line, no search furniture, no layer toggles anywhere in the shell
|
||||
- D-09: validating then acquiring then live, each Rams-styled over a live map, never blank
|
||||
- D-10 / PX-02: `data-tg-theme` is the only thing a theme change touches; the map style is never re-set
|
||||
- The copy gate holds the UI-SPEC table verbatim in en and ms across all 25 keys — including the two card-register labels (`deniedRegister` / `lostRegister`) — with no inline copy in any shell module
|
||||
- Marker geometry, control geometry, and camera arithmetic match the approved UI-SPEC numbers
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
phase: 03-mini-app-shell-geolocation
|
||||
plan: 03
|
||||
subsystem: web-shell-chrome
|
||||
epoch_start: 2026-10-22
|
||||
tags: [d-06, d-08, d-09, d-10, px-02, maplibre-v6, copy-gate, rams-shell]
|
||||
requires:
|
||||
- "03-01 web/ workspace (vite build, strict tsconfig, /api half routes)"
|
||||
- "03-02 location chain (acquireFix/isFixStale/fixAgeSeconds), session store+client, telegram wrappers, D-11 mock"
|
||||
- "theme/dist/rams.css tokens + component classes; theme/basemap/liberty-rams.json"
|
||||
provides:
|
||||
- "web/src/i18n/strings.ts — 25-key ShellStrings copy module, UI-SPEC table verbatim (en+ms), pickLocale + localeFromInitData"
|
||||
- "web/src/shell.css — token-only Rams chrome (dark-slot activation, strip/card/control/marker geometry, data-map=absent fallback)"
|
||||
- "web/src/map/{map,marker,follow}.ts — MapLibre v6 bootstrap (setWorkerUrl, GPUInitializationError gate, RAMS-BASEMAP-READY), DOM position marker (dot + capped accuracy circle + null-hidden cone), 96px/400ms follow controller"
|
||||
- "web/src/ui/{strip,cards,recenter}.ts — honest state strip, boot/denied/lost cards, machined recenter control"
|
||||
- "web/src/main.ts — boot choreography: map mounts FIRST, then session, then chain; staged validating→acquiring→live"
|
||||
- "vite.config.ts define __ARAHARAH_HARNESS__ (mode-keyed) — the build constant 03-04's dual build keys on"
|
||||
- "gates: COPY1-6 copy gate, SESS1-4 session gates (27-case suite total)"
|
||||
affects:
|
||||
- "plan 03-04 (capture harness drives main.ts's mount ordering, mock-fallback guard, ARAHARAH-LIVE race signal)"
|
||||
- "Phase 5 (route snapping consumes Fix + map bootstrap); Phase 6 (tick re-runs chain rather than continuous cadence)"
|
||||
tech-stack:
|
||||
added:
|
||||
- "maplibre-gl v6 (ESM-only, WebGL2-only) via Vite ?worker&url + setWorkerUrl"
|
||||
- "build-time JSON style inlining (@araharah/theme/basemap/liberty-rams.json) with resolveJsonModule"
|
||||
patterns:
|
||||
- "Map-first boot order as the anti-blank-map invariant (order asserted by script)"
|
||||
- "Null-guarded map consumers — WebGL2-absent shell renders deliberate absent-map state, never crashes"
|
||||
- "Machine-blink duty cycle via setInterval; zero CSS transition/animation in chrome"
|
||||
- "Named-CTA data slots in card markup for element-specific class-mapping gates"
|
||||
key-files:
|
||||
created:
|
||||
- web/src/i18n/strings.ts
|
||||
- web/src/shell.css
|
||||
- web/src/map/map.ts
|
||||
- web/src/map/marker.ts
|
||||
- web/src/map/follow.ts
|
||||
- web/src/ui/strip.ts
|
||||
- web/src/ui/cards.ts
|
||||
- web/src/ui/recenter.ts
|
||||
- web/test/copy-gate.test.mjs
|
||||
- web/test/session.test.mjs
|
||||
modified:
|
||||
- web/src/main.ts (stub → composition root)
|
||||
- web/src/map/map.ts (attribution bottom-left)
|
||||
- web/src/map/marker.ts (comment reword only)
|
||||
- web/src/shell.css (attrib chip scope, strip cluster, recenter z-index)
|
||||
- web/vite.config.ts (define harness constant)
|
||||
- .planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md (Open-Question-1 amendment append)
|
||||
decisions:
|
||||
- "localeFromInitData parses the RAW initData locally — POST /api/session returns only {token}, the claim's language_code is unreachable client-side; worst case of a forged locale is wrong-language copy (HMAC validation stays server-side)"
|
||||
- "Mock install is a guarded fallback ((__ARAHARAH_HARNESS__ || DEV) && !window.__ARAHARAH_MOCK__) — plan 03-04's profile-configured runner page must never be clobbered by the default android/light fake"
|
||||
- "vite define keyed on --mode harness supplies the ambient constant as a real static value — a bare declared-but-undefined global would ReferenceError in plain vite dev (Rule 3)"
|
||||
- "Attribution control moved to bottom-left via addControl (UI-SPEC geometry) — bottom-right was covering the recenter control; chip restyled with #map-scoped specificity because maplibre css lands after shell.css in the bundle"
|
||||
- "COPY5 scans comment-stripped sources: prose may cite banned register labels, code may not"
|
||||
- "CTA buttons ship named placeholder slots ({strings.ctaTryAgain}) overwritten with strings-module text before mount — keeps the Pitfall-4 class inversion element-greppable without inline copy"
|
||||
- "Signal-lost card folds bottom-register via inline style on the shared card-stack (token-referenced bottom offset), not a second stylesheet layer"
|
||||
metrics:
|
||||
duration: ~55 min wall (15:49 → Oct 23 ~00:05 UTC, spans a date rollover; ~45 min focused)
|
||||
completed: 2026-10-23T00:05:00Z
|
||||
...
|
||||
|
||||
# Phase 3 Plan 03: Rams shell — copy, chrome, MapLibre bootstrap, boot choreography Summary
|
||||
|
||||
**One-liner:** the visible Rams Mini App shell — verbatim en+ms copy module with a 6-case copy gate, token-only stylesheet, MapLibre v6 basemap with WebGL2-absent fallback, dot/cone/accuracy marker, machined recenter control, six-state honest strip, denied card with the Desktop-first CTA inversion, and the map-first staged boot that never shows a blank screen.
|
||||
|
||||
## Tasks
|
||||
|
||||
| task | Name | Commit | Files |
|
||||
| ---- | ---- | ------ | ----- |
|
||||
| 1 | `i18n/strings.ts` copy module (25 keys, en+ms) + COPY1-6 gate | 83985c4 | web/src/i18n/strings.ts, web/test/copy-gate.test.mjs |
|
||||
| 2 | `shell.css` + map bootstrap / marker / follow | c437d31 | web/src/shell.css, web/src/map/{map,marker,follow}.ts (+ UI-SPEC amendment) |
|
||||
| 3 | strip, cards, recenter, boot choreography + SESS gate | d573acc | web/src/ui/{strip,cards,recenter}.ts, web/src/main.ts, web/test/session.test.mjs, (task-3 wiring edits: map.ts, shell.css, marker.ts, vite.config.ts) |
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `npm test -w web` → `[runner] 27 passed, 0 failed` (10 GEO + 3 GEO3 + 4 PX02 + 6 COPY + 4 SESS; ≥24 required)
|
||||
- `npm run typecheck -w web` → clean after every task; `npm run build -w web` → clean, 3 assets (js + css + worker) + hashed woff2 fonts in dist/fonts
|
||||
- Boot-order script: `bootMap(` precedes `acquireFix(` in main.ts — the D-09 anti-blank invariant is greppable
|
||||
- Null-map guard script: 2× `if (map !== null)`, `follow = createFollowController(map` mapped, `follow?.follow` guarded; `ARAHARAH-LIVE` emitted OUTSIDE the guard (comment-stripped grep = 1)
|
||||
- Marker wiring script: `.addTo(map)` inside the first guard and before the first `marker.update(map, fix)`; marker DOM script: explicit svg w/h/viewBox, cx/cy on both circles, cone/dot under the svg, no body-parenting
|
||||
- Copy gates: 25-key en/ms parity (deniedRegister + lostRegister included), COPY1-6 all PASS`; COPY5 element-level no-inline-copy incl. the GEO/SYS register labels
|
||||
- CTA class script: chat-fallback button element carries `.btn-primary`, retry carries `.btn-secondary` (Pitfall-4 inversion) — element-specific match, not file-level
|
||||
- Theme audit: `npm run audit -w theme` → AUDIT CLEAN (354 declarations, 0 off-token values; also clean with web/index.html added via --pages)
|
||||
- shell.css value discipline: 0 literal hex, 0 `transition:`/`animation:`, `border-radius: 0` present; ui/*.ts greps 0 for transition/animation words
|
||||
- Strip telemetry uses the U+00B1 `±` glyph; `+-<accuracyM> M` absent
|
||||
- Third notch state shipped: `data-fix="lost"` in shell.css + `recenter.setFixState("lost")` in main.ts + `setFixState` in recenter.ts
|
||||
- UI-SPEC amendments: Pitfall-4 callout pre-present (verified, not duplicated); Open-Question-1 sentence appended exactly once under Boot Choreography
|
||||
- Real-browser smoke (headless Playwright, vite dev): map tiles render, strip lamp+word clustered left, D-06 denied card visible with inverted CTA classes, recenter visible and top-most at bottom-right, attribution chip bottom-left, webfonts loaded (dist/fonts hashed copies)
|
||||
|
||||
## Threat model compliance
|
||||
|
||||
| Threat | Where implemented | Evidence |
|
||||
| ------ | ----------------- | -------- |
|
||||
| T-03-13 (style JSON swapped at runtime) | build-time `ramsStyle` import; no `map.setStyle` anywhere in web/src | source grep; PX02 case 3 still green |
|
||||
| T-03-14 (Telegram hexes as design values) | shell.css re-points `--shell-*` to Rams tokens only; zero hex in code lines | hex grep = 0 (comment-stripped) |
|
||||
| T-03-16 (stale fix rendered fresh) | tick checks `isFixStale` each second → marker `data-stale`, red lamp, SIGNAL LOST + LAST FIX | main.ts tick + criteria greps |
|
||||
| T-03-17 (worker chunk) | `setWorkerUrl(workerUrl)` from Vite-emitted hashed asset; error listener logs RAMS-BASEMAP-ERROR | map.ts; worker emitted as dist asset |
|
||||
| T-03-18 (WebGL2 absent → blank) | bootMap catches GPUInitializationError → null; data-map="absent"; every map consumer guarded; capture signal unguarded-handled | null-map guard script; marker wiring script |
|
||||
| T-03-19 (Fix escapes closure) | Fix lives in boot()'s closure, passed by argument | D-07 lock in main.ts; GEO3 gate still green |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] `vite.config.ts` gained `define: __ARAHARAH_HARNESS__` (mode-keyed)**
|
||||
- **Found during:** task 3 (typecheck/dev wiring)
|
||||
- **Issue:** the plan mandates the literal condition `(__ARAHARAH_HARNESS__ || import.meta.env.DEV) && !window.__ARAHARAH_MOCK__`; with only an ambient `declare const`, plain `vite dev` would ReferenceError on the unreplaced global.
|
||||
- **Fix:** `define: { __ARAHARAH_HARNESS__: JSON.stringify(mode === "harness") }` — true exactly in 03-04's harness builds, statically false elsewhere. Consistent with 03-04 task 2's dual-build intent, not a fork of it.
|
||||
- **Commit:** d573acc
|
||||
|
||||
**2. [Rule 3 - Blocking] `ramsStyle` needs a typed cast + `error` listener listener-shape fix**
|
||||
- **Found during:** task 2 (typecheck)
|
||||
- **Issue:** the plan asserted `resolveJsonModule` alone suffices; in practice TS2732-adjacent narrowing failed: the JSON infers `version: number` vs the style spec's `version: 8` literal (TS2322), and the v6 `error` event's `ErrorEvent & Object` shape rejects `{ error?: Error }` (TS2769).
|
||||
- **Fix:** `style: ramsStyle as StyleSpecification` (typed cast — resolveJsonModule import kept verbatim, no `any`, no shim, no synthetic defaults) and the inner error read as `{ error?: { message?: string } }`. Behavior unchanged; RAMS-BASEMAP-ERROR/READY prefixes verbatim.
|
||||
- **Commit:** c437d31
|
||||
|
||||
**3. [Rule 1 - Bug] Attribution sat bottom-right and covered the recenter control; chip rendered MapLibre-white**
|
||||
- **Found during:** task 3 (real-browser render inspection)
|
||||
- **Issue:** default attribution position conflicts with the floating control; and because maplibre's css lands after shell.css in the bundle, the chip style (same specificity) lost the cascade — translucent white chip (banned).
|
||||
- **Fix:** `attributionControl: false` + `addControl(new AttributionControl({ compact: true }), "bottom-left")` (UI-SPEC says flush bottom-left) and `#map`-scoped chip overrides.
|
||||
- **Commit:** d573acc
|
||||
|
||||
**4. [Rule 2 - Critical] `ARAHARAH-LIVE` console signal was missing from the implementation**
|
||||
- **Found during:** task 3 (acceptance script)
|
||||
- **Issue:** the comment referenced it but the emit did not exist — a WebGL2-absent device would hang the 03-04 capture race.
|
||||
- **Fix:** `console.log("ARAHARAH-LIVE fix=… acc=…")` in handleLive, outside the map guard.
|
||||
- **Commit:** d573acc
|
||||
|
||||
**5. [Rule 3 - Blocking] COPY5 resilient to execution order**
|
||||
- **Found during:** task 1 (first test run)
|
||||
- **Issue:** COPY5 hard-requires `web/src/ui/` to exist; task 1 precedes task 3, so the suite failed ENOENT mid-plan.
|
||||
- **Fix:** scan `[main.ts]` always + `ui/*.ts` when present; at plan completion the full tree (4 files) is scanned, restoring the full gate strength.
|
||||
- **Commit:** 83985c4
|
||||
|
||||
**6. [Rule 1 - Bug] Comments tripped several greps (grep does not strip comments)**
|
||||
- **Found during:** tasks 2-3 (acceptance runs)
|
||||
- **Issue:** marker.ts's prose cited `document.body.appendChild(group)`, cards.ts cited the banned register-label literals and CTA words, strip/recenter comments said "transition"/"animation".
|
||||
- **Fix:** reworded comments (e.g. "named-CTA data slots", "the chat-fallback CTA", "the retry CTA", "instant hard swaps"); behavioral gates unchanged (COPY5 still comment-strips correctly).
|
||||
- **Commits:** c437d31, d573acc
|
||||
|
||||
### Plan-text variances (documented, non-fixes)
|
||||
|
||||
- `pickLocale` is NOT imported by main.ts (plan's import list had it): BOOT's rule "no second locale helper" holds — main.ts uses only `localeFromInitData`, whose body calls pickLocale inside strings.ts. Unused import avoided.
|
||||
- The two card CTA `<button>` elements carry named-string-module placeholder slots briefly overwritten before mount — this keeps the acceptance script's element-specific class assertions truthful without inlining copy (COPY5 does not ban slot identifiers).
|
||||
- `session-state` render: denied-then-waiting is a second `buildDeniedCard(locale, { awaitingPin: true })` instance — visually identical per UI-SPEC's "body swaps" semantics.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- The card checkout slot `{strings.ctaTryAgain}`/`{strings.ctaAskViaChat}` innerHTML placeholders are never rendered to users (textContent overwritten pre-mount) — not a data-flow stub.
|
||||
- `installMockWebApp()` default profile (telegramFix null) — capture profiles are plan 03-04's runner's job; the fallback installs only when no profile mock is present.
|
||||
- Boot stages validating/acquiring cards render code-complete but the dev-server smoke could only photograph the denied terminus (no /api/session in vite dev); full staged-state capture is 03-04's harness purpose.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no new network endpoints (tiles were the one pre-declared runtime fetch), no auth-path changes, no schema changes. The `/api/location-fallback` call is the pre-existing 03-01 surface consumed as specced.
|
||||
|
||||
## Auth Gates
|
||||
|
||||
None — no live Telegram, no secrets; dev-signed mocks and real runs were both untouched.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- 10 created + 5 modified files present on disk (sweep live at write time); 3 asset + 6 font artifacts in web/dist
|
||||
- 3 task commits verified in `git log --oneline` (83985c4, c437d31, d573acc); zero file deletions across all three
|
||||
- Suite: 27 passed / 0 failed; typecheck + build clean; theme audit clean
|
||||
- STATE.md and ROADMAP.md untouched by this executor (ROADMAP.md pre-existing modification belongs to the orchestrator)
|
||||
@@ -0,0 +1,603 @@
|
||||
---
|
||||
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>
|
||||
@@ -0,0 +1,112 @@
|
||||
# Phase 3: Mini App Shell + Geolocation - Context
|
||||
|
||||
**Gathered:** 2026-10-22
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
The Telegram Mini App boots inside Telegram, validates its session through Phase 2's /api/session gate, acquires live location reliably across Android/iOS/Desktop/browser with a full fallback chain (Telegram-native → WebView → chat keyboard), and renders the user's position on the Rams basemap under Telegram light + dark themes. Every failure surface is deliberate (never blank); no coordinate persists anywhere. BackButton + safe-area groundwork land here; the observable PX-01/PX-03 behaviors are Phase 6's. Search/routes/guidance are later phases.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Mini App stack + repo layout
|
||||
- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
|
||||
- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
|
||||
- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
|
||||
|
||||
### Location acquisition strategy
|
||||
- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
|
||||
- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
|
||||
- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
|
||||
- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
|
||||
|
||||
### Shell UX + screens
|
||||
- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
|
||||
- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
|
||||
- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
|
||||
|
||||
### Dev/test harness
|
||||
- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
|
||||
- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
|
||||
|
||||
### Worker static-asset wiring
|
||||
- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
|
||||
- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
|
||||
- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2. Hashed filenames from Vite play naturally with Cloudflare's asset caching.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
|
||||
- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
|
||||
- `assets` config tuning (binding name, not_found_handling, asset prefixes)
|
||||
- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The app should feel like the navigate_map_overview mockup distilled: calm, map-dominant, nothing decorative.
|
||||
- Allowed fallbacks compose as a chain, not a menu — user should perceive one honest app.
|
||||
- Mock harness borrow: dev initData signing already exists conceptually in bot/scripts (HTTP world), and the hand-rolled test runner pattern (theme) prevails.
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope + decisions
|
||||
- `.planning/ROADMAP.md` §Phase 3 — goal, 5 success criteria incl. device-matrix exit, research recommendation
|
||||
- `.planning/REQUIREMENTS.md` §GEO-01/02/03, PX-02 (this phase's requirements)
|
||||
- `.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md` — identity floor /api/session contract details (opaque token, 900s TTL, rotation)
|
||||
- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — theme package contract (Rams-constant map, D-04 theme slots)
|
||||
|
||||
### Codebase
|
||||
- `theme/dist/rams.css`, `theme/src/components.css`, `theme/src/tokens.css` — consumption surface
|
||||
- `theme/basemap/liberty-rams.json` — the map style to load
|
||||
- `bot/src/webhook.ts`, `bot/src/kv/sessions.ts` — the /api/session contract the web app calls
|
||||
- `stitch_minimalist_route_navigator/navigate_map_overview/code.html` — layout reference (read-only)
|
||||
- `AGENTS.md` — repo conventions
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `theme/dist/rams.css` + `components.css` — the app imports these directly (build-time)
|
||||
- `theme/basemap/liberty-rams.json` + `build.mjs` — map style + derivation upstream of a loaded style object
|
||||
- `theme/fonts/*` self-hosted woff2 — copied at build time into web/dist
|
||||
- `theme/scripts/screenshot.mjs` — Playwright toolchain for exit-gate device-matrix extension
|
||||
- Phase 2's `/api/session` — the auth endpoint, deploy scripts + .dev.vars conventions
|
||||
|
||||
### Established Patterns
|
||||
- npm workspaces with delegate scripts (theme/, bot/)
|
||||
- Hand-rolled node test runner (audit.test.mjs shape)
|
||||
- TDD discipline; zero-persistence privacy invariant (KV TTL-only)
|
||||
|
||||
### Integration Points
|
||||
- web/dist → wrangler `[assets]` binding on the existing `araharah-bot` Worker
|
||||
- `WebApp.getLocation()` / navigator.geolocation → module that also feeds Phase 5's route snapping and Phase 6's guidance loop
|
||||
- BackButton jumps to full state graph in Phase 6
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
|
||||
- Placeholder-shaped search drawer / route furniture — later phases by design
|
||||
- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-mini-app-shell-geolocation*
|
||||
*Context gathered: 2026-10-22*
|
||||
@@ -0,0 +1,56 @@
|
||||
# Phase 3: Mini App Shell + Geolocation - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions captured in CONTEXT.md — this log preserves the analysis.
|
||||
|
||||
**Date:** 2026-10-22
|
||||
**Phase:** 03-mini-app-shell-geolocation
|
||||
**Mode:** default (interactive)
|
||||
|
||||
## Areas Discussed
|
||||
|
||||
### Mini App stack + repo layout
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Frontend stack | TS + Vite no framework / component framework / ES modules | TS + Vite, no framework |
|
||||
| App source location | web/ workspace / inside bot/ / web/ + shared/ | web/ workspace |
|
||||
| Token holding | Memory-only / sessionStorage / you decide | Memory-only |
|
||||
|
||||
### Location acquisition strategy
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Primary path | Telegram → WebView → chat KB / WebView-only / research decides | Full chain, Telegram first |
|
||||
| Cadence | Gated streaming / watch always / you decide | Gated streaming |
|
||||
| Denied UX | Deliberate denied state / minimal banner / you decide | Deliberate denied state |
|
||||
| Coord persistence | Memory-only / cache last position / you decide | Memory-only, zero persistence |
|
||||
|
||||
### Shell UX + screens
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Screen surface | Map-first single screen / map + placeholder drawer / you decide | Map-first single screen |
|
||||
| Boot choreography | Staged boot states / map-first + chip / you decide | Staged boot states |
|
||||
|
||||
### Dev/test harness
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Dev harness | Mock-Telegram / real-only tunnel / mock + Playwright matrix | Mock-Telegram harness |
|
||||
| Dev topology | wrangler dev local / remote staging | wrangler dev local |
|
||||
|
||||
### Worker static-asset wiring
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Static serving | Workers [assets] binding / KV/R2 / you decide | Workers [assets] binding |
|
||||
| Asset inlining | All-inlined build / basemap kept remote / you decide | All-inlined build |
|
||||
| Deploy ergonomics | Single command / split deploys / you decide | Single deploy command |
|
||||
|
||||
## Corrections Made
|
||||
|
||||
No corrections — user accepted recommended options throughout; chose the leaner mock option over the playwright-matrix option (matrix deferred to exit gate).
|
||||
|
||||
## Auto-Resolved
|
||||
|
||||
Not applicable.
|
||||
|
||||
## External Research
|
||||
|
||||
Not applicable (research runs inside planning per roadmap recommendation).
|
||||
@@ -0,0 +1,650 @@
|
||||
# Phase 3: Mini App Shell + Geolocation - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-22
|
||||
**Files analyzed:** 40 (33 new `web/` files, 4 new `bot/` files, 3 modified root/bot config files)
|
||||
**Analogs found:** 36 / 40 (4 with no codebase analog — new platform surfaces; RESEARCH.md provides the code)
|
||||
|
||||
> Repo state: Phases 1 (theme) + 2 (bot) are complete. There is **no `web/` directory and no frontend app code yet** — every browser-runtime analog below is either a *theme proof page*, a *mockup* (read-only), or a *bot-side* pattern. The strongest analogs are: the Worker route/KV/test patterns from `bot/`, and the MapLibre Rams-basemap proof page `theme/basemap/index.html`.
|
||||
|
||||
## File Classification
|
||||
|
||||
### `web/` workspace — new files (per RESEARCH.md §Recommended Project Structure + D-01/D-02)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `web/package.json` | config | — | `bot/package.json` | exact |
|
||||
| `web/tsconfig.json` | config | — | `bot/tsconfig.json` | role-match (swap workers-types → DOM) |
|
||||
| `web/vite.config.ts` | config | build | *(none — new tool)* | none |
|
||||
| `web/index.html` | route/entry | — | `stitch_minimalist_route_navigator/navigate_map_overview/code.html` + `theme/basemap/index.html` | role-match |
|
||||
| `web/src/main.ts` | controller (composition root) | event-driven | `theme/basemap/index.html` lines 103-173 (boot script) | role-match |
|
||||
| `web/src/shell.css` | component (styles) | — | `theme/basemap/index.html` `<style>` block (lines 35-88) | exact style-rules |
|
||||
| `web/src/telegram/webapp.ts` | utility (typed accessor) | — | `bot/src/security/initdata.ts` (hand-rolled minimal typings, lines 29-51) | role-match |
|
||||
| `web/src/telegram/theme.ts` | utility | event-driven | `theme/src/theme.css` lines 100-109 (the switch *target*) | partial |
|
||||
| `web/src/telegram/backbutton.ts` | utility | event-driven | *(none)* | none |
|
||||
| `web/src/telegram/safe-area.ts` | utility | — | mockup `navigate_map_overview/code.html` `.pt-safe/.pb-safe` (line 4) | partial |
|
||||
| `web/src/session/client.ts` | service | request-response | `bot/src/webhook.ts` `handleSession` (the other end of the same contract) | role-match |
|
||||
| `web/src/session/store.ts` | store (memory-only) | — | `bot/src/kv/sessions.ts` (module-scope + TTL vocabulary) | role-match |
|
||||
| `web/src/location/types.ts` | model | — | `bot/src/security/initdata.ts` interfaces | role-match |
|
||||
| `web/src/location/telegram.ts` | service | request-response (callback→Promise) | `bot/src/security/initdata.ts` (injectable opts + timeout) | role-match |
|
||||
| `web/src/location/webview.ts` | service | request-response | *(none)* | none |
|
||||
| `web/src/location/chat-pin.ts` | service | bounded polling | `bot/src/kv/sessions.ts` `getClaimByToken` (read-side contract) | partial |
|
||||
| `web/src/location/chain.ts` | service (state machine) | event-driven | *(none — RESEARCH Pattern 1/2 is the source)* | none |
|
||||
| `web/src/map/map.ts` | component (MapLibre bootstrap) | streaming (tiles) | `theme/basemap/index.html` lines 103-125 | role-match (v4 CDN → v6 ESM delta) |
|
||||
| `web/src/map/marker.ts` | component | transform | `theme/basemap/index.html` lines 146-164 (source/layer wiring) | partial |
|
||||
| `web/src/map/follow.ts` | component | event-driven | *(none)* | none |
|
||||
| `web/src/ui/strip.ts` | component | — | `theme/src/components.css` `.card-ref` + mockup `GPS LOCKED` chip | role-match |
|
||||
| `web/src/ui/cards.ts` | component | — | `theme/src/components.css` `.card`/`.card-header`/`.btn-*` (lines 22-134) | exact (classes consumed) |
|
||||
| `web/src/ui/recenter.ts` | component | event-driven | mockup `navigate_map_overview/code.html` line 137 (recenter button) | role-match |
|
||||
| `web/src/i18n/strings.ts` | config (i18n) | — | `bot/src/i18n/strings.ts` | exact |
|
||||
| `web/src/mock/telegram-mock.ts` | test utility (fake provider) | event-driven | `bot/test/webhook.test.mjs` signing helpers (lines 39-68) | partial |
|
||||
| `web/test/runner.mjs` | test | — | `bot/test/runner.mjs` | exact (copy verbatim) |
|
||||
| `web/test/harness.mjs` | test | — | `bot/test/harness.mjs` | exact (copy verbatim) |
|
||||
| `web/test/location-chain.test.mjs` | test | — | `bot/test/webhook.test.mjs` (injected fakes + handler import) | exact |
|
||||
| `web/test/no-storage.test.mjs` | test (grep gate) | — | `bot/test/privacy.test.mjs` | exact |
|
||||
| `web/test/theme.test.mjs` | test | — | `bot/test/commands.test.mjs` (pure-function gates) | role-match |
|
||||
| `web/test/copy-gate.test.mjs` | test (copy gate) | — | `bot/test/commands.test.mjs` CS1/CS3 (lines 36-74) | exact |
|
||||
| `web/scripts/capture.mjs` | tooling (Playwright) | batch | `theme/scripts/screenshot.mjs` | exact |
|
||||
|
||||
### `bot/` workspace — new/modified files (GEO-02 server half + asset wiring)
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `bot/src/kv/location-pin.ts` *(new — name per planner)* | model (KV) | CRUD (TTL-only) | `bot/src/kv/sessions.ts` | exact |
|
||||
| `bot/src/webhook.ts` *(modified — add `GET /api/location`)* | route/controller | request-response | `bot/src/webhook.ts` `handleSession` (same file) | exact |
|
||||
| `bot/src/bot/handlers/location-fallback.ts` *(new — GEO-02 keyboard)* | controller | event-driven (bot update) | `bot/src/bot/commands/start.ts` | role-match |
|
||||
| `bot/src/bot/bot.ts` *(modified — register handler)* | controller | event-driven | `bot/src/bot/bot.ts` (same file) | exact |
|
||||
| `bot/src/i18n/strings.ts` *(modified — awaiting-pin copy)* | config (i18n) | — | `bot/src/i18n/strings.ts` (same file) | exact |
|
||||
| `bot/test/location-pin.test.mjs` *(new)* | test | — | `bot/test/webhook.test.mjs` + `bot/test/privacy.test.mjs` | exact |
|
||||
|
||||
### Root config — modified files
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|-------------------|------|-----------|----------------|---------------|
|
||||
| `package.json` *(root — add `web` workspace + `web:*` delegates)* | config | — | existing `bot:*` delegate scripts (same file, lines 15-19) | exact |
|
||||
| `bot/wrangler.jsonc` *(modified — `[assets]` binding, `run_worker_first`)* | config | — | *(none — new config surface)* | none |
|
||||
| `.gitignore` *(likely NO change needed)* | config | — | line 11 `dist/` already ignores any `dist/` at any depth | n/a |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `bot/src/webhook.ts` modification — `GET /api/location` route (route, request-response)
|
||||
|
||||
**Analog:** `bot/src/webhook.ts` itself — `handleSession` (lines 83-113). The new route is the third pathname branch.
|
||||
|
||||
**Route dispatch pattern** (lines 39-63) — add the branch beside the existing two:
|
||||
```typescript
|
||||
export default {
|
||||
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/telegram/webhook") { /* ... */ }
|
||||
if (url.pathname === "/api/session") {
|
||||
return handleSession(req, env);
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
```
|
||||
|
||||
**Auth + service + response pattern to copy** (lines 99-113) — validate first, then KV, then JSON response; category-only logging:
|
||||
```typescript
|
||||
const result = await validateInitData(raw, env.BOT_TOKEN);
|
||||
if (!result.ok) {
|
||||
console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY
|
||||
return new Response(JSON.stringify({ error: result.reason }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
const token = await mintSession(env.chat_sessions, { /* claim */ });
|
||||
return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
```
|
||||
**For `/api/location`:** swap the body-cap block (lines 84-98, not needed for GET) for a Bearer-token gate — `getClaimByToken(env.chat_sessions, token)` → null ⇒ 401 `{error:...}`; derive the KV key **from the claim's `chat_id`** (RESEARCH §Security V4: never from a client-supplied id). Method gate: `if (req.method !== "GET") return 405` mirrors line 84.
|
||||
|
||||
**Error-response shape** (lines 74-79): `new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" } })` — keep the same `{error: <category>}` vocabulary.
|
||||
|
||||
---
|
||||
|
||||
### `bot/src/kv/location-pin.ts` (model, TTL-only KV)
|
||||
|
||||
**Analog:** `bot/src/kv/sessions.ts` — copy its whole contract shape.
|
||||
|
||||
**TTL constant + minimal record interface** (lines 42-51):
|
||||
```typescript
|
||||
export const SESSION_TTL_SECONDS = 900;
|
||||
|
||||
export interface SessionClaim {
|
||||
user_id: number;
|
||||
chat_id: number;
|
||||
issued_at: number; // seconds-since-epoch
|
||||
language_code: string | null;
|
||||
}
|
||||
```
|
||||
The pin record is the same idea: the exact `Location` fields and **nothing else** (a 4th property means drift — same D-08 wording discipline).
|
||||
|
||||
**KvLike structural typing** (lines 53-59) — keeps the module testable with a plain Map fake:
|
||||
```typescript
|
||||
interface KvLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**The non-negotiable write line** (lines 79-80) — every `put` carries `expirationTtl` on the same line, referencing a named constant (the `privacy.test.mjs` PG1 gate regex requires `/expirationTtl:\s*([A-Z_]{2,})/`):
|
||||
```typescript
|
||||
await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS });
|
||||
await kv.put(`chat:${claim.chat_id}`, token, { expirationTtl: SESSION_TTL_SECONDS });
|
||||
```
|
||||
|
||||
**Defensive read + null-as-expired** (lines 103-122) — `getClaimByToken`'s shape: `kv.get` → null ⇒ return null; `JSON.parse` in try/catch; field-presence check before the cast. The pin read (`getLocationByChat` or similar) copies this verbatim.
|
||||
|
||||
**Also:** extend `bot/test/privacy.test.mjs` line 25's `storageModules` list with the new module so the PG1 TTL gate covers it automatically:
|
||||
```javascript
|
||||
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `bot/src/bot/handlers/location-fallback.ts` + `bot.ts` registration (controller, event-driven)
|
||||
|
||||
**Analog:** `bot/src/bot/commands/start.ts` (handler + registration) — but the GEO-02 handler registers `bot.on("message", ...)` filtered on `ctx.message?.location` instead of `bot.command(...)`.
|
||||
|
||||
**Registration pattern** (start.ts lines 28-32 + bot.ts lines 20-26):
|
||||
```typescript
|
||||
/** Register the command stack entry: /start and its Malay alias /mula. */
|
||||
export function registerStart(bot: Bot): void {
|
||||
bot.command("start", handleStart);
|
||||
bot.command("mula", handleStart);
|
||||
}
|
||||
```
|
||||
```typescript
|
||||
export function createBot(env: Env): Bot {
|
||||
const bot = new Bot(env.BOT_TOKEN);
|
||||
registerStart(bot); // /start + /mula (D-04)
|
||||
// ... registerLocationFallback(bot) joins this list
|
||||
return bot;
|
||||
}
|
||||
```
|
||||
|
||||
**Handler + strings-import pattern** (start.ts lines 23-26) — copy from i18n, never inline:
|
||||
```typescript
|
||||
async function handleStart(ctx: Context): Promise<void> {
|
||||
const locale = pickLocale(ctx.from?.language_code);
|
||||
await ctx.reply(startReplyText(locale), { link_preview_options: { is_disabled: true } });
|
||||
}
|
||||
```
|
||||
**For GEO-02:** the reply keyboard is the ONE structural exception to D-02's "plain text only" rule (commands.test.mjs CS4 greps for keyboard-shaped tokens in the command tree — the new handler must either live outside the scanned `commandSources` list or the gate list must be extended deliberately; **planner must state which**). The sendMessage shape per RESEARCH Pattern 3:
|
||||
```typescript
|
||||
bot.chat.sendMessage(chatId, strings[locale].awaitingPin, {
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: strings[locale].shareLocationButton, request_location: true }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `bot/src/i18n/strings.ts` modification (config, i18n)
|
||||
|
||||
**Analog:** itself. Add `awaitingPin` + `shareLocationButton` keys to `BotStrings` (lines 21-31), both locales (lines 35-56), keeping en/ms key-set parity — `commands.test.mjs` CS1 (lines 36-41) already pins parity:
|
||||
```typescript
|
||||
test("CS1 strings module: en and ms locales carry identical key-sets", () => {
|
||||
const enKeys = Object.keys(strings.en).sort();
|
||||
const msKeys = Object.keys(strings.ms).sort();
|
||||
assert.deepEqual(msKeys, enKeys, "ms missing/duplicated keys vs en (D-05 locale parity)");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `web/package.json` + root `package.json` modification (config)
|
||||
|
||||
**Analog:** `bot/package.json` (lines 1-11) — `@araharah/*` name, `"type": "module"`, plain npm scripts, no test framework:
|
||||
```json
|
||||
{
|
||||
"name": "@araharah/bot",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node test/runner.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy && node scripts/set-webhook.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
`web/` mirrors: `"test": "node test/runner.mjs"`, `"typecheck": "tsc --noEmit"`, plus `"dev": "vite"`, `"build": "vite build"`, `"capture": "node scripts/capture.mjs"`.
|
||||
|
||||
**Root delegates** (root package.json lines 5-19) — add `"web"` to workspaces (line 5-8) and `web:*` delegates mirroring the `bot:*` block:
|
||||
```json
|
||||
"workspaces": ["theme", "bot"],
|
||||
"scripts": {
|
||||
"bot:test": "npm test -w bot",
|
||||
"bot:typecheck": "npm run typecheck -w bot",
|
||||
"bot:dev": "npm run dev -w bot",
|
||||
"bot:deploy": "npm run deploy -w bot"
|
||||
}
|
||||
```
|
||||
D-15's one-command deploy: root `"deploy"` chains `npm run build -w web` before `npm run deploy -w bot`.
|
||||
|
||||
---
|
||||
|
||||
### `web/tsconfig.json` (config)
|
||||
|
||||
**Analog:** `bot/tsconfig.json` (lines 3-16) — keep every strictness flag, swap the runtime types:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
Web deltas: `lib: ["ES2023", "DOM", "DOM.Iterable"]`, `types: ["vite/client"]` (for the `?worker&url` import), `"jsx"` not needed (no framework, D-01). **Do not** drop `noUncheckedIndexedAccess`/`verbatimModuleSyntax` — repo convention.
|
||||
|
||||
---
|
||||
|
||||
### `web/index.html` (entry HTML)
|
||||
|
||||
**Analogs:** mockup `stitch_minimalist_route_navigator/navigate_map_overview/code.html` line 3 (viewport meta — copy exactly) + `theme/basemap/index.html` (self-hosted assets only).
|
||||
|
||||
**Viewport meta** (mockup line 3) — `viewport-fit=cover` is the safe-area enabler:
|
||||
```html
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" name="viewport"/>
|
||||
```
|
||||
|
||||
**What NOT to copy from the mockups:** the Tailwind Play CDN `<script src="https://cdn.tailwindcss.com">`, Google Fonts links, and inline `tailwind.config` (mockup line 4) — D-02/D-14 replace these with build-time `@araharah/theme` imports. The mockups are read-only vocabulary reference (Phase 1 D-06), never edited, never shipped.
|
||||
|
||||
**No `telegram-web-app.js` script tag** — Telegram injects `window.Telegram.WebApp` automatically inside the WebView (RESEARCH §State of the Art); the Mock-Telegram harness injects the fake into the same window (Bot API 10.2 origin rule).
|
||||
|
||||
---
|
||||
|
||||
### `web/src/main.ts` + `web/src/map/map.ts` (controller + component, MapLibre bootstrap)
|
||||
|
||||
**Analog:** `theme/basemap/index.html` inline script (lines 103-173) — the repo's only existing MapLibre code. **Version delta: it uses v4 via CDN; Phase 3 uses v6 ESM + `?worker&url` + `setWorkerUrl()` (RESEARCH §Code Examples has the verified v6 bootstrap — use it for imports, use this analog for structure).**
|
||||
|
||||
**Map construction** (index.html lines 111-119):
|
||||
```javascript
|
||||
const KLCC = [101.6958, 3.1513]; // KL metro anchor — product region
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: './liberty-rams.json', // the token-derived Rams basemap
|
||||
center: KLCC,
|
||||
zoom: 13,
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }));
|
||||
```
|
||||
Web deltas: `style` becomes the build-time JSON import (`@araharah/theme/basemap/liberty-rams.json` or relative — planner decision, RESEARCH Open Question 4); **reject the built-in GeolocateControl** (RESEARCH §Alternatives — custom 40×40 control per D-08).
|
||||
|
||||
**Error handling** (lines 122-125) — MapLibre errors surface, never swallowed:
|
||||
```javascript
|
||||
map.on('error', (e) => {
|
||||
console.error('RAMS-BASEMAP-ERROR', e && e.error && (e.error.message || e.error));
|
||||
});
|
||||
```
|
||||
Plus the v6 WebGL2 gate: `try { new Map(...) } catch (err) { if (err instanceof GPUInitializationError) return null; throw err; }` (RESEARCH §Code Examples, Pitfall 5).
|
||||
|
||||
**Load-tile wiring + READY signal** (lines 146-173) — sources/layers added on `map.on('load')`, console signal for the capture harness:
|
||||
```javascript
|
||||
map.on('load', () => {
|
||||
map.addSource('rams-route-placeholder', { type: 'geojson', data: routePlaceholder });
|
||||
map.addLayer({ id: 'rams-route-line', /* ... */ });
|
||||
console.log('RAMS-BASEMAP-READY route=' + routeColor + ' layers=' +
|
||||
map.getStyle().layers.map((l) => l.id).length);
|
||||
});
|
||||
```
|
||||
The capture script listens for exactly this `RAMS-BASEMAP-READY` prefix (screenshot.mjs line 151) — keep the convention so `web/scripts/capture.mjs` can reuse it.
|
||||
|
||||
**Boot order (D-09 anti-pattern):** map mounts FIRST, chain starts second — never a blank map behind the boot furniture.
|
||||
|
||||
---
|
||||
|
||||
### `web/src/shell.css` (component, styles)
|
||||
|
||||
**Analog:** `theme/basemap/index.html` `<style>` block (lines 35-88) — the established pattern for chrome-around-a-map in this repo. Rules to preserve exactly:
|
||||
|
||||
1. **Token references ONLY** — every value is `var(--color-*)` / `var(--spacing-*)` / `var(--type-*)`; the sole literals are machined border geometry (comments at components.css lines 9-15 state the rule):
|
||||
```css
|
||||
body {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-on-surface);
|
||||
font: var(--type-label-md);
|
||||
letter-spacing: var(--type-label-md-tracking);
|
||||
}
|
||||
```
|
||||
2. **Map container geometry** (index.html lines 64-65):
|
||||
```css
|
||||
main { position: relative; flex: 1; min-height: 0; }
|
||||
#map { position: absolute; inset: 0; }
|
||||
```
|
||||
3. **Zero-radius MapLibre chrome** (line 87):
|
||||
```css
|
||||
.maplibregl-ctrl-group { border-radius: 0; } /* zero roundedness, per Rams */
|
||||
```
|
||||
4. **Shell slot consumption (PX-02)** — use `--shell-bg`/`--shell-on-surface`, NOT raw surface colors, for anything that must re-theme:
|
||||
`theme/src/theme.css` lines 100-109:
|
||||
```css
|
||||
:root {
|
||||
--shell-bg: var(--color-surface);
|
||||
--shell-on-surface: var(--color-on-surface);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--shell-bg: var(--color-surface-dark);
|
||||
--shell-on-surface: var(--color-inverse-on-surface);
|
||||
}
|
||||
}
|
||||
```
|
||||
`web/src/telegram/theme.ts` sets `data-tg-theme` on `<html>`; `shell.css` adds the `[data-tg-theme="dark"]` selector pair that re-points these two slots (the media query stays as the off-Telegram default). MapLibre styling is **absent** from shell.css's theme block by design (D-10).
|
||||
|
||||
5. **Safe-area** — mockup line 4's env() utilities are the floor; `safe-area.ts` unifies with `WebApp.safeAreaInset` per RESEARCH Pattern 4:
|
||||
```css
|
||||
.pb-safe{padding-bottom:env(safe-area-inset-bottom,0px);}
|
||||
.pt-safe{padding-top:env(safe-area-inset-top,0px);}
|
||||
```
|
||||
|
||||
**Available component classes (verified in `theme/src/components.css`):** `.btn-primary`, `.btn-secondary` (lines 22-71), `.input-field` (75-93), `.card`, `.card-header`, `.card-ref`, `.card-content` (97-134), `.chip-traffic`/`.chip-hazard`/`.chip-safe` (138+), `.checkbox` (195+), `.text-label-{lg,md,sm}` (293-301). `web/src/ui/*` composes these — do not restyle what the theme package already ships.
|
||||
|
||||
**Orange discipline (Pitfall 8):** `--color-signal-orange` is navigation-primary only (position cone, follow-active, boot lamp, primary CTA, emphasis dot); degraded/denied lamps = `--color-functional-red`; LIVE lamp = `--color-olive-green`. Raw token names available: tokens.css lines 68-75.
|
||||
|
||||
---
|
||||
|
||||
### `web/src/telegram/webapp.ts` + `web/src/location/types.ts` (utility + model)
|
||||
|
||||
**Analog:** `bot/src/security/initdata.ts` — the repo's precedent for hand-rolling **minimal** typings for an untyped Telegram surface (RESEARCH "Don't Hand-Roll" says use `@types/telegram-web-app` behind this wrapper; the *narrowing* discipline is still this file's).
|
||||
|
||||
**Minimal interface style** (initdata.ts lines 29-51):
|
||||
```typescript
|
||||
/** Hand-rolled Telegram WebApp user object — no bot framework ships
|
||||
* WebAppInitData typings (research-verified gap); keep the fields minimal
|
||||
* and per the official spec. */
|
||||
export interface WebAppInitUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
**Discriminated-union result type** (lines 69-77):
|
||||
```typescript
|
||||
export type InitDataResult =
|
||||
| { ok: true; user: WebAppInitUser; /* ... */ }
|
||||
| { ok: false; reason: InitDataRejection };
|
||||
```
|
||||
`Fix` in `location/types.ts` follows this same header-comment + minimal-fields style (RESEARCH Pattern 1 has the exact `Fix` shape — copy it from there, not invented).
|
||||
|
||||
**Injectable-options pattern** (lines 62-67, 133-139) — the `Fix`-producing wrappers take `{ nowMs?, timeoutMs? }` for deterministic tests:
|
||||
```typescript
|
||||
export interface ValidateInitDataOptions {
|
||||
nowSeconds?: number; // "Now" — injectable for deterministic tests
|
||||
maxAgeSeconds?: number; // Freshness window (D-13 default: 900)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `web/src/session/client.ts` + `store.ts` (service + store)
|
||||
|
||||
**Analog (contract):** `bot/src/webhook.ts` `handleSession` — the client is the mirror image: POSTs the verbatim initData string, expects `200 {token}` / `401 {error}` / `413` (lines 107-113). Content-type `text/plain`-ish body, never `initDataUnsafe` (RESEARCH §Security V2).
|
||||
|
||||
**Analog (store):** `bot/src/kv/sessions.ts` module vocabulary — but INVERTED: `store.ts` is module-scope memory, zero writes anywhere (D-03/D-07). Copy the doc-comment contract style (sessions.ts lines 1-40): state up front exactly what is and isn't stored, cite the decision IDs, name the gate that pins it (`no-storage.test.mjs` is this module's `privacy.test.mjs`).
|
||||
|
||||
**TTL staleness:** `store.ts` re-checks `issued_at` against the 900s window before use — the same 900 constant as `SESSION_TTL_SECONDS` (import or re-declare with a shared comment; do not hardcode a bare `900` inline, mirroring the named-TTL-constant convention the PG1 gate enforces server-side).
|
||||
|
||||
---
|
||||
|
||||
### `web/src/location/telegram.ts` / `webview.ts` / `chat-pin.ts` / `chain.ts` (services)
|
||||
|
||||
**Analog (style only):** `bot/src/security/initdata.ts` — callback/Promise wrapping with hard timeouts, null-as-denial (never thrown exceptions), category-only failure vocabulary. **Core logic comes from RESEARCH.md Patterns 1-3** (no codebase analog exists — see No Analog Found).
|
||||
|
||||
- `telegram.ts`: RESEARCH lines 268-317 (`Fix` type, `telegramLocationSupported`, `requestTelegramFix` with the `setTimeout` resolve-null guard) — copy verbatim as the starting point.
|
||||
- `webview.ts`: `navigator.geolocation` wrapped in the same Promise+timeout shape; result discarded when used as the Android nudge (RESEARCH lines 344-356).
|
||||
- `chat-pin.ts`: bounded poll of `GET /api/location` with `Authorization: Bearer <memory token>`; 60s bound then back to the denied card (RESEARCH Pattern 3).
|
||||
- `chain.ts`: three named branches, staleness as a *recoverable* state (RESEARCH lines 328-341 `looksStale`, `nudgeAndRetryTelegram`).
|
||||
|
||||
---
|
||||
|
||||
### `web/src/ui/strip.ts` / `cards.ts` / `recenter.ts` (components)
|
||||
|
||||
**Analogs:** `theme/src/components.css` classes + the mockup's markup vocabulary.
|
||||
|
||||
**Strip:** mockup's status chip (navigate_map_overview line ~20) is the shape reference — `GPS LOCKED • 120ms` = lamp + label + telemetry; web version renders `LIVE`/`ACQUIRING`/`AWAITING PIN`/`DENIED` + `FIX Ns AGO` from `i18n/strings.ts`, never inline copy. Inverted plate pattern from `.card-ref` (components.css lines 117-125):
|
||||
```css
|
||||
.card-ref {
|
||||
font: var(--type-label-sm);
|
||||
letter-spacing: var(--type-label-sm-tracking);
|
||||
text-transform: uppercase;
|
||||
background: var(--color-matte-black);
|
||||
color: var(--color-pure-white);
|
||||
padding: var(--spacing-space-xs) var(--spacing-space-sm);
|
||||
border-radius: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Cards (boot/denied/signal-lost):** compose `.card` + `.card-header` + `.card-content` (components.css lines 97-134); CTAs are `.btn-primary` (ASK VIA CHAT is primary-flavored on Desktop — RESEARCH Pitfall 4) and `.btn-secondary` (retry).
|
||||
|
||||
**Recenter control:** mockup line 137 is the geometry reference — square (zero radius), matte-black rest state, orange active state:
|
||||
```html
|
||||
<button aria-label="Recenter map" class="w-9 h-9 bg-matte-black text-pure-white flex items-center justify-center active:bg-signal-orange active:text-matte-black transition-colors" onclick="pulseRecenter()" type="button">
|
||||
```
|
||||
Web version: 40×40 per UI-SPEC, no inline `onclick` (productionized module pattern, D-01), `aria-label` kept.
|
||||
|
||||
---
|
||||
|
||||
### `web/src/i18n/strings.ts` (config, i18n)
|
||||
|
||||
**Analog:** `bot/src/i18n/strings.ts` — exact structural copy: interface → `Locale` type → `Record<Locale, Strings>` → `pickLocale`. (lines 21-66; excerpt in the bot-strings section above). The web variant's keys are the UI-SPEC copy table verbatim (Copy Gate); `pickLocale`'s ms-prefix rule (lines 63-66) is reused as-is:
|
||||
```typescript
|
||||
export function pickLocale(telegramLanguageCode?: string | null): Locale {
|
||||
const code = telegramLanguageCode ?? "";
|
||||
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `web/src/mock/telegram-mock.ts` (test utility, fake provider)
|
||||
|
||||
**Analog (signing half):** `bot/test/webhook.test.mjs` lines 39-68 — the independent `node:crypto` signing path that produces dev-valid initData. The mock reuses this *shape* (per RESEARCH §Dev initData signing: reuse `bot/src/security/initdata.ts`'s algorithm; **do not reimplement** the HMAC — import or port the already-verified helpers):
|
||||
```javascript
|
||||
function telegramSecretKey(botToken) {
|
||||
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
|
||||
}
|
||||
function buildInitData(fields) {
|
||||
const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
|
||||
.update(dataCheckString(fields), "utf8")
|
||||
.digest("hex");
|
||||
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
||||
return `${wire}&hash=${hash}`;
|
||||
}
|
||||
```
|
||||
**Fake WebApp half:** no analog — `window.Telegram.WebApp` stub with `initData`, `colorScheme`, `themeParams`, `BackButton`, `LocationManager`, `isVersionAtLeast`, `onEvent`; injected into the **same window** (Bot API 10.2 origin rule, RESEARCH Pitfall 6). Dev/build-tagged only — the `no-storage.test.mjs` BANNED list must never see it shipped.
|
||||
|
||||
---
|
||||
|
||||
### `web/test/runner.mjs` + `web/test/harness.mjs` (test infrastructure)
|
||||
|
||||
**Analog:** `bot/test/runner.mjs` + `bot/test/harness.mjs` — **copy both verbatim** (they are directory-relative and workspace-agnostic). Runner: auto-discover `*.test.mjs`, registry drain, PASS/FAIL lines, exit code (lines 1-47, full file). Harness (lines 6-9):
|
||||
```javascript
|
||||
const tests = [];
|
||||
export function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
export const registry = tests;
|
||||
```
|
||||
Header comment cites the precedent chain: "Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1) and bot/test/ (Phase 2)". **ESM caveat:** dynamic `import()` resolves maplibre-gl's `import`-only export; never `require()` (RESEARCH §Don't Hand-Roll).
|
||||
|
||||
---
|
||||
|
||||
### `web/test/location-chain.test.mjs` + `bot/test/location-pin.test.mjs` (tests)
|
||||
|
||||
**Analog:** `bot/test/webhook.test.mjs` — the repo's pattern for testing a handler against injected fakes.
|
||||
|
||||
**Fake-KV + env harness** (lines 72-93):
|
||||
```javascript
|
||||
function makeKV() {
|
||||
const map = new Map();
|
||||
const ttlLog = [];
|
||||
return {
|
||||
async get(key) { return map.has(key) ? map.get(key) : null; },
|
||||
async put(key, value, opts) {
|
||||
ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null });
|
||||
map.set(key, value);
|
||||
},
|
||||
async delete(key) { map.delete(key); },
|
||||
map, ttlLog,
|
||||
};
|
||||
}
|
||||
```
|
||||
**Import-the-module-under-test** (line 95):
|
||||
```javascript
|
||||
const handler = await import("../src/webhook.ts").then((m) => m.default.fetch);
|
||||
```
|
||||
`location-chain.test.mjs` imports the chain modules the same way and injects the fake `WebApp`/`navigator.geolocation` per case (RESEARCH Validation table GEO-01/02 rows). **Test chain logic against fakes, never a real MapLibre instance** (RESEARCH ESM caveat).
|
||||
|
||||
**Assertion style** (lines 108-116) — descriptive failure strings citing decision IDs:
|
||||
```javascript
|
||||
assert.equal(kv.ttlLog.length, 2, "mint writes exactly two keys");
|
||||
for (const entry of kv.ttlLog) assert.strictEqual(entry.expirationTtl, 900, "TTL-only (PRIV-02)");
|
||||
```
|
||||
`bot/test/location-pin.test.mjs` additionally asserts every put in the new KV module carries `expirationTtl` (extend the `privacy.test.mjs` storageModules list — see the KV section above).
|
||||
|
||||
---
|
||||
|
||||
### `web/test/no-storage.test.mjs` (test, grep gate — GEO-03)
|
||||
|
||||
**Analog:** `bot/test/privacy.test.mjs` — exact same source-introspection genre. Its PG1 gate (lines 31-48) shows the per-line scan + named-constant assertion; `no-storage.test.mjs` is the inverted form (banned-token scan over `web/src/**`):
|
||||
|
||||
**Path scaffolding to copy** (privacy.test.mjs lines 23-27):
|
||||
```javascript
|
||||
const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src");
|
||||
const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/")));
|
||||
```
|
||||
**Gate body style** (lines 34-47) — file:line in the assertion message, the pinned rule in the failure text:
|
||||
```javascript
|
||||
lines.forEach((line, i) => {
|
||||
if (line.includes(".put(")) {
|
||||
assert.ok(
|
||||
line.includes("expirationTtl"),
|
||||
`${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`,
|
||||
);
|
||||
```
|
||||
BANNED list from RESEARCH lines 648-654: `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `document.cookie`, `CacheStorage`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`.
|
||||
|
||||
---
|
||||
|
||||
### `web/test/theme.test.mjs` + `web/test/copy-gate.test.mjs` (tests)
|
||||
|
||||
**Analog:** `bot/test/commands.test.mjs` — pure-function/import-the-module gates with source-introspection for structural claims (header lines 17-19: "structural claims are pinned by reading the shipped source, not by behavior mocks").
|
||||
|
||||
**Copy-gate excerpt** (lines 59-68) — regex assertions on the strings module, both locales:
|
||||
```javascript
|
||||
test("CS3 PRIV-01: privacy copy states the never-facts in both locales", () => {
|
||||
assert.ok(/location/i.test(strings.en.privacyNotStored), "en never-facts must name location");
|
||||
assert.ok(/lokasi/i.test(strings.ms.privacyNotStored), "ms never-facts must name lokasi");
|
||||
```
|
||||
`copy-gate.test.mjs` asserts `web/src/i18n/strings.ts` matches the UI-SPEC table exactly (en+ms) and that shell modules contain no inline copy. `theme.test.mjs` asserts the PX-02 behaviors (dark ⇒ `data-tg-theme="dark"`, luminance fallback, `themeChanged` touches only the attribute).
|
||||
|
||||
---
|
||||
|
||||
### `web/scripts/capture.mjs` (tooling, Playwright)
|
||||
|
||||
**Analog:** `theme/scripts/screenshot.mjs` — extend its established toolchain (static server + chromium + MIN_BYTES blank bar + READY-signal wait + exit-code gate).
|
||||
|
||||
**Blank bar** (lines 44-45):
|
||||
```javascript
|
||||
/* Minimum plausible non-blank PNG (the plan's own blank bar). */
|
||||
const MIN_BYTES = 10240;
|
||||
```
|
||||
**Static server** (lines 64-89) — `serveTheme()` with the docroot-guard (WR-05) is reused nearly as-is with `web/dist` (or `vite preview`) as docroot.
|
||||
**READY-signal race** (lines 148-162):
|
||||
```javascript
|
||||
const ready = new Promise((resolveReady) => {
|
||||
page.on('console', (m) => {
|
||||
if (m.text().startsWith('RAMS-BASEMAP-READY')) resolveReady(true);
|
||||
});
|
||||
});
|
||||
const signal = await Promise.race([ready, new Promise((r) => setTimeout(() => r('timeout'), 30000))]);
|
||||
```
|
||||
**Report + gate** (lines 178-193) — FAILED/MISSING/UNDERSIZED lines, `process.exit(1)` on any failure.
|
||||
**New this phase:** `context.setGeolocation()` + `grantPermissions(['geolocation'])` + `colorScheme` for light/dark captures (Playwright docs, RESEARCH §Environment), the GEO-03 runtime probe (assert `localStorage.length === 0` etc. in-page after boot), and the mock-runner page (D-11). Playwright is already `theme/`'s dependency (`^1.50.0`, browsers cached) — `web/` declares it too or runs via the theme install.
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### 1. Module header comments (all new files)
|
||||
**Source:** every file in `bot/src/` and `theme/src/` — e.g. `bot/src/webhook.ts` lines 1-32.
|
||||
**Apply to:** every `web/src/` and new `bot/src/` file. Format: `filename.ts — one-line purpose (decision IDs).` then a dashed rule, then the contract in prose (what's stored/not stored, what's pinned by which test). This is the repo's strongest convention — the privacy guarantees are *readable from the headers*.
|
||||
|
||||
### 2. Category-only failure vocabulary + JSON error bodies
|
||||
**Source:** `bot/src/webhook.ts` lines 74-79, 99-105; `bot/src/security/initdata.ts` line 27 (`"malformed" | "tampered" | "stale"`).
|
||||
**Apply to:** the `/api/location` route, session client error mapping, location-chain failure states. Never log/echo payloads, ids, or coordinates; rejection bodies are `{error: "<category>"}`.
|
||||
|
||||
### 3. Hand-rolled zero-dependency test runner
|
||||
**Source:** `bot/test/runner.mjs` + `bot/test/harness.mjs` (precedent chain: `theme/scripts/audit.test.mjs`).
|
||||
**Apply to:** all of `web/test/`. `npm test -w web` must behave identically to `npm test -w bot` (PASS/FAIL lines, `[runner] N passed, M failed`, exit code).
|
||||
|
||||
### 4. Source-introspection grep gates
|
||||
**Source:** `bot/test/privacy.test.mjs` (PG1/PG2), `bot/test/commands.test.mjs` (CS4 structural grep).
|
||||
**Apply to:** `web/test/no-storage.test.mjs` (GEO-03), the TTL assertion for the new KV module, the no-inline-copy assertion, and the Pitfall-7 invariant (themeChanged handler's only statement is the attribute set — make it greppable).
|
||||
|
||||
### 5. TTL-on-every-write KV contract
|
||||
**Source:** `bot/src/kv/sessions.ts` lines 79-80 + the PG1 gate that pins it.
|
||||
**Apply to:** `bot/src/kv/location-pin.ts` — every `put` line carries `expirationTtl: <NAMED_CONSTANT>`. Add the module to `privacy.test.mjs`'s scanned list.
|
||||
|
||||
### 6. Rams styling constraints (zero radius, token-only, no blur/shadow)
|
||||
**Source:** `theme/src/components.css` lines 9-15 (the rule), `theme/src/tokens.css` lines 98-127 (radius 0 + `--shadow: initial` etc.), `theme/basemap/index.html` line 87.
|
||||
**Apply to:** `web/src/shell.css`, all `web/src/ui/*`, `web/index.html`. Component classes come from `@araharah/theme/dist/rams.css` (build-time import, D-02) — compose, don't restate.
|
||||
|
||||
### 7. Workspace + delegate-script ergonomics
|
||||
**Source:** root `package.json` lines 5-19, `bot/package.json`, `theme/package.json`.
|
||||
**Apply to:** `web/package.json` + root delegates (`web:dev`, `web:build`, `web:test`, `web:capture`); D-15's one-command root `deploy`.
|
||||
|
||||
### 8. Dev-signing via the independent node:crypto path
|
||||
**Source:** `bot/test/webhook.test.mjs` lines 39-68 (`buildInitData`); algorithm canonicalized in `bot/src/security/initdata.ts` lines 171-183.
|
||||
**Apply to:** `web/src/mock/telegram-mock.ts` — the fake's dev initData is signed with the dev bot token so the real `/api/session` accepts it unmodified.
|
||||
|
||||
### 9. Ops scripts: .dev.vars reading + fail-loud exits
|
||||
**Source:** `bot/scripts/set-webhook.mjs` lines 24-54 (readDevVars, missing-var guard, `process.exit(1)` with non-secret error text).
|
||||
**Apply to:** any new ops script (e.g., a device-gate checklist helper); deploy itself stays `wrangler deploy` (bot/package.json line 9).
|
||||
|
||||
---
|
||||
|
||||
## No Analog Found
|
||||
|
||||
Files with no close codebase match — the planner should use RESEARCH.md's code examples as the pattern source instead:
|
||||
|
||||
| File | Role | Data Flow | Reason | RESEARCH.md source |
|
||||
|------|------|-----------|--------|--------------------|
|
||||
| `web/vite.config.ts` | config | build | No bundler has ever been configured in this repo (mockups are CDN-static; bot/theme have no bundler) | §Code Examples "Vite + MapLibre v6 bootstrap"; `?worker&url` import |
|
||||
| `web/src/location/chain.ts` | service | event-driven | No state machine or location code exists anywhere; this is the phase's new core abstraction | §Pattern 1 (state machine) + §Pattern 2 (stale-fix nudge) — full code given |
|
||||
| `web/src/location/webview.ts` | service | request-response | No `navigator.geolocation` usage in repo | §Pattern 2 (`readWebviewFix` shape) |
|
||||
| `web/src/telegram/backbutton.ts` / `safe-area.ts` / `follow.ts` | utility/component | event-driven | No Telegram client API code exists (bot-side only so far) | §Pattern 4 (version-gating table); UI-SPEC for safe-area/follow details |
|
||||
| `bot/wrangler.jsonc` `[assets]` block | config | — | New Cloudflare config surface ( Workers static assets didn't exist when Phase 2 wrote this file) | RESEARCH cites developers.cloudflare.com/workers/static-assets/binding — `assets.directory`, `assets.binding`, `run_worker_first: ["/api/*", ...]` glob array, `not_found_handling` |
|
||||
|
||||
**Near-misses worth naming (planner should NOT treat as analogs):**
|
||||
- `maplibre-gl` v4 CDN usage in `theme/basemap/index.html` — structural analog only; v6 requires named ESM imports, `setWorkerUrl()`, and WebGL2 handling (RESEARCH §State of the Art).
|
||||
- Mockup inline `onclick` handlers — the anti-pattern D-01 explicitly productionizes away; copy the *markup vocabulary*, never the handler style.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `bot/src/**`, `bot/test/**`, `bot/scripts/**`, `theme/src/**`, `theme/scripts/**`, `theme/basemap/**`, `theme/dist/`, root config files, `stitch_minimalist_route_navigator/navigate_map_overview/code.html`. `.planning/phases/0{1,2}-*/` CONTEXT files consulted for cross-phase contracts (theme package D-04 slots, /api/session contract).
|
||||
**Files scanned:** 24 source/config files read; ~15 grep/glob passes over `web/`-shaped patterns (confirmed absent).
|
||||
**Key confirmed-absent surfaces:** no `web/` directory, no bundler config, no frontend TypeScript, no `navigator.geolocation`/Telegram-WebApp client code, no `[assets]` wrangler config — hence the four No-Analog rows.
|
||||
**Pattern extraction date:** 2026-10-22
|
||||
@@ -0,0 +1,900 @@
|
||||
# Phase 3: Mini App Shell + Geolocation - Research
|
||||
|
||||
**Researched:** 2026-10-22
|
||||
**Domain:** Telegram Mini App platform integration, WebView geolocation, MapLibre GL JS v6, Vite build, Cloudflare Workers static assets
|
||||
**Confidence:** HIGH for platform API surface (official Telegram + MapLibre + Cloudflare docs fetched this session); MEDIUM for the Android stale-fix workaround shape (community-sourced, unverified on device)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Mini App stack + repo layout**
|
||||
- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
|
||||
- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
|
||||
- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
|
||||
|
||||
**Location acquisition strategy**
|
||||
- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
|
||||
- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
|
||||
- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
|
||||
- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
|
||||
|
||||
**Shell UX + screens**
|
||||
- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
|
||||
- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
|
||||
- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
|
||||
|
||||
**Dev/test harness**
|
||||
- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
|
||||
- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
|
||||
|
||||
**Worker static-asset wiring**
|
||||
- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
|
||||
- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
|
||||
- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
|
||||
- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
|
||||
- `assets` config tuning (binding name, not_found_handling, asset prefixes)
|
||||
- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
|
||||
- Placeholder-shaped search drawer / route furniture — later phases by design
|
||||
- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| **GEO-01** | The Mini App can obtain the user's current location via Telegram LocationManager, with permission persisting across sessions | `LocationManager.init()` → `getLocation()` gate (`isAccessGranted` persists per-bot across sessions — the API's `openSettings()` path is the manual re-grant). **Version floor Bot API 8.0.** §"The definitive LocationManager API" + §"Android stale-fix" + §"Version gating floor". **Critical:** `getLocation()` is one-shot; there is **no** `getLiveData`/`startLocationUpdates` in the official API — see §"getLiveData does not exist" |
|
||||
| **GEO-02** | When in-app location fails (denied permissions or Desktop Telegram without geolocation), the chat "send location" keyboard fallback obtains the origin | `KeyboardButton.request_location: true` in a `ReplyKeyboardMarkup` → user shares a `Location` message → bot receives it in an update. Desktop geolocation is confirmed broken (issue #77). §"The chat-keyboard fallback mechanism (GEO-02)" + §"Desktop Telegram has no geolocation at all" |
|
||||
| **GEO-03** | The user's location is never written to persistent storage — only ephemeral TTL server state exists during the active trip | Zero-persistence is enforceable and *testable*: Vite emits no service worker by default; MapLibre's tile cache is `CacheStorage`/HTTP-cache (transient, and tile URLs are not coordinates). §"GEO-03 is enforceable, not aspirational" — includes the grep gate + runtime probe design. Confirms D-03's localStorage caution (`localStorage` may not even persist in the WebView) |
|
||||
| **PX-02** | The app follows Telegram light/dark themes for chrome while the map keeps the Rams cartography constant | `WebApp.colorScheme` (`"light"`/`"dark"`) + `themeChanged` event. Phase 1's `--shell-bg`/`--shell-on-surface` slots are the switch target. Map is *never* re-styled. §"PX-02 theme mapping — the map is untouched" |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 3 is the project's highest platform-integration risk, and this research surfaces **four findings that materially change the plan**:
|
||||
|
||||
1. **`getLiveData()` does not exist.** The roadmap note (`.planning/ROADMAP.md:91`) asks research to find a "`getLiveData()` wrapper" for the Android stale-fix. There is no such method in the official Telegram WebApp API. `LocationManager` has exactly `init()`, `getLocation(callback)`, `openSettings()`, and two events (`locationManagerUpdated`, `locationRequested`). D-05's "gated streaming" must therefore be built on top of either repeated `getLocation()` polling or `navigator.geolocation.watchPosition()` — the planner must choose explicitly and Phase 6 must not assume a native streaming primitive exists.
|
||||
|
||||
2. **The Android stale-fix bug is real, confirmed, and unfixed.** [Telegram-Mini-Apps issue #56](https://github.com/Telegram-Mini-Apps/issues/issues/56) — opened Dec 2024, still open, labeled `bug` + `in progress` + `Telegram for Android`, with 6 independent confirmations and the reporter concluding "This issue renders the new LocationManager api useless." The reported workaround: **`navigator.geolocation.getCurrentPosition()` inside the WebView refreshes the same fix** that a later `LocationManager.getLocation()` then returns. This inverts D-04's naive reading — the chain is not "try Telegram, then browser"; it is "Telegram first, browser to nudge it."
|
||||
|
||||
3. **Desktop Telegram has no geolocation, confirmed.** [Issue #77](https://github.com/Telegram-Mini-Apps/issues/issues/77) (open, Apr 2025 → Jan 2026) reports no permission prompt at all on Telegram Desktop/Windows/Mac/Linux, with a second reporter confirming in Sep 2025 that the geolocation button is *greyed out*. GEO-02's chat-keyboard fallback is therefore the *only* origin path on Desktop — not a degraded backup. This strengthens D-06's "no client-specific variant" copy rule: the Desktop experience must not read as an error state.
|
||||
|
||||
4. **MapLibre v6 is ESM-only and WebGL2-only — this breaks the repo's existing test runner shape.** `maplibre-gl@6.15.0` ships `exports: { ".": { import: "./dist/maplibre-gl.mjs" } }` with **no `require` condition** (verified against the npm registry). Any `.mjs` test that `import`s maplibre through the hand-rolled runner is fine, but any CJS-style `require()` or an untranspiled tool will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Separately, **WebGL2 is now required** and the `Map` constructor throws `GPUInitializationError` when unavailable — the planner needs a deliberate fallback for that, and the Phase-1 Playwright capture toolchain (chromium-1217/1248 cached) is WebGL2-capable so the harness is fine.
|
||||
|
||||
**Bonus finding — a live security constraint nobody has in the docs:** Bot API **10.2** (July 14, 2026, auto-enabled July 20, 2026) *"disallowing the usage of Mini App methods from origins different from the original Mini App domain."* This is now in force. D-11's Mock-Telegram harness and D-12's `wrangler dev` on `localhost:8787` are fine (same origin), but it means the app must never call `Telegram.WebApp.*` from an iframe, a partner domain, or a secondary origin — and BotFather's registered URL is the authority.
|
||||
|
||||
**Primary recommendation:** Ship the location chain as a single `locationSource` state machine with three named sources (`telegram` / `webview` / `chat-pin`), each returning the same `Fix` shape, selected by an explicit capability probe — and treat "Telegram fix is stale" as a *recoverable* condition that fires the `navigator.geolocation` nudge before falling through, rather than as success.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Telegram WebApp API access (initData, theme, BackButton, safe-area, LocationManager) | Frontend Server (browser runtime) | — | `window.Telegram.WebApp` only exists inside the WebView; nothing server-side can read it. All gating (`isVersionAtLeast`, `colorScheme`, `isAccessGranted`) is client-side. |
|
||||
| Session mint + validation | API / Backend (Worker) | Frontend Server | Phase 2 already owns `/api/session`. The app only *posts* `initData` and holds the token in memory (D-03). |
|
||||
| Chat-keyboard location fallback (GEO-02) | API / Backend (Worker) | Frontend Server | The bot must send the `ReplyKeyboardMarkup` and receive the `Location` message via webhook. The app can only *request* the fallback and *display* the waiting state. |
|
||||
| Fix normalization (`LocationData` vs `GeolocationPosition` vs bot `Location`) | Frontend Server | API / Backend | Three sources, three shapes, one internal `Fix`. Normalization is a client concern; the bot's `Location` message must be relayed to the app via an authenticated endpoint (bot can't push to the WebView). |
|
||||
| Map rendering + camera + marker | Frontend Server (MapLibre) | CDN / Static (tiles) | MapLibre runs in the page; tiles come from OpenFreeMap at runtime (D-14). Style JSON is inlined at build time. |
|
||||
| Theme slot switching (PX-02) | Frontend Server | — | `colorScheme` + `themeChanged` are client events. Backend never sees them. |
|
||||
| Safe-area / viewport | Frontend Server | — | `env(safe-area-inset-*)` + `WebApp.safeAreaInset` are both client-read CSS/JS values. |
|
||||
| BackButton wiring | Frontend Server | — | `WebApp.BackButton.show()/hide()/onClick()` is client-only. Phase 3 ships the wire; Phase 6 extends the state graph. |
|
||||
| Coordinate persistence policy | API / Backend (KV TTL) + Frontend Server (memory only) | — | D-07 splits it: the *client* must write nothing (verifiable by grep + runtime probe); the *server* keeps only TTL-expiring session claims (already built in Phase 2). |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `maplibre-gl` | **6.15.0** (published 2026-10-18) | Vector map renderer for the Rams basemap | D-01 locks MapLibre GL JS v6. Zero license cost (BSD-3), no Mapbox token. `theme/basemap/liberty-rams.json` is already a v8 style spec built for it. |
|
||||
| `vite` | **8.3.5** (published 2026-10-21) | Dev server + production bundler | D-01 locks Vite. v8 requires `node: ^20.19.0 \|\| >=22.12.0` — the repo runs Node **24.18.0**, satisfied. v8 handles the `?worker&url` MapLibre worker import natively. |
|
||||
| `typescript` | **5.9.3** (pinned in `bot/package.json`) | Type safety for `web/` | Already a workspace dependency; matching the bot's pin avoids workspace version skew. *Note:* npm registry shows `typescript@7.0.2` as latest — do **not** upgrade the shared pin as a side effect of this phase. |
|
||||
| `@araharah/theme` | workspace | Rams CSS tokens + component classes | D-02 locks build-time consumption of `theme/dist/rams.css`. Component classes `.btn-primary`, `.btn-secondary`, `.card`, `.text-label-sm` etc. are **verified present** in `theme/src/components.css`. |
|
||||
| `@telegram-apps/types` (or hand-written `.d.ts`) | see note | TypeScript types for `window.Telegram.WebApp` | **`@types/telegram-web-app@10.1.0` exists** (published 2026-10-04) and is the only viable npm option — `telegram-web-app`, `telegram-web-app.js`, `@telegram-apps/web-app` are all dead ends (E404 / abandoned 2022). Verify its `LocationManager` typings match the official docs before committing; otherwise hand-write a narrow interface for the ~12 members this phase uses. **[MEDIUM confidence]** |
|
||||
|
||||
### Supporting
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `wrangler` | **4.151.3** (already in `bot/`) | Local dev on 8787 + deploy with `[assets]` | D-12/D-13/D-15. The `assets` binding config is documented and supported at this version. |
|
||||
| `playwright` | already in `theme/` (`^1.50.0`, browsers cached: chromium-1248, webkit-2272, firefox-1511) | Mock-Telegram harness captures + exit-gate device matrix | D-11 + the DSGN-02 fidelity artifact. Playwright's `context.setGeolocation()` + `grantPermissions(['geolocation'])` are the clean way to drive the harness's location stream. |
|
||||
| `grammy` | `^1.46.0` (already in `bot/`) | Receiving the `Location` message for GEO-02 | Already the bot's framework; a `bot.on('message', ...)` handler filtered on `message.location` is the whole mechanism. |
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `maplibre-gl` built-in `GeolocateControl` | Custom follow/recenter control | **Reject.** GeolocateControl renders its own chrome (violates D-08's map-only surface) and owns its own geolocation strategy, which would fight D-04's chain. The UI-SPEC specifies a custom 40×40 machined control. |
|
||||
| `maplibre-gl` `Marker` | GeoJSON source + circle layers | **Either works.** `Marker` gives exact DOM control over the accuracy ring + heading cone (the UI-SPEC's geometry is DOM/SVG-shaped: 20px dot, 16px cone). A GeoJSON source is better for Phase 5's route line. **Recommendation:** `Marker` for the position group in Phase 3; the route line (Phase 5) will use a GeoJSON source. They coexist. |
|
||||
| A third-party Telegram SDK (`@telegram-apps/sdk`) | Direct `window.Telegram.WebApp` calls | **Reject.** D-01 says no framework; the SDK is a React-oriented abstraction layer. Direct calls + a thin typed wrapper keep the bundle small and the platform surface auditable — which matters because the location chain *is* the phase. |
|
||||
| `vitest` / `jest` | Existing hand-rolled `test/runner.mjs` | **Reject for unit tests** (repo convention, zero deps, proven in `theme/scripts/audit.test.mjs` + `bot/test/`). **But see the ESM caveat in §"Don't Hand-Roll"** — maplibre-gl has no `require` export. |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# from repo root — add "web" to workspaces, then:
|
||||
npm install -w web maplibre-gl@^6.15.0
|
||||
npm install -w web -D vite@^8.3.5 @types/telegram-web-app@^10.1.0
|
||||
```
|
||||
Root `package.json` gains `"web"` in `workspaces` plus delegate scripts (`web:dev`, `web:build`, `web:test`, `web:capture`) mirroring the existing `bot:*` pattern (D-02).
|
||||
|
||||
**Version verification (run this session, `npm view` against the live registry):**
|
||||
| Package | Version | `time.modified` |
|
||||
|---------|---------|-----------------|
|
||||
| `maplibre-gl` | 6.15.0 | 2026-10-18 |
|
||||
| `vite` | 8.3.5 | 2026-10-21 |
|
||||
| `typescript` | 7.0.2 (latest) — **repo pins 5.9.3** | 2026-10-22 |
|
||||
| `@types/telegram-web-app` | 10.1.0 | 2026-10-04 |
|
||||
| `wrangler` (installed) | 4.151.3 | — |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TELEGRAM CLIENT (Android / iOS / Desktop) │
|
||||
│ │
|
||||
│ WebView ── window.Telegram.WebApp ── │
|
||||
│ • initData ──────────┐ │
|
||||
│ • colorScheme ──┐ │ │
|
||||
│ • LocationManager│ │ │
|
||||
│ • BackButton ──┼───┼── (client-only APIs) │
|
||||
│ • safeAreaInset│ │ │
|
||||
└────────────────────┼───┼───────────────────────────────────────┘
|
||||
│ │
|
||||
┌────────────┘ └──────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────┐ ┌──────────────────────────┐
|
||||
│ web/ (Vite SPA) │ │ bot/ (Worker) │
|
||||
│ │ │ │
|
||||
│ 1. boot shell │ │ POST /telegram/webhook │
|
||||
│ staged states │ │ ├─ /start /help /privacy│
|
||||
│ 2. POST /api/session │──▶│ POST /api/session │
|
||||
│ initData → token │ │ validate HMAC │
|
||||
│ (token = memory) │ │ mint opaque token │
|
||||
│ 3. location chain │ │ KV put TTL=900 │
|
||||
│ ┌──────────────┐ │ │ │
|
||||
│ │ telegram │ │ │ GEO-02 fallback: │
|
||||
│ │ LocationMgr │ │ │ sendMessage( │
|
||||
│ │ .getLocation│ │ │ reply_keyboard:[ │
|
||||
│ │ ↓ null / │ │ │ {text:"Share my │
|
||||
│ │ ↓ STALE │ │ │ location", │
|
||||
│ │ ↓ (platform │ │ │ request_location: │
|
||||
│ │ gating) │ │ │ true}]}) │
|
||||
│ ├──────────────┤ │ │ ← receives message │
|
||||
│ │ webview │ │ │ .location │
|
||||
│ │ navigator. │ │ │ write to KV (TTL) │
|
||||
│ │ geolocation │ │ │ │
|
||||
│ │ (THE NUDGE │ │ └──────────┬───────────────┘
|
||||
│ │ that │ │ │
|
||||
│ │ refreshes │ │ │ GET /api/location
|
||||
│ │ the stale │ │ │ (token-auth'd,
|
||||
│ │ telegram │ │ │ TTL-ephemeral)
|
||||
│ │ fix) │ │ ▼
|
||||
│ ├──────────────┤ │ ┌──────────┐
|
||||
│ │ chat-pin │◀──┼────────│ app │ ← D-04 link 3
|
||||
│ │ (awaiting) │ │ poll │ polls │
|
||||
│ └──────────────┘ │ └──────────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ normalize → Fix │
|
||||
│ {lat,lng,acc,heading,ts,source}
|
||||
│ │
|
||||
│ ├──────────────▶ MapLibre (Rams style, INLINED at build)
|
||||
│ │ • Marker: dot + cone + accuracy ring
|
||||
│ │ • camera: follow / free (96px padding)
|
||||
│ │ • style NEVER changes on theme flip
|
||||
│ ▼
|
||||
│ status strip + boot card + denied card
|
||||
│ (chrome re-slots on themeChanged; map does not)
|
||||
│
|
||||
│ RUNTIME (never touched): localStorage · sessionStorage · cookies
|
||||
│ IndexedDB · CacheStorage · SW
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── package.json # workspace: @araharah/web
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts # base:'/', build.outDir:'dist'
|
||||
├── index.html # viewport-fit=cover meta (UI-SPEC safe-area)
|
||||
├── public/ # (empty or minimal — nothing static ships)
|
||||
├── src/
|
||||
│ ├── main.ts # boot entry: mount map → session → location
|
||||
│ ├── shell.css # ONLY var(--color-*) / var(--spacing-*) refs
|
||||
│ ├── telegram/
|
||||
│ │ ├── webapp.ts # typed window.Telegram.WebApp accessor + isVersionAtLeast gate
|
||||
│ │ ├── theme.ts # colorScheme → data-tg-theme (PX-02)
|
||||
│ │ ├── backbutton.ts # D-10 wire; total fn (no dead-ends)
|
||||
│ │ └── safe-area.ts # env() ∪ WebApp.safeAreaInset, per-edge max
|
||||
│ ├── session/
|
||||
│ │ ├── client.ts # POST /api/session; token in MODULE SCOPE (D-03)
|
||||
│ │ └── store.ts # in-memory only; TTL staleness check
|
||||
│ ├── location/
|
||||
│ │ ├── types.ts # Fix {lat,lng,acc,heading?,ts,source}
|
||||
│ │ ├── telegram.ts # LocationManager.init/getLocation + staleness detect
|
||||
│ │ ├── webview.ts # navigator.geolocation getCurrentPosition/watchPosition
|
||||
│ │ ├── chat-pin.ts # poll the bot-side pin endpoint
|
||||
│ │ └── chain.ts # the D-04 state machine (three sources)
|
||||
│ ├── map/
|
||||
│ │ ├── map.ts # MapLibre Map, Rams style, setWorkerUrl once
|
||||
│ │ ├── marker.ts # dot + cone + accuracy ring (DOM Marker)
|
||||
│ │ └── follow.ts # follow/free toggle, 96px camera padding
|
||||
│ ├── ui/
|
||||
│ │ ├── strip.ts # status strip (lamp + label + telemetry)
|
||||
│ │ ├── cards.ts # boot / denied / signal-lost cards
|
||||
│ │ └── recenter.ts # 40×40 machined control
|
||||
│ ├── i18n/
|
||||
│ │ └── strings.ts # en + ms, UI-SPEC copy table verbatim (Copy Gate)
|
||||
│ └── mock/
|
||||
│ └── telegram-mock.ts # D-11 fake WebApp (dev/build-tagged only)
|
||||
├── test/
|
||||
│ ├── runner.mjs # hand-rolled (repo precedent)
|
||||
│ ├── harness.mjs
|
||||
│ ├── location-chain.test.mjs # GEO-01/02 chain with injected fake WebApp
|
||||
│ ├── no-storage.test.mjs # GEO-03 grep + AST gate
|
||||
│ └── copy-gate.test.mjs # strings.ts matches UI-SPEC table
|
||||
└── scripts/
|
||||
└── capture.mjs # extends theme/scripts/screenshot.mjs pattern
|
||||
```
|
||||
|
||||
### Pattern 1: The Location Chain as an Explicit State Machine
|
||||
|
||||
**What:** Three named sources, one `Fix` output type, one selection function. Every failure mode is a *state*, never an exception that escapes to a blank screen.
|
||||
|
||||
**When to use:** Always — this is the phase's core abstraction and Phases 5/6 both consume it (Phase 5 snaps routes to fixes; Phase 6's guidance loop drives the watch cadence).
|
||||
|
||||
**The three sources and their real behavior:**
|
||||
|
||||
```typescript
|
||||
// Source 1: Telegram-native (GEO-01 primary path)
|
||||
export type Fix = {
|
||||
lat: number; lng: number;
|
||||
accuracyM: number | null; // horizontal_accuracy | coords.accuracy
|
||||
headingDeg: number | null; // course | coords.heading — null when unknown
|
||||
ts: number; // ms epoch, for FIX AGE telemetry
|
||||
source: 'telegram' | 'webview' | 'chat-pin';
|
||||
};
|
||||
|
||||
// Version gate FIRST — LocationManager is Bot API 8.0+.
|
||||
// Do not feature-detect by truthiness alone: an old client exposes
|
||||
// `WebApp.LocationManager` as undefined, but a client that has the
|
||||
// field with isLocationAvailable=false is a DIFFERENT failure.
|
||||
export function telegramLocationSupported(wa: WebApp): boolean {
|
||||
return typeof wa.isVersionAtLeast === 'function' && wa.isVersionAtLeast('8.0');
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Source 1 continued — init() then getLocation().
|
||||
// getLocation's callback receives `null` when access was NOT granted;
|
||||
// there is no error object, so the null case is the whole denial class.
|
||||
export function requestTelegramFix(wa: WebApp, timeoutMs = 8000): Promise<Fix | null> {
|
||||
return new Promise((resolve) => {
|
||||
const lm = wa.LocationManager;
|
||||
if (!lm) return resolve(null);
|
||||
|
||||
const finish = (d: unknown | null) => {
|
||||
if (!d) return resolve(null); // ← not granted, or unavailable
|
||||
const loc = d as LocationData;
|
||||
resolve({
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
accuracyM: loc.horizontal_accuracy ?? null,
|
||||
headingDeg: loc.course ?? null, // null ⇒ UI-SPEC hides the cone
|
||||
ts: Date.now(),
|
||||
source: 'telegram',
|
||||
});
|
||||
};
|
||||
|
||||
// init() is REQUIRED before first use (official docs).
|
||||
if (lm.isInited) return lm.getLocation(finish);
|
||||
lm.init(() => lm.getLocation(finish));
|
||||
|
||||
// getLocation has no documented timeout — add one, or the boot
|
||||
// choreography hangs in stage 2 forever on a wedged client.
|
||||
setTimeout(() => resolve(null), timeoutMs);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Why this shape:** `getLocation` is documented as *"called with `null` as the first argument if access to location was not granted"* — there is no rejection path and no error code. Every caller must therefore treat `null` as "could not get a fix, try the next source," and the chain must have a hard timeout because the API has none. **[CITED: core.telegram.org/bots/webapps §LocationManager]**
|
||||
|
||||
### Pattern 2: Android Stale-Fix Recovery (the "nudge")
|
||||
|
||||
**What:** On Android, `LocationManager.getLocation()` returns a *static* coordinate that does not refresh until some other app triggers a location update. Calling `navigator.geolocation.getCurrentPosition()` inside the same WebView forces a refresh.
|
||||
|
||||
**When to use:** Only as a *recovery* path after a Telegram fix is detected stale. Not as the primary source.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Android: LocationManager.getLocation() returns a cached, non-refreshing
|
||||
* fix (Telegram-Mini-Apps issue #56, open since Dec 2024).
|
||||
* Detecting it: compare two successive telegram fixes. A device that has
|
||||
* MOVED but returns byte-identical coordinates is the signature.
|
||||
*/
|
||||
export function looksStale(prev: Fix | null, next: Fix): boolean {
|
||||
if (!prev) return false;
|
||||
const movedMeters = haversineMeters(prev, next);
|
||||
// Identical to sub-centimetre precision ⇒ the cache never refreshed.
|
||||
return movedMeters < 0.01;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// The nudge: a single webview read refreshes the native cache, so the
|
||||
// NEXT telegram read returns fresh coordinates.
|
||||
export async function nudgeAndRetryTelegram(
|
||||
wa: WebApp,
|
||||
): Promise<Fix | null> {
|
||||
const first = await requestTelegramFix(wa);
|
||||
if (!first) return null;
|
||||
if (!looksStale(lastAccepted, first)) return first;
|
||||
|
||||
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result discarded
|
||||
return requestTelegramFix(wa); // now fresh (issue #56)
|
||||
}
|
||||
```
|
||||
|
||||
**Honest caveat — this is MEDIUM confidence.** The mechanism is community-reported and reproduced by 6+ developers, but **it has not been verified on a real device in this project.** The planner should:
|
||||
- Implement it behind the chain (it degrades to "keep polling telegram," never to a crash), and
|
||||
- Make the device-matrix exit gate (§Success Criterion 5) the place where it is *proven*, with an explicit note that a phone's Location settings may need to be toggled if the first Android verification shows a still-static fix.
|
||||
|
||||
### Pattern 3: The Chat-Keyboard Fallback (GEO-02)
|
||||
|
||||
**What:** The bot sends a `ReplyKeyboardMarkup` with `request_location: true`. The user taps it, Telegram's native location picker opens, and the bot receives a `Message` with a `location` field.
|
||||
|
||||
**Bot side** (worker, grammY):
|
||||
```typescript
|
||||
// KeyboardButton.request_location — available in private chats only.
|
||||
bot.chat.sendMessage(chatId, strings.awaitingPin, {
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: strings.shareLocationButton, request_location: true }]],
|
||||
resize_keyboard: true, // ← one tap to the native picker
|
||||
one_time_keyboard: true, // ← dismisses after use; keeps the chat clean
|
||||
},
|
||||
});
|
||||
|
||||
// The bot then receives the shared pin as a normal message:
|
||||
bot.on('message', (ctx) => {
|
||||
const loc = ctx.message?.location; // Location { latitude, longitude,
|
||||
if (!loc) return; // horizontal_accuracy?, live_period? }
|
||||
// …write to TTL-ephemeral KV keyed by the session token…
|
||||
});
|
||||
```
|
||||
|
||||
**App side:** the Mini App **cannot** receive the pin directly — Telegram has no push channel from chat → WebView. The app must poll an authenticated endpoint (`GET /api/location`, `Authorization: Bearer <memory token>`), which reads the same TTL-ephemeral KV. This endpoint is a **new Worker route this phase must add** — it does not exist in Phase 2.
|
||||
|
||||
**[CITED: core.telegram.org/bots/api §KeyboardButton — "If True, the user's current location will be sent when the button is pressed. Available in private chats only."; §Location — `latitude`, `longitude`, `horizontal_accuracy?`, `live_period?`, `heading?`]**
|
||||
|
||||
**Design consequence for D-06:** the UI-SPEC's `AWAITING PIN` strip state and "Waiting for the pin you send in chat" card body are exactly right — the wait is genuinely server-mediated, and the app must poll rather than assume instant delivery. Set a bounded poll (e.g. 60s, then back to the denied card with the hint re-shown) so the UI can never wedge on `AWAITING PIN`.
|
||||
|
||||
### Pattern 4: Version-Gated Capability Probes
|
||||
|
||||
**What:** Every Telegram API this phase touches is version-gated. Gate with `isVersionAtLeast()` and give each gated feature a working fallback.
|
||||
|
||||
| Feature | Bot API | Fallback if unavailable |
|
||||
|---------|---------|--------------------------|
|
||||
| `LocationManager` | 8.0 | `navigator.geolocation` → chat-pin |
|
||||
| `safeAreaInset` / `contentSafeAreaInset` | 8.0 | pure `env(safe-area-inset-*)` (UI-SPEC already specifies this) |
|
||||
| `colorScheme` | always present | luminance test on `themeParams.header_bg_color` (UI-SPEC already specifies this) |
|
||||
| `BackButton` | 6.1 | hidden; native swipe-down close remains the exit gesture |
|
||||
| `themeChanged` event | 6.0 | n/a (event support is broad) |
|
||||
|
||||
**The one non-negotiable:** `isAccessGranted` is what makes GEO-01's "permission persists across sessions" true — Telegram stores the grant **per bot, on the device**, outside the WebView's storage. That is exactly why D-03's memory-only token and D-07's zero-persistence do not conflict with a persistent permission: **the grant lives on Telegram's side of the boundary, not in the page.** The planner should state this explicitly in the GEO-01 verification notes, because it is the subtlest point in the phase.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Blank map while the chain runs.** D-09 requires the basemap to mount and render tiles *immediately*, with boot furniture overlaid. Mount the map first, start the chain second. The reverse order produces the exact blank screen the UI-SPEC forbids.
|
||||
- **Swapping the style JSON on theme flip.** D-10 / PX-02: the map is Rams-constant. A `map.setStyle()` on `themeChanged` would destroy and rebuild all 110 layers and flash — it also violates "zero re-styling, zero re-creation" in the UI-SPEC's mapping table. Only `data-tg-theme` on `<html>` changes.
|
||||
- **Treating `null` from `getLocation()` as an error to surface.** It is the *normal* denial signal. There is no error object and no code. Branch on `null`, not on exceptions.
|
||||
- **Calling `LocationManager.init()` more than once, or using before init.** Official docs: "Before the first use of this object, it needs to be initialized." Guard on `isInited`.
|
||||
- **Assuming a polling interval delivers live location on Android.** It does not — that is issue #56. Without the nudge (or without accepting staleness honestly), a "LIVE" strip on Android may be showing a frozen pin. **The strip's `FIX Ns AGO` telemetry is the honesty mechanism**; make it honest rather than assuming freshness.
|
||||
- **Storing the fix "just for convenience"** in `localStorage` to survive a webview revival. That is exactly what D-07 forbids and D-03 warns about — and it would not even work reliably (see §"localStorage is unreliable in the WebView").
|
||||
- **Adding a service worker** for offline tiles. Vite emits none by default; PWA work is explicitly out of scope; and a SW cache is a GEO-03 audit surface for zero benefit this phase.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Telegram WebApp types | A 500-line ambient `.d.ts` | `@types/telegram-web-app@10.1.0`, narrowed by a thin local wrapper | The full `WebApp` interface is large and versioned; the package tracks Bot API 10.x. Wrap it in `web/src/telegram/webapp.ts` so a types mismatch is one file to fix. **[MEDIUM]** — verify its `LocationManager` shape against the official docs before relying on it. |
|
||||
| MapLibre worker plumbing | Copying `maplibre-gl-worker.mjs` into `public/` and hand-maintaining a URL | Vite's `?worker&url` query | The docs are explicit: for bundlers (Vite included), `import.meta.url` does not resolve the worker, so a one-time `setWorkerUrl()` is required. Vite's query does it correctly and hash-caches it for free. |
|
||||
| A geolocation state machine from scratch | A bespoke event emitter + retry/timeout plumbing | A single explicit `chain.ts` with three named branches | The state space is small and *known* (verified sources: telegram / webview / chat-pin). A generic abstraction would obscure exactly the platform-specific behavior that is the point of this phase. |
|
||||
| A test framework | Adding vitest/jest | The existing hand-rolled `runner.mjs` + `harness.mjs` | Repo convention across `bot/test/` and `theme/scripts/audit.test.mjs`; zero dependencies; proven. **Caveat below.** |
|
||||
| Battery-farming in dev | Real phones in the daily loop | The Mock-Telegram harness (D-11); phones at the exit gate | D-11 already locks this. Device time is expensive and the harness covers ~90% of the loop. |
|
||||
| HTTP polling | WebSockets / SSE for the chat-pin | Bounded `fetch` polling | Cloudflare Workers + Durable Objects are not in the v1 stack (AGENTS.md: ops-stack stripped). A bounded poll against the TTL-ephemeral KV endpoint is the honest v1 answer. |
|
||||
|
||||
**⚠️ Key insight — the ESM trap in our own test runner.** `maplibre-gl@6.15.0` publishes:
|
||||
```json
|
||||
"exports": { ".": { "types": "./dist/maplibre-gl.d.ts", "import": "./dist/maplibre-gl.mjs" } }
|
||||
```
|
||||
There is **no `require` condition** (verified against the npm registry this session). The repo's test runner is `node test/runner.mjs` doing `await import(...)` on `.mjs` files with `"type": "module"` — **that is fine**, dynamic `import()` resolves the `import` condition. What breaks is any CJS `require('maplibre-gl')` or a tool that doesn't transform ESM. The planner must state this in the plan so an executor doesn't lose an hour to `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
||||
|
||||
**Second key insight — don't trust `localStorage` even if you wanted it.** Independently reported: inside Telegram's WebView, `localStorage` *may not persist at all* on iOS and some Linux desktop builds, so a restart can clear it. This is independent corroboration of D-03's memory-only choice: it isn't only the privacy rule, it's the only choice that actually behaves predictably. **[MEDIUM confidence — community source, not official docs.]**
|
||||
|
||||
---
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
> Phase 3 is **not** a rename/refactor/migration phase, so this section is included only for the GEO-03 zero-persistence audit — the phase's own "runtime state" question.
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| **Stored data** | Client: nothing may be written (D-07). Server: `chat_sessions` KV already TTL-only at 900s (verified in `bot/src/kv/sessions.ts`, every `put` carries `expirationTtl`). **New this phase:** a location record for the chat-pin path — must carry `expirationTtl` on its own `put` line, subject to the existing `privacy.test.mjs` grep gate. | Code edit + a new grep-gate assertion that every `put` in the location module carries an explicit TTL. |
|
||||
| **Live service config** | `wrangler.jsonc` `kv_namespaces` binding `chat_sessions` (id `59f58a54…`) — unaffected. BotFather's registered Mini App URL is now security-critical under Bot API 10.2's origin restriction. | None for KV. **Verify** the BotFather URL points at the same origin the app is served from. |
|
||||
| **OS-registered state** | **Telegram's per-bot location grant** lives here (inside the Telegram client, keyed by bot + device). This is the mechanism GEO-01's "permission persists across sessions" depends on. `LocationManager.openSettings()` is the deep-link to re-grant. | None — do not attempt to clear it. It is the feature, not the bug. |
|
||||
| **Secrets / env vars** | `BOT_TOKEN`, `WEBHOOK_SECRET` (via `bot/.dev.vars`). The app needs **no** secrets — it holds an opaque session token issued by the Worker, not a bot token. | None. Confirm the app never receives `BOT_TOKEN` (it cannot and must not). |
|
||||
| **Build artifacts** | `web/dist/` is new. `theme/dist/rams.css` + `theme/dist/fonts/**` are copied at build time (D-14). MapLibre's worker is emitted by Vite's `?worker&url` into a hashed file. | Add `web/dist/` to `.gitignore` (mirroring `theme/screenshots/`). |
|
||||
|
||||
**Nothing found in category:** No pre-existing client-side persistence to migrate — the repo has no `web/` directory yet, so GEO-03 starts from a clean slate. The audit surface is *"prove nothing gets added."*
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Assuming a native streaming location primitive exists
|
||||
|
||||
**What goes wrong:** The planner writes `LocationManager.getLiveData()` or `startLocationUpdates()` because the roadmap note mentions "`getLiveData()` wrapper." Neither method exists in the official API — the plan's core module would be dead on arrival, or worse, silently undefined.
|
||||
|
||||
**Why it happens:** The roadmap's research recommendation was written from an assumption, not from the API docs.
|
||||
|
||||
**How to avoid:** Treat D-05's "gated streaming" as *our* abstraction. Phase 3 ships one-shot `getLocation()` calls; Phase 6's continuous mode is either a bounded poll over `getLocation()` or `navigator.geolocation.watchPosition()` — **the planner must pick and write it down, because it changes Phase 6.**
|
||||
|
||||
**Warning signs:** Any plan task naming a `LocationManager` member that is not `init`, `getLocation`, `openSettings`, `isInited`, `isLocationAvailable`, `isAccessRequested`, or `isAccessGranted`.
|
||||
|
||||
### Pitfall 2: Treating the D-04 chain as strictly sequential and terminal
|
||||
|
||||
**What goes wrong:** Code does `telegram → webview → chat-pin`, first-success-wins. On Android, `getLocation()` *succeeds* with a frozen coordinate, so the chain never advances to `navigator.geolocation`, and the app shows a confidently-wrong `LIVE` pin that never moves.
|
||||
|
||||
**Why it happens:** `getLocation()` returning data is not evidence that the data is fresh.
|
||||
|
||||
**How to avoid:** Freshness is a separate axis from availability. Compare successive fixes (Pattern 2) and route staleness into the recovery path, not into success. The `FIX Ns AGO` strip telemetry is the user-visible honesty surface for this.
|
||||
|
||||
**Warning signs:** No code path anywhere computes the age of the current fix.
|
||||
|
||||
### Pitfall 3: `getLocation()` with no timeout hangs the boot choreography
|
||||
|
||||
**What goes wrong:** The WebView is wedged, `init()`'s callback never fires, and the app sits in `ACQUIRING` forever — the exact "never blank, always progressing" contract (D-09) is violated by an indefinite stage.
|
||||
|
||||
**Why it happens:** Telegram documents no timeout for `init()` or `getLocation()`, and both are callback-based with no error channel.
|
||||
|
||||
**How to avoid:** Every `getLocation` wrapper carries its own `setTimeout` that resolves `null` (→ next source). The UI-SPEC's `elapsedSeconds` telemetry in the `acquiring` strip state is the affordance that makes a long wait legible — use it.
|
||||
|
||||
### Pitfall 4: Desktop Telegram treated as an error state
|
||||
|
||||
**What goes wrong:** The denied card reads as a failure on Desktop, where geolocation is *never* going to work (issue #77: no prompt, button greyed out). Users on Desktop read a broken app.
|
||||
|
||||
**Why it happens:** The same code path serves mobile-denial and desktop-unsupported, and the copy ("Location is switched off") implies the user can fix it.
|
||||
|
||||
**How to avoid:** The UI-SPEC already handles this — *"Desktop Telegram / no geolocation → denied card with identical copy (no client-specific variant)."* That is the right call and the research confirms it is load-bearing. The card's `ASK VIA CHAT` secondary CTA is the real Desktop path; make sure it is *not* styled as secondary there.
|
||||
|
||||
### Pitfall 5: Assuming WebGL2 exists everywhere
|
||||
|
||||
**What goes wrong:** MapLibre v6 removed WebGL1 support. On a device/browser without WebGL2, `new Map()` throws `GPUInitializationError` and there is no map at all — violating D-08's "map is the dominant surface" and D-09's "never blank."
|
||||
|
||||
**Why it happens:** v6 made this a hard requirement (it was a soft degradation in v5).
|
||||
|
||||
**How to avoid:** Feature-detect before constructing (`GPUInitializationError` is exported from `maplibre-gl`). Older Android WebViews on low-end devices are the realistic risk. Decide now what the shell looks like without a map — and note that in the plan, because the UI-SPEC has no "no map" state defined. **[Planner decision needed.]**
|
||||
|
||||
### Pitfall 6: Bot API 10.2's origin restriction breaks an innocent refactor
|
||||
|
||||
**What goes wrong:** Someone moves the location chain into an iframe, a CDN-hosted shell, or a preview subdomain. `Telegram.WebApp.*` calls stop working on updated clients — with no clear error.
|
||||
|
||||
**Why it happens:** The restriction landed 2026-07-20, after most third-party tutorials were written.
|
||||
|
||||
**How to avoid:** Every `Telegram.WebApp.*` call originates from the app's own document. The Mock-Telegram harness (D-11) must inject the fake into the *same* window the app runs in, not into an iframe. **[VERIFIED against core.telegram.org/bots/api-changelog, Bot API 10.2, July 14 2026]**
|
||||
|
||||
### Pitfall 7: Re-theming the map by rebuilding the style
|
||||
|
||||
**What goes wrong:** `themeChanged` handler calls `map.setStyle(darkRamsStyle)`. All 110 layers are torn down and rebuilt; the map flashes, tiles re-fetch, and the camera can jump. Directly violates D-10 and the UI-SPEC's "identical — zero re-styling, zero re-creation."
|
||||
|
||||
**Why it happens:** `themeChanged` fires and the reflex is to do *something*.
|
||||
|
||||
**How to avoid:** The handler sets `data-tg-theme` on `<html>` and stops. CSS custom properties re-slot; MapLibre is not touched. Write the handler so its only statement is the attribute set — that makes the invariant greppable.
|
||||
|
||||
### Pitfall 8: The accent color leaks into states it shouldn't carry
|
||||
|
||||
**What goes wrong:** `signal-orange` is used for a "waiting" or "error-ish" chip. Phase 1 D-08's rule is that orange is **navigation-primary only** — Phase 5 draws the route in it, so any non-navigation use of orange now reads as "this is the route."
|
||||
|
||||
**Why it happens:** Orange is the most visually available accent.
|
||||
|
||||
**How to avoid:** The UI-SPEC's reserved-for list is exact and short (position cone · follow-active control · focus brackets · boot lamp · primary CTA fill · boot-card emphasis dot). Degraded/lost/denied lamps are `functional-red`; the LIVE lamp is `olive-green`. Enforce with the same grep-style off-token audit Phase 1 built (`theme/scripts/audit.mjs`).
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Vite + MapLibre v6 bootstrap (verified pattern)
|
||||
|
||||
```typescript
|
||||
// web/src/main.ts
|
||||
// Source: https://www.maplibre.org/maplibre-gl-js/docs/ §Installation → Vite
|
||||
import { Map as MapLibreMap, setWorkerUrl, GPUInitializationError }
|
||||
from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
||||
|
||||
// v6 is ESM-only; `?worker&url` is Vite's supported way to emit a
|
||||
// bundled worker URL. Required for ALL bundlers per the v5→v6 guide.
|
||||
setWorkerUrl(workerUrl);
|
||||
|
||||
import ramsStyle from '@araharah/theme/basemap/liberty-rams.json';
|
||||
|
||||
function bootMap(container: HTMLElement): MapLibreMap | null {
|
||||
try {
|
||||
// WebGL2 is REQUIRED in v6. This constructor throws otherwise.
|
||||
return new MapLibreMap({
|
||||
container,
|
||||
style: ramsStyle, // inlined at build time (D-14) — never re-set
|
||||
center: [101.6869, 3.139], // KL, per AGENTS.md region focus
|
||||
zoom: 12,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof GPUInitializationError) {
|
||||
// Pitfall 5: WebGL2 absent. Shell still renders (D-08/D-09).
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note on the style import:** `theme/basemap/liberty-rams.json` is not inside `@araharah/theme`'s `exports` map (that package has no `exports` field, so deep imports work), but Vite needs it reachable from `web/`. Either add a `basemap` export/subpath to the theme package, or import it via a relative path `../../theme/basemap/liberty-rams.json`. **[Planner decision — D-14 says "build-time dependency", which both satisfy.]**
|
||||
|
||||
### Theme slot switching (PX-02)
|
||||
|
||||
```typescript
|
||||
// web/src/telegram/theme.ts
|
||||
// Source: core.telegram.org/bots/webapps §WebApp.colorScheme, §Events (themeChanged)
|
||||
import type { WebApp } from './webapp';
|
||||
|
||||
type ThemeBranch = 'light' | 'dark';
|
||||
|
||||
function resolveBranch(wa: WebApp): ThemeBranch {
|
||||
// Primary signal — present on all clients this phase targets.
|
||||
if (wa.colorScheme === 'dark' || wa.colorScheme === 'light') return wa.colorScheme;
|
||||
|
||||
// Fallback (UI-SPEC §Theme mapping): luminance of header_bg_color ≥ 0.5
|
||||
const hex = wa.themeParams?.header_bg_color;
|
||||
if (typeof hex === 'string' && /^#[0-9a-f]{6}$/i.test(hex)) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
return lum >= 0.5 ? 'light' : 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function applyTheme(wa: WebApp): void {
|
||||
// The ONLY thing a theme change is allowed to touch.
|
||||
// MapLibre is deliberately absent from this function (D-10).
|
||||
document.documentElement.dataset.tgTheme = resolveBranch(wa);
|
||||
}
|
||||
|
||||
export function watchTheme(wa: WebApp): void {
|
||||
applyTheme(wa);
|
||||
wa.onEvent?.('themeChanged', () => applyTheme(wa));
|
||||
}
|
||||
```
|
||||
|
||||
### Position marker geometry (per UI-SPEC)
|
||||
|
||||
```typescript
|
||||
// web/src/map/marker.ts — DOM Marker; geometry from 03-UI-SPEC §Marker
|
||||
// The accuracy circle radius must be METRES → pixels via map.project().
|
||||
// It is NOT a CSS pixel value.
|
||||
function accuracyRadiusPx(map: MapLibreMap, center: LngLat, metres: number): number {
|
||||
const sw = map.getCanvas().width;
|
||||
const mPerPx = (40075016.686 * Math.cos((center.lat * Math.PI) / 180))
|
||||
/ (sw * Math.pow(2, map.getZoom()));
|
||||
return Math.min(metres / mPerPx, 100 / mPerPx); // UI-SPEC display cap: 100 m
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Heading cone: HIDDEN when heading is unknown.
|
||||
// Source: 03-UI-SPEC §Position Marker — "no default orientation guess,
|
||||
// no half-open cone".
|
||||
if (fix.headingDeg === null) cone.style.display = 'none';
|
||||
else { cone.style.display = ''; cone.style.transform = `rotate(${fix.headingDeg}deg)`; }
|
||||
```
|
||||
|
||||
### Follow / free mode detection
|
||||
|
||||
```typescript
|
||||
// web/src/map/follow.ts
|
||||
map.on('movestart', (e) => {
|
||||
// A programmatic move we initiated is not the user panning away.
|
||||
if (followLock) return;
|
||||
if (e.originalEvent) setFollowMode('free'); // user gesture only
|
||||
});
|
||||
|
||||
// Recenter (UI-SPEC): flyTo ≤400ms, camera padding 96px.
|
||||
function recenter(map: MapLibreMap, fix: Fix): void {
|
||||
followLock = true;
|
||||
map.flyTo({ center: [fix.lng, fix.lat], padding: CAMERA_PADDING_PX /* 96 */,
|
||||
duration: 400 });
|
||||
setFollowMode('following');
|
||||
}
|
||||
```
|
||||
|
||||
### GEO-03 zero-persistence grep gate (extends Phase 2's `privacy.test.mjs` pattern)
|
||||
|
||||
```javascript
|
||||
// web/test/no-storage.test.mjs
|
||||
// GEO-03: no coordinate — and no session token — reaches ANY storage API.
|
||||
const BANNED = [
|
||||
'localStorage', 'sessionStorage', 'indexedDB', 'caches',
|
||||
'document.cookie', 'CacheStorage',
|
||||
];
|
||||
// Plus Telegram's own persistence surfaces, which D-07 also forbids:
|
||||
// 'WebApp.DeviceStorage', 'WebApp.SecureStorage', 'WebApp.CloudStorage'
|
||||
test('no storage API is referenced in web/src', async () => {
|
||||
const files = await glob('src/**/*.{ts,html}');
|
||||
for (const f of files) {
|
||||
const src = await readFile(f, 'utf8');
|
||||
for (const api of BANNED) {
|
||||
assert.ok(!src.includes(api), `${f} references ${api} — GEO-03 violated`);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Plus a runtime probe** (stronger than grep, and the real phase-exit spot-check): after a full boot → live cycle in the Mock-Telegram harness, assert in-page that `localStorage.length === 0`, `sessionStorage.length === 0`, `document.cookie === ""`, and `indexedDB.databases()` is empty. Run it under Playwright — that is the artifact GEO-03's success criterion asks to be "spot-checkable at phase exit."
|
||||
|
||||
### Dev initData signing for the Mock-Telegram harness (D-11)
|
||||
|
||||
Phase 2's `bot/src/security/initdata.ts` already implements HMAC-SHA256 with `HMAC(bot_token, "WebAppData")`. The harness's fake `initData` must be signed with the **dev** bot token so `POST /api/session` accepts it unmodified. The signing logic is HTTP-world and reusable verbatim — do not reimplement it in `web/`. **[Repo inspection — `bot/src/security/initdata.ts` exists and is the canonical implementation.]**
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `navigator.geolocation` only, re-prompt each launch | `WebApp.LocationManager` (Bot API 8.0) — grant persists per-bot on device | Bot API 8.0, Nov 2024 | GEO-01's "permission persists across sessions" is only achievable via LocationManager. The WebView API *cannot* deliver this. |
|
||||
| Treat `getLocation()` data as fresh | Freshness must be measured (successive-fix comparison + `FIX Ns AGO` telemetry) | Still open upstream (issue #56, Dec 2024 →) | D-06's honesty principle extends to data age, not just permission state. |
|
||||
| MapLibre v5 UMD/CJS bundles | v6 ESM-only + mandatory `setWorkerUrl()` in bundlers | MapLibre v6 | Breaks CJS consumers; Vite's `?worker&url` is the blessed path. |
|
||||
| WebGL1 tolerated (v5 degraded gracefully) | WebGL2 required; `Map` throws `GPUInitializationError` | MapLibre v6 | Needs an explicit no-map shell state (Pitfall 5). |
|
||||
| `styleimagemissing` + `map.addImage()` | `map.setMissingStyleImageResolver()` | MapLibre v6 | Relevant if Phase 5's route line uses a generated icon. |
|
||||
| Mini App methods callable from any origin | Bot API 10.2 origin restriction, auto-enabled 2026-07-20 | July 2026 | All `Telegram.WebApp.*` calls must come from the registered origin. |
|
||||
| Telegram storage choices: `CloudStorage` / `DeviceStorage` / `SecureStorage` | n/a — this project uses **none** of them | — | D-07's zero-persistence is stricter than Telegram's model and is what makes GEO-03 provable. |
|
||||
|
||||
**Deprecated / do not use:**
|
||||
- **`telegram-web-app.js` as an npm package** — does not exist on npm (E404). Telegram's official integration is a `<script src>` from `core.telegram.org/bots/webapps`; ****CORRECTED by device-matrix debug round 3 (2026-10-23): WRONG call — Telegram does NOT auto-inject; production had `window.Telegram` undefined (no initData → session 401, no LocationManager, no BackButton). `telegram-web-app.js` from telegram.org IS required in `<head>`**. Add the script tag (done in web/index.html). The npm-package 404 part still stands — `<script src>` is the only integration. **[ERRATUM logged]**
|
||||
- **`@telegram-apps/web-app`** — E404 on npm.
|
||||
- **Cloudflare Pages** — superseded by Workers + `[assets]` for this project (AGENTS.md).
|
||||
- **`maplibre-gl` default import** (`import maplibregl from 'maplibre-gl'`) — removed in v6. Use named or namespace imports.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
> Claims below are **not** verified against primary sources. The planner should treat each as a decision point, not a fact.
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | `navigator.geolocation.getCurrentPosition()` inside the Android WebView refreshes the stale native cache such that the next `LocationManager.getLocation()` returns fresh coordinates. | §Pattern 2 | **Medium.** The mechanism is community-reported (6 confirmations on issue #56) but unverified on our devices. If it does not hold, Android `LIVE` shows a frozen pin. Mitigation: the recovery path degrades gracefully to polling, and the exit gate is where it gets proven. |
|
||||
| A2 | `@types/telegram-web-app@10.1.0`'s `LocationManager` typings match the official docs (especially `LocationData.course` being nullable and `getLocation`'s callback being `(LocationData \| null) => void`). | §Standard Stack | **Low.** If wrong, one wrapper file needs a local type override. Recommend verifying before commit. |
|
||||
| A3 | Telegram WebView geolocation requires the page be served over HTTPS (or localhost) for `navigator.geolocation` to work at all — meaning `wrangler dev` on `http://localhost:8787` is fine but a LAN-IP dev URL would not be. | §Pitfalls / D-12 | **Medium.** Affects how the real-device verification session is staged (D-12 says remote staging is used "sparingly right before real-device verification sessions" — that is likely *because* of this). Planner should confirm the staging path before the device gate. |
|
||||
| A4 | A reply keyboard with `one_time_keyboard: true` is the right presentation for the GEO-02 fallback (auto-dismisses after one use, keeps the chat clean). | §Pattern 3 | **Low.** UX preference; `resize_keyboard: true` is the load-bearing part. |
|
||||
| A5 | The bot can relay a chat-shared pin to the open Mini App via a polled authenticated endpoint, given the Worker is stateless. | §Pattern 3 | **Low–Medium.** Architecturally sound with KV, but adds a new Worker route (`GET /api/location`) not present in Phase 2 — a real scope item the planner must budget for. |
|
||||
| A6 | OpenFreeMap's tile endpoint tolerates the request rate a Phase 3 demo/user cohort generates. | §D-14 | **Low.** Not a Phase 3 concern; revisit in Phase 7 hardening. |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All four questions below were open at research time and are resolved by this phase's plans. The
|
||||
resolution is recorded here so a later phase can see the decision and its owner without re-deriving it.
|
||||
|
||||
1. **What does the shell look like with no map at all (WebGL2 absent)?**
|
||||
- What we know: v6 throws `GPUInitializationError`; D-08 says the map is the dominant surface; the UI-SPEC defines no map-less state.
|
||||
- What's unclear: whether to (a) render the shell chrome over a Rams-tinted field, (b) hide the chrome and show only the status strip, or (c) accept that this only happens on ancient devices and let it throw.
|
||||
- **Recommendation:** the planner decides explicitly and the UI-SPEC gets a one-line amendment. Option (a) is cheapest and preserves "never blank." This is a genuine gap in the approved contract — flag it to `/gsd-ui-phase` if the planner wants the contract amended rather than silently extended.
|
||||
- **RESOLVED — option (a).** `bootMap()` catches `GPUInitializationError` and returns `null`; the shell renders over `--color-surface-dim` with `data-map="absent"`, and a one-line planner amendment is appended to `03-UI-SPEC.md` §Boot Choreography. See plan 03-03 task 2 (map bootstrap + `shell.css`) and task 3 (the `main.ts` header note).
|
||||
|
||||
2. **Which source does Phase 6's continuous mode actually use — poll `getLocation()` or `watchPosition()`?**
|
||||
- What we know: no native streaming API exists (Pitfall 1); `navigator.geolocation.watchPosition` works on iOS (issue #56 reporter confirms iOS updates correctly every second) and is the likely answer for continuous guidance.
|
||||
- What's unclear: whether the Android stale-fix affects `watchPosition` too, or only `LocationManager`.
|
||||
- **Recommendation:** Phase 3 builds the `Fix`-shaped abstraction and a pluggable cadence; Phase 6 picks. Write the abstraction so this choice stays open — **do not** hard-code a poll interval in Phase 3.
|
||||
- **RESOLVED — deliberately left open, by design.** `chain.ts` exposes a `Fix`-shaped abstraction with a pluggable cadence and hard-codes no continuous-mode interval; `readWebviewFix` uses `maximumAge: 0`, and there is an acceptance gate that `setInterval|watchPosition` appear nowhere in `chain.ts`. The post-live 1s ticker in `main.ts` re-runs `acquireFix` rather than streaming. Phase 6 picks poll-vs-`watchPosition` without a rewrite. See plan 03-02 task 2 (`chain.ts`) and plan 03-03 task 3 (the post-live cadence).
|
||||
|
||||
3. **Does the Phase 3 exit gate have real device access?**
|
||||
- What we know: Success Criterion 5 requires an Android + iOS + Desktop + plain-browser pass. D-11 says device batteries are spent at the exit gate.
|
||||
- What's unclear: whether the executor has physical Android and iOS devices available at all. If not, the criterion is unverifiable and the phase cannot honestly close.
|
||||
- **Recommendation:** the planner must confirm device availability **before** writing the exit-gate task, and A1 (the Android stale-fix) can only be retired with a real Android phone.
|
||||
- **RESOLVED — handled as a blocking human checkpoint, not a pre-flight assumption.** Availability cannot be determined in advance by the executor, so plan 03-04 carries it as a blocking `checkpoint:human-verify` (task 3) with the five legs enumerated, and an explicit instruction that missing hardware means Success Criterion 5 is recorded as UNVERIFIED with the blocker in `.planning/STATE.md` — the phase is never closed claiming verification that did not happen. The automated half (capture harness + Desktop chat-pin leg) is built first so only the genuinely manual part is checkpointed.
|
||||
|
||||
4. **Should the theme package expose `basemap/liberty-rams.json` via a subpath export, or should `web/` import it relatively?**
|
||||
- What we know: `@araharah/theme` has no `exports` map today (verified), so deep relative imports work; Vite resolves JSON imports natively.
|
||||
- What's unclear: which reads better as the long-term contract for Phases 4–6.
|
||||
- **Recommendation:** add a proper `exports` entry (`.` → `./dist/rams.css`, `./basemap/*`, `./fonts/*`). One small change now, avoids four phases of ad-hoc relative paths. OpenCode's discretion per D-02.
|
||||
- **RESOLVED — deep package import, resolved at build time.** `web/src/map/map.ts` imports `@araharah/theme/basemap/liberty-rams.json` directly. `moduleResolution: "bundler"` already permits the deep subpath, so no `exports` map change is needed for Phase 3, and Vite inlines the JSON into `web/dist` at build (proven by the BA4 gate). The only TypeScript-side requirement is `resolveJsonModule: true`, pre-authorized in `web/tsconfig.json` by plan 03-01 task 1. If a later phase needs more subpaths, an `exports` map can be added then without touching Phase 3's code. See plan 03-03 task 2.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Node.js | Vite 8 (needs `^20.19.0 \|\| >=22.12.0`) | ✓ | **24.18.0** | — |
|
||||
| npm workspaces | D-02 `web/` workspace | ✓ | 11.16.0 | — |
|
||||
| wrangler | D-12 dev on 8787, D-15 deploy | ✓ | 4.151.3 (already in `bot/`) | — |
|
||||
| TypeScript | D-01 | ✓ | 5.9.3 (`bot/` pin) | — |
|
||||
| Playwright + browsers | D-11 harness, DSGN-02 fidelity artifact | ✓ | chromium-1248, webkit-2272, firefox-1511 cached | — |
|
||||
| esbuild (Vite dep) | Vite build | ✓ | in `node_modules/.bin` | — |
|
||||
| Cloudflare KV (`chat_sessions`) | Phase 2 session, Phase 3 pin relay | ✓ | binding configured in `wrangler.jsonc` | — |
|
||||
| **OpenFreeMap tiles** | D-14 runtime basemap | ✓ (network) | `https://tiles.openfreemap.org/planet` | — |
|
||||
| **Android device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's Android claim and for retiring A1. |
|
||||
| **iOS device** | Success Criterion 1 + 5 | **?** | — | **None.** Blocking for GEO-01's iOS claim. |
|
||||
| Telegram Desktop | Success Criterion 5 (GEO-02 path) | **?** | — | **None.** Blocking for the Desktop leg of the matrix. |
|
||||
| Telegram BotFather registration | origin restriction (Bot API 10.2) | **?** | — | **None.** Must point at the served origin. |
|
||||
|
||||
**Missing dependencies with no fallback:** real Android and iOS devices, and a Telegram Desktop install. The roadmap's own Success Criterion 5 requires all of them. **The planner must surface this as a checkpoint before the exit-gate task, not discover it at execution.**
|
||||
|
||||
**Missing dependencies with fallback:** none identified — the toolchain is complete.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
> `.planning/config.json` → `workflow.nyquist_validation: true`. Included per protocol.
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | **Hand-rolled zero-dependency runner** — `web/test/runner.mjs` + `harness.mjs`, mirroring `bot/test/` and `theme/scripts/audit.test.mjs` (repo precedent, D-11's "hand-rolled test runner pattern prevails") |
|
||||
| Config file | none (no vitest/jest config — by design) |
|
||||
| Quick run command | `npm test -w web` |
|
||||
| Full suite command | `npm test -w web && npm run typecheck -w web && npm run build -w web` |
|
||||
| Browser/visual checks | `npm run capture -w web` — Playwright, extends the `theme/scripts/screenshot.mjs` pattern (static server + chromium + `MIN_BYTES` blank-bar check already established there) |
|
||||
|
||||
⚠️ **ESM caveat:** `maplibre-gl@6.15.0` exports **only** an `import` condition. The runner uses dynamic `import()` on `.mjs` files with `"type": "module"`, which resolves it correctly. Any test that `require()`s maplibre will fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Unit tests should therefore test the **chain logic against injected fakes**, never against a real MapLibre instance — which is better design anyway.
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| **GEO-01** | `LocationManager.init()` then `getLocation()` yields a `Fix` with `source:'telegram'` | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-01** | A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-01** | `getLocation()` returning `null` (denied) advances to the next source, not to an error | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-01** | `getLocation()` that never calls back resolves `null` after the wrapper timeout (Pitfall 3) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-01** | Two identical successive fixes are detected as stale and route to the recovery path (Pitfall 2) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-01** | Grant persists across a fresh boot — re-read on revalidation, no re-prompt | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-02** | Bot sends `ReplyKeyboardMarkup` with `request_location: true`, `resize_keyboard`, `one_time_keyboard` | unit (bot-side, extends `bot/test/`) | `npm run bot:test` | ❌ Wave 0 |
|
||||
| **GEO-02** | Bot receives `message.location` and writes it with an explicit `expirationTtl` | unit (bot-side) | `npm run bot:test` | ❌ Wave 0 |
|
||||
| **GEO-02** | App enters `AWAITING PIN`, polls, and consumes the pin into `live` | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-02** | Poll is bounded — a never-delivered pin returns to the denied card, never wedges (Pitfall: indefinite `AWAITING PIN`) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-02** | Desktop Telegram (no `LocationManager`, no `navigator.geolocation`) reaches the denied card with **identical** copy | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-03** | No storage API referenced anywhere in `web/src` (grep gate) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **GEO-03** | Runtime probe: after boot → live in the harness, `localStorage`/`sessionStorage`/cookies/IndexedDB are all empty | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||||
| **GEO-03** | Every KV `put` in the new location module carries `expirationTtl` | unit (bot-side grep gate) | `npm run bot:test` | ❌ Wave 0 |
|
||||
| **PX-02** | `colorScheme:'dark'` sets `data-tg-theme="dark"`; light sets `light` | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **PX-02** | Missing `colorScheme` falls back to `header_bg_color` luminance ≥ 0.5 | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **PX-02** | `themeChanged` re-sets the attribute and touches nothing else (map style object identity unchanged — Pitfall 7) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **PX-02** | Dark-theme capture exists in the fidelity artifact | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||||
| **D-09** | Boot renders stage 1 → stage 2 → live with no blank intermediate frame | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||||
| **D-10** | `BackButton.hide()` at init; the handler is a total function (closes when no overlay — no dead-ends) | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **D-03** | Session token lives only in module scope; never written to any storage | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
| **D-14** | `web/dist` contains the inlined style JSON, self-hosted fonts, and a MapLibre worker file; no CDN references in the built output | unit (build-artifact gate) | `npm run build -w web` | ❌ Wave 0 |
|
||||
| **D-08** | Boot output contains no route line, no search furniture, no layer toggles | integration (Playwright) | `npm run capture -w web` | ❌ Wave 0 |
|
||||
| Copy gate | `strings.ts` exports exactly the UI-SPEC copy table, en + ms; no inline copy in shell modules | unit | `npm test -w web` | ❌ Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `npm test -w web`
|
||||
- **Per wave merge:** `npm test -w web && npm run bot:test && npm run typecheck -w web && npm run build -w web`
|
||||
- **Phase gate:** full suite green + `npm run capture -w web` produced (DSGN-02 fidelity artifact) + **the device-matrix pass (manual, blocking)**
|
||||
|
||||
### Wave 0 Gaps
|
||||
|
||||
- [ ] `web/test/runner.mjs` — hand-rolled runner (copy the `bot/test/runner.mjs` shape)
|
||||
- [ ] `web/test/harness.mjs` — test registry
|
||||
- [ ] `web/src/mock/telegram-mock.ts` — D-11 fake `WebApp`; the keystone that makes every location test above possible without a device
|
||||
- [ ] `web/test/location-chain.test.mjs` — covers GEO-01, GEO-02 (client half)
|
||||
- [ ] `web/test/no-storage.test.mjs` — GEO-03 grep gate
|
||||
- [ ] `web/test/theme.test.mjs` — PX-02 slot-switch gate
|
||||
- [ ] `web/test/copy-gate.test.mjs` — UI-SPEC copy table gate
|
||||
- [ ] `bot/test/location-pin.test.mjs` — GEO-02 bot half + TTL grep assertion
|
||||
- [ ] `web/scripts/capture.mjs` — Playwright harness captures (extend `theme/scripts/screenshot.mjs`)
|
||||
- [ ] **`GET /api/location` Worker route + KV module** — the missing link between the bot's received pin and the open Mini App (Open Question 5)
|
||||
|
||||
**None of these exist yet — `web/` does not exist.** Every test above is Wave 0.
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | **yes** | Phase 2's `/api/session` (HMAC-validated `initData` → opaque token). The app posts the **verbatim** `initData` string — never `initDataUnsafe` (Telegram's own docs warn on this). Token held in module scope only (D-03). |
|
||||
| V3 Session Management | **yes** | 900s TTL (already enforced server-side, `SESSION_TTL_SECONDS`), rotate-on-new, revalidate on every fresh boot. No client-side session persistence at all — which is stricter than `SecureStorage`/`CloudStorage` and therefore immune to their availability quirks. |
|
||||
| V4 Access Control | **yes** | The new `GET /api/location` route must validate the bearer token via `getClaimByToken` before returning anything. A pin must never be readable without a live, unexpired session token for that chat. |
|
||||
| V5 Input Validation | yes | Validate `Location` message fields (`latitude`/`longitude` numeric, sane ranges) before use; cap `horizontal_accuracy` (Telegram documents 0–1500 m); reject absurd coordinates rather than flying the camera to null island. |
|
||||
| V6 Cryptography | **no new crypto** | HMAC-SHA256 stays entirely server-side. The client never holds `BOT_TOKEN`. No crypto in `web/`. |
|
||||
| **Origin restriction (Bot API 10.2)** | **yes — new** | All `Telegram.WebApp.*` calls originate from the registered Mini App origin. Auto-enabled since 2026-07-20. |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Forging a session by posting a crafted `initData` | Spoofing | Phase 2's constant-time HMAC compare + `auth_date` freshness window. **Do not** add a client-side shortcut. |
|
||||
| Reading another user's shared pin via `GET /api/location` | Information Disclosure | Token-scoped KV lookup — a token resolves to exactly one `chat_id`; the route must derive the KV key from the *claim*, never from a client-supplied id. |
|
||||
| Coordinate persisted to a client store | Information Disclosure (privacy regression) | D-07 + the grep gate + the runtime probe. This is the GEO-03 spot-check made mechanical. |
|
||||
| Stale/cached fix presented as `LIVE` | Repudiation (dishonest state) | Successive-fix staleness detection + `FIX Ns AGO` telemetry. The app must never claim freshness it has not measured. |
|
||||
| Malicious bot update injecting a pin | Tampering | Only Telegram-authenticated webhook updates reach grammY; the secret-token compare runs before parsing (Phase 2's fail-closed guard). |
|
||||
| Map style JSON fetched from a mutable URL at runtime | Tampering | D-14 already forbids this — the style is inlined at build time. Tiles are the only runtime network dependency. |
|
||||
| Telegram WebApp called cross-origin | Elevation of Privilege | Bot API 10.2 restriction — keep all calls on the registered origin. |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- **https://core.telegram.org/bots/webapps** — `LocationManager` (`init`/`getLocation`/`openSettings`/`isInited`/`isLocationAvailable`/`isAccessRequested`/`isAccessGranted`), `LocationData` (all 9 fields incl. nullable `course`/`horizontal_accuracy`), `colorScheme`, `ThemeParams`, `BackButton`, `SafeAreaInset`/`ContentSafeAreaInset`, `isVersionAtLeast`, the complete event table (`locationManagerUpdated`, `locationRequested`, `themeChanged`, `safeAreaChanged`, `activated`/`deactivated`), `ready()`/`expand()`/`viewportStableHeight`. Fetched 2026-10-22. **This is the authority for the entire location chain.**
|
||||
- **https://core.telegram.org/bots/api-changelog** — Bot API **10.2, July 14 2026**: *"Hardened the security of Mini Apps by disallowing the usage of Mini App methods from origins different from the original Mini App domain. The protection will be automatically enabled for all Mini Apps on July 20, 2026."* Also confirms LocationManager landed in 8.0 (Nov 2024). Fetched 2026-10-22.
|
||||
- **https://core.telegram.org/bots/api** (HTML) — `KeyboardButton.request_location` (*"If True, the user's current location will be sent when the button is pressed. Available in private chats only."*), `Location` type fields, `ReplyKeyboardMarkup` (`resize_keyboard`, `one_time_keyboard`). Fetched 2026-10-22.
|
||||
- **https://maplibre.org/maplibre-gl-js/docs/guides/v5-to-v6-migration-guide/** — ESM-only, `setWorkerUrl()` bundler-only, **WebGL2 required** + `GPUInitializationError`, `styleimagemissing` → `setMissingStyleImageResolver`, no default import. Fetched 2026-10-22.
|
||||
- **https://www.maplibre.org/maplibre-gl-js/docs/ §Installation** — the exact Vite snippet (`?worker&url` → `setWorkerUrl`), CSS import path, `import 'maplibre-gl/dist/maplibre-gl.css'`, CSP directives. Fetched 2026-10-22.
|
||||
- **https://developers.cloudflare.com/workers/static-assets/binding/** — `assets.directory`, `assets.binding`, **`run_worker_first` as a glob array** (`["/api/*", "!/api/docs/*"]`, max 100 entries), `not_found_handling`, `env.ASSETS.fetch(request)` passthrough. Fetched 2026-10-22. **This is the exact D-13 config shape.**
|
||||
- **https://playwright.dev/docs/emulation#geolocation** — `context.setGeolocation()`, `grantPermissions(['geolocation'])`, `colorScheme`, `devices[...]` — the D-11 harness and exit-gate matrix primitives. Fetched 2026-10-22.
|
||||
- **npm registry (`npm view`, run this session)** — `maplibre-gl@6.15.0` (2026-10-18) with `exports` containing **only** an `import` condition; `vite@8.3.5` (2026-10-21) `engines.node: ^20.19.0 || >=22.12.0`; `@types/telegram-web-app@10.1.0` (2026-10-04); `telegram-web-app` / `telegram-web-app.js` / `@telegram-apps/web-app` all E404 or abandoned.
|
||||
- **Repo inspection** — `bot/src/webhook.ts` (`/api/session` contract, 4 KB cap, fail-closed webhook), `bot/src/kv/sessions.ts` (900s TTL, rotate-on-new, `getClaimByToken`), `bot/test/{runner,harness}.mjs` (test pattern), `bot/wrangler.jsonc` (`chat_sessions` binding), `theme/src/components.css` (`.btn-primary`, `.card`, `.text-label-sm`, `.checkbox` all confirmed present), `theme/src/theme.css` (`--shell-bg`/`--shell-on-surface` + `prefers-color-scheme` defaults), `theme/basemap/liberty-rams.json` (v8 style, 110 layers, `openmaptiles` + `ne2_shaded` sources → OpenFreeMap).
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- **https://github.com/Telegram-Mini-Apps/issues/issues/56** — Android stale-fix bug. Open since Dec 18 2024, labels `bug`/`in progress`/`Telegram for Android`, 6+ independent confirmations. Reporter's workaround: *"Integrating the browser's `navigator.geolocation.getCurrentPosition` … allows the Telegram mini app to retrieve the same coordinates afterward"*; *"Opening Google Maps on the device and then returning to the Telegram mini app also triggers the location to refresh."* Commenter `stefda`: *"This issue renders the new LocationManager api useless."* **The basis for A1 — treat as a hypothesis to verify at the device gate, not as established fact.**
|
||||
- **https://github.com/Telegram-Mini-Apps/issues/issues/77** — Desktop geolocation absent. Open Apr 2025 → last activity Jan 2026. Second reporter (Sep 2025) confirms the button is *greyed out* on Telegram macOS and Desktop. Establishes GEO-02 as the only Desktop origin path.
|
||||
- **https://crossminiapp.com/en/blog/webview-geolocation-broken-runtime** (Oct 20, 2026) — corroborating platform analysis: Android's `WebSettings.setGeolocationEnabled()` defaults to **false**; needs `WebChromeClient.onGeolocationPermissionsShowPrompt` + runtime permission + HTTPS/localhost; iOS routes every request through `WKUIDelegate.webView(_:requestGeolocationPermissionFor:…)`, and **a page that calls geolocation on load or without a direct user gesture is blocked or re-prompted**. Vendor ROMs (EMUI/MIUI) add background restrictions. *Vendor marketing site — used only for the well-known Android/iOS platform facts, which it cites to primary developer.android.com / developer.apple.com sources.*
|
||||
- **https://adsgram.ai/blog/adsgram/telegram-mini-app-tma-development-mistakes-and-how-to-avoid-them** (Aug 23, 2026) — two independently valuable claims: (a) `localStorage` **may not persist** inside Telegram's WebView on iOS and some Linux desktop builds (corroborates D-03's choice); (b) the Bot API 10.2 origin restriction and its Jul 20 2026 enforcement date — which I then **confirmed against Telegram's own changelog** and therefore treat as HIGH. *Ads vendor blog; treat mechanism claims as MEDIUM.*
|
||||
|
||||
### Tertiary (LOW confidence — flagged for validation)
|
||||
- Various Stack Overflow / Reddit / YouTube results on WKWebView geolocation permission prompts and iOS 14+ `CLLocationManager` authorization changes. These surfaced during search and were **not** used for any specific claim; they indicate the area is genuinely messy and support the general "gate everything, never assume" posture rather than any particular implementation detail.
|
||||
- `@telegram-apps/sdk` (the community SDK) appeared in issue #77's reproduction. Not evaluated further — D-01 rules it out (no framework), and the official API surface is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- **Standard stack: HIGH** — every version verified against the live npm registry this session; Vite/MapLibre/Telegram types resolved and their failures documented.
|
||||
- **Architecture (platform API surface): HIGH** — the entire `LocationManager`, `ThemeParams`, safe-area, and event surface read directly from `core.telegram.org/bots/webapps` this session.
|
||||
- **Architecture (the fallback mechanisms): HIGH** — `request_location` and the `Location` message shape confirmed against `core.telegram.org/bots/api`.
|
||||
- **Pitfalls: HIGH** — Pitfalls 1, 3, 5, 6, 7 are direct consequences of documented API behavior, not inference.
|
||||
- **The Android stale-fix workaround (A1): MEDIUM** — community-sourced, reproduced by multiple reporters, **not verified on our devices**. Explicitly listed in the Assumptions Log and gated behind a degradation path.
|
||||
- **`localStorage` unreliability in the WebView: MEDIUM** — single secondary source; corroborates rather than drives the design.
|
||||
- **`@types/telegram-web-app` accuracy (A2): MEDIUM** — package exists and is current; its `LocationManager` shape was not diffed against the official docs.
|
||||
- **Device-matrix feasibility: UNKNOWN** — the availability of real Android/iOS devices is an open question that blocks Success Criterion 5 and cannot be resolved by research.
|
||||
|
||||
**What might I have missed:**
|
||||
- The **WebGL2-absent shell state** (Open Question 1) is a genuine gap in an *approved* UI-SPEC. The planner should amend the contract rather than silently extend it.
|
||||
- The **new `GET /api/location` Worker route** is a real scope item that appears in neither Phase 2's code nor Phase 3's stated deliverables, yet GEO-02 cannot work without it. Budget it explicitly.
|
||||
- I did **not** research Telegram's *test server* (`test.telegram.org`) as an alternative to physical devices for the matrix. It could shorten iteration but does not substitute for real-device verification of a WebView-specific bug.
|
||||
|
||||
**Research date:** 2026-10-22
|
||||
**Valid until:** 2026-11-05 (14 days) — **shorter than usual, deliberately.** The Android stale-fix bug (issue #56) and the Telegram Mini App platform are both moving targets; a fix landing would change the recommended chain. Re-check `core.telegram.org/bots/api-changelog` and issue #56 before executing.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
phase: 3
|
||||
slug: mini-app-shell-geolocation
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-10-22
|
||||
---
|
||||
|
||||
# Phase 3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Hand-rolled zero-dependency runner — `web/test/runner.mjs` + `harness.mjs`, mirroring `bot/test/` and `theme/scripts/audit.test.mjs` (repo precedent, D-11's "hand-rolled test runner pattern prevails") |
|
||||
| **Config file** | none (no vitest/jest config — by design) |
|
||||
| **Quick run command** | `npm test -w web` |
|
||||
| **Full suite command** | `npm test -w web && npm test -w bot && npm run typecheck -w web && npm run build -w web` |
|
||||
| **Browser/visual checks** | `npm run capture -w web` — Playwright, extends the `theme/scripts/screenshot.mjs` pattern (static server + chromium + `MIN_BYTES` blank-bar check already established there) |
|
||||
| **Estimated runtime** | ~30 seconds |
|
||||
|
||||
⚠️ **ESM caveat:** `maplibre-gl@6.15.0` exports **only** an `import` condition. The runner uses dynamic `import()` on `.mjs` files with `"type": "module"`, which resolves it correctly. Unit tests test the **chain logic against injected fakes**, never against a real MapLibre instance.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `npm test -w web` (plus `npm test -w bot` for bot-side tasks)
|
||||
- **After every plan wave:** Run `npm test -w web && npm test -w bot && npm run typecheck -w web && npm run build -w web`
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** ~30 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-task Verification Map
|
||||
|
||||
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| {N}-01-01 | 01 | 1 | (scaffold) | — | N/A | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (W0) | 01 | 1 | GEO-01 | — | chain vs fakes | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (W0) | 01 | 1 | GEO-03 | T-3-x | no storage API in web/src | unit grep gate | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (W0) | 01 | 1 | PX-02 | — | theme slot switch | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (W0) | 01 | 1 | GEO-02 | T-3-03 | TTL'd location pin KV | unit (bot) | `npm test -w bot` | ❌ W0 | ⬜ pending |
|
||||
| (W0) | 01 | 1 | copy gate | — | strings.ts = UI-SPEC table | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (plans) | 02-0N | 2 | GEO-01 | — | source fallback chain | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (plans) | 02-0N | 2 | GEO-02 | — | pin poll bounded | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
|
||||
| (plans) | 03-0N | 3 | D-14 | T-3-06 | no CDN refs in dist | build gate | `npm run build -w web` | ❌ W0 | ⬜ pending |
|
||||
| (plans) | 03-0N | 3 | GEO-03 / D-09 / D-10 | — | runtime probes | integration (Playwright) | `npm run capture -w web` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
*(task IDs will be filled by the plans' own `<automated>` fields; the map above is the Wave-0 skeleton.)*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `web/test/runner.mjs` — hand-rolled runner (copy the `bot/test/runner.mjs` shape)
|
||||
- [ ] `web/test/harness.mjs` — test registry
|
||||
- [ ] `web/src/mock/telegram-mock.ts` — D-11 fake `WebApp`; the keystone that makes every location test possible without a device
|
||||
- [ ] `web/test/location-chain.test.mjs` — covers GEO-01, GEO-02 (client half)
|
||||
- [ ] `web/test/no-storage.test.mjs` — GEO-03 grep gate
|
||||
- [ ] `web/test/theme.test.mjs` — PX-02 slot-switch gate
|
||||
- [ ] `web/test/copy-gate.test.mjs` — UI-SPEC copy table gate
|
||||
- [ ] `bot/test/location-pin.test.mjs` — GEO-02 bot half + TTL grep assertion
|
||||
- [ ] `web/scripts/capture.mjs` — Playwright harness captures (extend `theme/scripts/screenshot.mjs`)
|
||||
- [ ] **`GET /api/location` Worker route + KV module** — the missing link between the bot's received pin and the open Mini App
|
||||
|
||||
*None of these exist yet — `web/` does not exist. Every test above is Wave 0.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Live position on Rams basemap on real Android + iOS | GEO-01 | WebView location bugs are device/client-specific (Bot API 10.2, WKWebView, LocationManager) | Device-matrix exit pass: boot → session → position on each device |
|
||||
| Chat "send location" keyboard fallback on Desktop Telegram | GEO-02 | Desktop Telegram has no geolocation at all (issue #77); only real clients show the path | Open Mini App on Desktop Telegram; trigger fallback via chat location |
|
||||
| Android stale-fix workaround effectiveness (A1) | GEO-01 | Community-sourced, unverified on our devices | On Android: issue ONE stale Telegram fix → attempt in-app refresh (navigator nudge) → verify fix freshness updates |
|
||||
| Theme flip live (light ↔ dark) while map stays Rams-constant | PX-02 | Telegram theme switching only happens inside the real client | Switch Telegram theme mid-session; verify shell re-themes, map unchanged |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 30s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
|
||||
---
|
||||
|
||||
*Source: `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` § Validation Architecture (2026-10-22)*
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 893 KiB |
@@ -0,0 +1,114 @@
|
||||
# Phase 4: Destination Search + KL Gazetteer - Context
|
||||
|
||||
**Gathered:** 2026-10-23
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Users type `/cari <query>` in chat (bilingual aliases already reserved), the bot answers from an in-Worker curated KL gazetteer with the best result as a native venue card (sendVenue) plus inline alternates, and tapping OPEN IN ARAHARAH launches the Mini App with the destination preloaded via a `startapp` deep link. The search-quality experiment CLOSES this phase: a pinned golden corpus with thresholds decides gazetteer sufficiency vs escalation (normalizer edits → WASM geocoder backend #2 → Photon-on-VPS, in that order). The Malaysia-only WASM geocoder is a documented future backend, not this phase's deliverable. VPS provisioning defers to Phase 5 (Valhalla). License: GPL-3.0 — forkability is a design law.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Search interaction surface
|
||||
- **D-01:** Search entry is the **`/cari` command** in chat (with `/pergi_ke` reserved for Phase 5 semantics; bilingual alias scheme per Phase 2 D-05). Free-text-to-bot stays reserved; no inline mode; no in-webview search UI this phase (a shared search module keeps that door open).
|
||||
- **D-02:** Shortlist presentation: the **best result ships immediately** as the venue card with its OPEN button; **2–4 alternates ride as inline buttons on the same message**. One tap to proceed, one tap to switch — no second message round.
|
||||
- **D-03:** Search backend is a **pluggable `SearchSource` interface implemented in-Worker**: curated KL gazetteer queried in-Worker, $0 infrastructure. **VPS/Photon provisioning defers to Phase 5** (Valhalla's road graph needs the VPS anyway). Photon remains a documented escalation path *behind the interface*.
|
||||
|
||||
### Forkability + license (user-surfaced design law)
|
||||
- **D-04:** **GPL-3.0** is the project license. Forkability laws are binding from this phase: no hardcoded secrets (env only — already true), data as plain committed files, one-command deploy, one free-tier Cloudflare account per fork.
|
||||
- **D-05:** The user's **Malaysia-only WASM geocoder** vision (tantivy/sqlite-trigram index, statically hosted, client-side webview search — a genuine privacy win) is captured as **SearchSource backend #2**, an OSS-track artifact *after* v1. The JSON↔SQLite converter script keeps its dataset format reachable from day one. **Backend #3 = Photon-on-VPS.** Neither ships in this phase.
|
||||
|
||||
### Gazetteer + Malay normalizer
|
||||
- **D-06:** Entries are **hand-curated seed + a repo-script Overpass one-shot** that proposes top-up candidates for maintainer approval into the curated file. The script is a repo tool, not a runtime dependency.
|
||||
- **D-07:** Canonical format is **typed JSON in the repo** (V8-native parse, no WASM in the Worker bundle, diff-friendly PRs). Rationale: at ~200–500 rows, SQLite/sql.js-in-Worker is ~1.5MB + cold-start overhead of pure cost.
|
||||
- **D-08:** **Aliases live natively inside each entry** (`"KLCC"` → Suria KLCC, KLCC Park; IJN, PWTC, Stesen/Station…) — locals' shorthand is the main corpus quality lever.
|
||||
- **D-09:** The Malay normalizer is a **rule table + folding + fuzzy-residual** module: bidirectional abbreviation table (Jln↔Jalan, Tmn↔Taman, Kg↔Kampung, vowel-drop-aware Presint/Prsn forms), case/whitespace folding, glued-word split — applied to BOTH the query and entry aliases at index time. Fuzzy scoring (Damerau) is only the residual net for genuine typos on longer words. Per-rule test vectors; extends by table edit, not code change.
|
||||
|
||||
### Venue card + deep-link hand-off
|
||||
- **D-10:** The venue card is **Bot API `sendVenue`** (native pin/name/address semantics SRCH-03 names) with inline buttons: OPEN IN ARAHARAH (`web_app` button carrying `startapp`) + alternates.
|
||||
- **D-11:** The deep link carries a **plain gazetteer entry id** (`?startapp=dest_<id>`). The webview maps id → destination from its own copy of the same gazetteer module (shared module, build-time). No crypto: the payload's only threat is a user pranking themselves; stale-webview datasets re-map via the shared module version.
|
||||
- **D-12:** ONBD-03 scope: **minimal PWA hooks** — `addToHomeScreen` prompt handling + a startapp-less boot landing in the search-ready map (existing Phase 3 staged boot covers it). Full PWA manifest/offline story is documented-deferred.
|
||||
|
||||
### Acceptance corpus + thresholds (the bake-off close)
|
||||
- **D-13:** A **pinned golden corpus** in the repo (~60–100 queries): golden landmarks (KLCC, Sunway Pyramid, DUKE), abbreviation forms, typo set (transpositions, missing keys), glued words, aliases, kampung-style names, plus **deliberate negatives** (out-of-KL: "Kuching", "Penang") that must get an honest "not in v1 area" answer, never a fabricated pin. Each positive pins its expected entry id. Reviewers extend by editing one file.
|
||||
- **D-14:** The bake-off closes on: **top-3 contains the expected id for ≥90% of positives AND negatives are refused honestly**. Below threshold, escalation order is fixed: (1) alias/normalizer table edits → re-run; (2) SearchSource backend #2 (WASM geocoder); (3) Photon-on-VPS. The phase records which lever got pulled and re-runs the corpus — the experiment ends with a documented decision, not an open question.
|
||||
|
||||
### Privacy (carried + made specific)
|
||||
- **D-15:** Search queries are user payload: **category-only logging** (Phase 2 law) — no query text persisted. KV caching of search results, if any, is TTL-only with short TTL and keyed by normalized query (content is public place data, never user identity). Venue choices are not stored.
|
||||
|
||||
### OpenCode's Discretion
|
||||
- Scoring function shape (weighted trigram/Jaro vs Damerau) within the rule-table architecture — corpus decides empirically
|
||||
- Alternates ranking + tie-breaking details; "not in v1 area" copy (en/ms, clinical tone)
|
||||
- Overpass query shape for the top-up script; gazetteer entry schema fields beyond (id, name, aliases, lat, lng, category)
|
||||
- D1/KV TTL result-cache mechanics or none (perf is likely fine without at this scale)
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The venue card message should read like the bot's clinical voice: one line confirming the destination, instruments-grade terseness.
|
||||
- The search module is shared by design: bot handler AND webview import the same gazetteer + normalizer (web copy of the module is build-time; id mapping stays in sync by construction).
|
||||
- Corpus file doubles as documentation of what "locals' shorthand" means — keep it curated, not generated.
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope + decisions
|
||||
- `.planning/ROADMAP.md` §Phase 4 — goal, 5 success criteria, bake-off-closes mandate
|
||||
- `.planning/REQUIREMENTS.md` §SRCH-01/02/03, ONBD-02/03
|
||||
- `.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md` — boot choreography (startapp lands in the staged flow), session contract, strings/i18n precedent
|
||||
- `.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md` — command registration, aliases, category-only logging, KV TTL-only law
|
||||
|
||||
### Codebase
|
||||
- `bot/src/bot/bot.ts`, `bot/src/bot/handlers/` — handler patterns, command wiring
|
||||
- `bot/src/i18n/strings.ts` — bilingual strings module (search copy follows)
|
||||
- `web/src/session/`, `web/src/main.ts` — boot flow the deep link lands in; `web/src/mock/` harness (destination-preload profiles for captures)
|
||||
- `bot/wrangler.jsonc`, `bot/src/config.ts` — the Worker env the SearchSource lives in
|
||||
- `AGENTS.md` — repo conventions; GPL-3.0 LICENSE file to be added this phase
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `web/src/i18n/strings.ts` pattern → `bot/src/i18n/strings.ts` (search copy en/ms)
|
||||
- Hand-rolled test runners (bot/web/theme) — corpus runner is a new suite in the same shape
|
||||
- Mock-Telegram harness (web) — add a `?dest=<id>` boot profile for ONBD-02 capture evidence
|
||||
- `bot/src/webhook.ts` route table — `/cari` handler slots beside location-fallback
|
||||
|
||||
### Established Patterns
|
||||
-npm workspaces + delegate scripts; TDD RED→GREEN; category-only logs; TTL-only KV; strings-module copy gates
|
||||
|
||||
### Integration Points
|
||||
- `startapp` param arrives inside `Telegram.WebApp.initData` (`start_param` field) — session flow untouched; webview reads it post-validation
|
||||
- `sendVenue` needs no new scopes; web_app inline button URL = deployed origin (already armed in Phase 3)
|
||||
- The SearchSource interface is the seam Phases 5 (origin/destination), 6, and backend #2/#3 plug into
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Malaysia-only WASM geocoder (tantivy/sqlite-trigram), statically hosted, client-side webview search — SearchSource backend #2, OSS track after v1
|
||||
- Photon-on-VPS — SearchSource backend #3; VPS provisioning happens in Phase 5 for Valhalla regardless
|
||||
- In-webview search UI — needs a UI-SPEC round; shared module already keeps it cheap
|
||||
- Full PWA (manifest, offline shell) — documented-deferred beyond minimal ONBD-03 hooks
|
||||
- Free-text bot search + inline mode — reserved interaction surfaces
|
||||
- Log-derived corpus (real queries) — becomes possible only post-launch (Phase 7+)
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 04-destination-search-kl-gazetteer*
|
||||
*Context gathered: 2026-10-23*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Phase 4: Destination Search + KL Gazetteer - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions captured in CONTEXT.md — this log preserves the analysis.
|
||||
|
||||
**Date:** 2026-10-23
|
||||
**Phase:** 04-destination-search-kl-gazetteer
|
||||
**Mode:** default (interactive)
|
||||
|
||||
## Areas Discussed
|
||||
|
||||
### Search interaction surface
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Where does the user type? | /cari command / free-text / in-webview / command+shared module | /cari command |
|
||||
| Shortlist presentation | best+inline alternates / list-then-choose / single result | Best + inline alternates |
|
||||
| Backend architecture | gazetteer-first pluggable / KL-scale WASM now / full pivot | Gazetteer-first pluggable |
|
||||
|
||||
### Photon/VPS detour (user education round)
|
||||
User asked what Photon is, why static hosts (Cloudflare Pages/GitHub Pages) can't run it, and proposed SQLite/sql.js and Rust-WASM (tantivy) alternatives — including a full "Malaysia-only Photon-lite" sketch (Geofabrik pbf → SQLite/FTS5 → sql.js in browser on GitHub Pages, forkable). Explained: static hosts execute nothing; the Worker is mandatory for the webhook regardless; full-MY index exceeds Worker memory caps; client-side engine means huge mobile downloads; chat-side /cari needs server-side answers. The forkable endgame is preserved as SearchSource backend #2. **Notable: user's pushback killed the default VPS-now plan** — search is $0 in-Worker; VPS defers to Phase 5 (Valhalla).
|
||||
|
||||
### License (user-surfaced area)
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| License for forkability | MIT / Apache-2.0 / GPL-3.0 / later | **GPL-3.0** |
|
||||
|
||||
### Gazetteer + Malay normalizer
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Sourcing | hand-curated / OSM pipeline / curated + Overpass top-up | Curated + Overpass top-up |
|
||||
| Canonical format | JSON in repo / SQLite now (user asked "how heavy?": ~1.5MB WASM + cold-start of pure cost at this scale) | JSON in repo |
|
||||
| Normalizer | rules+folding+fuzzy residual / fuzzy only / research decides | Rules + folding + fuzzy residual (after a worked-example explanation of why short-word fuzzy fails: jln→jalan is high-distance, tmn≈Tun collisions, vowel-drop conventions, glued words, Stesen/Station synonym class) |
|
||||
| Aliases | in entries / separate table | Aliases in entries |
|
||||
|
||||
### Venue card + deep-link hand-off
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| startapp payload | plain id / full payload / signed | Plain gazetteer id |
|
||||
| Venue card | sendVenue + buttons / styled message | sendVenue + buttons |
|
||||
| ONBD-03 scope | minimal PWA hooks / full PWA | Minimal PWA hooks |
|
||||
|
||||
### Acceptance corpus + thresholds
|
||||
| Question | Options presented | Selection |
|
||||
|---|---|---|
|
||||
| Corpus shape | pinned golden set / log-derived | Pinned golden set (~60–100, incl. negatives) |
|
||||
| Bake-off close | 90% top-3 + honest negatives / 80% top-1 / judgment | 90% top-3 + honest negatives; escalation order fixed (table edits → backend #2 → Photon) |
|
||||
|
||||
## Corrections Made
|
||||
|
||||
None reversed; two user questions reshaped defaults materially (VPS deferral; license capture). Third-round education (fuzzy-vs-rules) ended in the recommended option being chosen with informed consent.
|
||||
|
||||
## Auto-Resolved
|
||||
|
||||
Not applicable.
|
||||
|
||||
## External Research
|
||||
|
||||
Deferred to planning's research step per roadmap (gazetteer sourcing, Overpass shapes, corpus composition details).
|
||||
Reference in New Issue
Block a user