[AZ-497] [AZ-498] [AZ-499] Cycle 2 New Task: epic, stories, contract draft

Closes autodev existing-code Step 9 for cycle 2.

- Epic AZ-497 (Self-Hosted Satellite Tiles - SPA Integration) added
  to _docs/02_tasks/_dependencies_table.md as the cycle-2 umbrella.
- AZ-498 (5 pts): self-hosted satellite tiles + drop map-type toggle.
  Cross-workspace prereq: satellite-provider must add cookie-auth on
  GET /tiles/{z}/{x}/{y} before merge (user files separately).
- AZ-499 (2 pts): mission-planner OWM env-var hardening + closes the
  AZ-482 source-scan gap with a new owm_key_in_source banned-deps kind.
- Contract _docs/02_document/contracts/satellite-provider/tiles.md
  v1.0.0 (draft): slippy-tile XYZ shape both sides commit to.
- _docs/_autodev_state.md: Step 9 closure note + advances pointer to
  Step 10 (Implement) for cycle 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 03:45:44 +03:00
parent d7fff1374c
commit 20a39d3d8a
5 changed files with 460 additions and 3 deletions
@@ -0,0 +1,175 @@
# Replace external map tiles with self-hosted satellite-provider
**Task**: AZ-498_satellite_tile_swap
**Name**: Self-hosted satellite tiles + drop map-type toggle
**Description**: Replace OpenStreetMap (classic) and Esri (satellite) tile sources with the suite's own `satellite-provider /tiles/{z}/{x}/{y}` endpoint, drop the classic/satellite toggle (satellite-provider serves satellite imagery only), and wire cookie-based authentication for tile fetches.
**Complexity**: 5 points
**Dependencies**: AZ-450 (Externalize map tile URLs). Cross-workspace prerequisite — satellite-provider must publish a cookie-auth variant of `/tiles/{z}/{x}/{y}` before this task can be merged. The user files that ticket separately on the satellite-provider workspace.
**Component**: 05_flights (with adjustments to 10_app-shell and the e2e harness)
**Tracker**: AZ-498
**Epic**: AZ-497
## Problem
`src/features/flights/types.ts` (post AZ-450) reads two tile-URL env vars and exposes them to `FlightMap` and `MiniMap` via a `{ classic, satellite }` shape. Today those URLs resolve to external providers (OpenStreetMap, Esri ArcGIS World Imagery). This:
- Sends pilot flight-area coordinates to third-party CDNs (privacy/operational risk for sensitive missions).
- Adds an external network dependency the air-gap NFR (NFT-RES-03 / restriction E1) was meant to eliminate — the e2e profile only papers over it via the `tile-stub`.
- Wastes bandwidth re-downloading tiles that the suite's own `satellite-provider` service already caches on disk (`./tiles/{z}/{x}/{y}.jpg`).
The suite already runs a `satellite-provider` .NET service that exposes a slippy-tile XYZ endpoint (`GET /tiles/{z}/{x}/{y}`) backed by an on-disk cache plus on-demand Google Maps download, with `Cache-Control` and `ETag` headers wired. The UI does not consume it.
## Outcome
- The SPA's map renders satellite tiles served by the suite's own `satellite-provider`, on the same origin as the SPA in production.
- The classic/satellite toggle is removed; the map is satellite-only.
- Tile fetches authenticate via a same-origin cookie, not via an `Authorization: Bearer …` header (Leaflet `<img>` requests cannot send the header).
- Air-gap restriction E1 is satisfied for tiles in production without requiring a stub.
- `_docs/02_document/contracts/satellite-provider/tiles.md` documents the contract both sides commit to.
## Scope
### Included
- Collapse `TILE_URLS` in `src/features/flights/types.ts` to a single URL string read from `import.meta.env.VITE_SATELLITE_TILE_URL`.
- Remove the classic/satellite toggle from `FlightMap.tsx`: the `mapType` state, the toggle `<button>`, and the `mapType` prop passed to `MiniMap`.
- Update `MiniMap.tsx` to render a single `<TileLayer>` without a `mapType` prop.
- Both `<TileLayer>` instances MUST include `crossOrigin="use-credentials"` so the browser attaches the auth cookie on same-origin requests.
- Update `.env.example`: add `VITE_SATELLITE_TILE_URL`, remove `VITE_OSM_TILE_URL` and `VITE_ESRI_TILE_URL`, refresh the comment block.
- Update `src/vite-env.d.ts`: add `VITE_SATELLITE_TILE_URL?: string`, remove the two OSM/Esri declarations.
- Update `_docs/02_document/contracts/satellite-provider/tiles.md` to reference this task in the `Consumer tasks` field once the ticket ID is assigned.
- Update `e2e/docker-compose.suite-e2e.yml`: replace `tile-stub` wiring with either (a) a redirect of the SPA's `VITE_SATELLITE_TILE_URL` to the actual `satellite-provider` Docker service, or (b) repurpose `e2e/stubs/tile/server.ts` to serve the `/tiles/{z}/{x}/{y}` path used by the new contract. The choice is made during implementation to minimize churn in the e2e harness.
- Update `e2e/tests/infrastructure.e2e.ts` AC-2 path assertion and `e2e/tests/tile_split_zoom.e2e.ts` to point at the new path/host.
- Remove the i18n key `flights.planner.satellite` from `src/i18n/en.json` and `src/i18n/ua.json` (the toggle that referenced it is gone). Verify no other call site references the key.
- Update `_docs/02_document/modules/src__features__flights.md` and `_docs/02_document/components/05_flights/description.md` to reflect the new tile source and the removed toggle.
- New blackbox test that asserts the `<TileLayer>` URL resolves to the env-var value AND that `crossOrigin="use-credentials"` is present on the rendered DOM element.
- New blackbox test that asserts the toggle button and the `mapType` state are absent from the rendered `FlightMap`.
### Excluded
- The `satellite-provider` server-side change to switch `/tiles/{z}/{x}/{y}` from JWT bearer to cookie authentication. Filed separately on the satellite-provider workspace; this task assumes that work lands first.
- Bringing back any street-tile fallback. Re-introducing OSM-style classic view is a future task.
- Pre-warming tile caches via `POST /api/satellite/request`. The SPA does not call that endpoint; on-demand server-side cache fill is sufficient.
- Refactoring `mission-planner/` map tiles. Task 2 handles `mission-planner` separately for OWM, and `mission-planner`'s tile config is independent (its own `VITE_SATELLITE_TILE_URL`).
- Adding `If-None-Match` / 304 handling on the consumer side. Leaflet's built-in caching is sufficient.
## Acceptance Criteria
**AC-1: Single env-var resolves the tile URL**
Given `VITE_SATELLITE_TILE_URL=http://satellite-provider:5100/tiles/{z}/{x}/{y}` at build time,
When `FlightMap` mounts,
Then the rendered `<TileLayer>` `url` prop equals that exact string.
**AC-2: Default URL when env var is unset**
Given `VITE_SATELLITE_TILE_URL` is unset at build time,
When the bundle runs,
Then `<TileLayer>` `url` resolves to `http://localhost:5100/tiles/{z}/{x}/{y}` (dev default, per the cycle-2 assumption-validation decision).
**AC-3: Cookie auth is wired**
Given the satellite-provider expects an `HttpOnly; SameSite=Lax` cookie,
When `<TileLayer>` issues a tile request via Leaflet,
Then the rendered `<img>` element exposes `crossOrigin="use-credentials"` so the browser sends the cookie on same-origin requests.
**AC-4: Map-type toggle removed**
Given `FlightMap` mounts,
When the user inspects the rendered output,
Then there is no toggle button, no `mapType` state, and `MiniMap`'s `Props` no longer accepts a `mapType` value.
**AC-5: Env declarations stay in sync**
Given a TypeScript build,
Then `ImportMetaEnv` declares only `VITE_SATELLITE_TILE_URL` (the two prior OSM/Esri vars are gone), and `.env.example` lists `VITE_SATELLITE_TILE_URL` in the same documented style.
**AC-6: E2E suite-e2e harness exercises the new path**
Given the e2e profile is brought up via `e2e/docker-compose.suite-e2e.yml`,
When the harness asserts the tile endpoint via `infrastructure.e2e.ts` AC-2,
Then the request URL is `http://<tile-host>:<port>/tiles/{z}/{x}/{y}` (not `/{z}/{x}/{y}.png` and not the `/sat/...` Esri shape), and the response is a 256×256 image.
**AC-7: Contract documented**
Given `_docs/02_document/contracts/satellite-provider/tiles.md` exists,
When `code-review` Phase 2 runs against this task,
Then the contract's `Shape` section matches the URL pattern and headers used by the rendered `<TileLayer>` and assert no `Spec-Gap` finding.
**AC-8: Legacy tile-aware tests still pass**
Given `tests/tile_split_zoom.test.tsx` and `e2e/tests/tile_split_zoom.e2e.ts` are updated to the new URL,
When the test suite runs,
Then both tests pass against the new tile-URL shape.
**AC-9: Architecture gate stays green**
Given the static-only profile runs (`scripts/run-tests.sh --static-only`),
When `STC-ARCH-01` and `STC-ARCH-02` execute,
Then no new cross-component import violation is introduced.
## Non-Functional Requirements
**Performance**
- A cold pan over an uncached region must not block the UI thread: the SPA must continue to render placeholders while `satellite-provider` downloads upstream tiles.
- The same tile URL viewed twice within a session MUST be served from the browser's HTTP cache (i.e., `Cache-Control` + `ETag` round-trip).
**Compatibility**
- The `MapContainer` / `TileLayer` API surface in `react-leaflet` is unchanged. No version bump.
- Production deploy MUST work behind the suite's nginx ingress on a single origin; cross-origin direct calls are explicitly NOT supported.
**Reliability**
- A 401 from the tile endpoint MUST NOT crash the map; it must render a broken-tile placeholder and the rest of the SPA must remain functional.
- A 503 from the tile endpoint (Google Maps upstream down) MUST be tolerated identically to 404.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Module-scope evaluation of `TILE_URL` with env mocked | Equals mocked value |
| AC-2 | Module-scope evaluation of `TILE_URL` with env unset | Equals dev default |
| AC-5 | TypeScript compilation against `ImportMetaEnv` | Compiles; no `VITE_OSM_TILE_URL` reference remains |
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|-------------------------|--------------|-------------------|----------------|
| AC-1 / AC-2 | env set / unset; `FlightMap` mounted | Rendered `<TileLayer>` `url` prop | Equals the resolved URL | Compat |
| AC-3 | `FlightMap` mounted | Rendered tile `<img>` element's `crossOrigin` attribute | `use-credentials` | Reliability |
| AC-4 | `FlightMap` mounted | DOM scan for `[data-testid="map-type-toggle"]` AND absence of `mapType` references | Toggle absent; `MiniMap.Props` has no `mapType` | UX |
| AC-6 | suite-e2e profile up | GET `http://<tile-host>:<port>/tiles/1/0/0` | 200 + image bytes | E2E determinism |
| AC-7 | Contract file present | Contract shape matches implementation | No `Spec-Gap` finding | Docs |
| AC-8 | tile_split_zoom tests updated | Run against new URL shape | Pass | Compat |
## Constraints
- Leaflet's `<TileLayer>` API surface MUST NOT change; only the `url` value, `crossOrigin` prop, and removal of the per-mode branching change.
- Same-origin deployment via nginx is the production assumption. Any setup that requires cross-origin cookies on tile requests is out of scope.
- No new third-party tile provider may be introduced as a fallback (would re-violate restriction E1).
## Risks & Mitigation
**Risk 1: Cross-workspace dependency (cookie auth on `/tiles/{z}/{x}/{y}`)**
- *Risk*: Until the satellite-provider workspace adds cookie auth, the endpoint returns 401 to the SPA in production. Merging the UI side first results in a broken map.
- *Mitigation*: The user files the satellite-provider-side ticket separately. This UI task is gated on that work landing. The task's deploy step (autodev Step 16) MUST verify both sides are in place before flipping prod traffic; suggested gate is a "tiles-render" smoke check in the deploy skill.
**Risk 2: Dev environment cookie scope (`localhost:5173` ↔ `localhost:5100`)**
- *Risk*: Once cookie auth is enforced, devs running the SPA at `localhost:5173` and satellite-provider at `localhost:5100` cannot send the auth cookie cross-port. Tiles will 401 in dev.
- *Mitigation*: Document the limitation in `_docs/02_document/deployment/environment_strategy.md`. Recommend local satellite-provider be run with auth disabled OR be reached through the suite's local nginx (same origin). This is an explicit trade-off the user accepted at cycle-2 assumption validation.
**Risk 3: UX regression — losing the classic (street) view**
- *Risk*: Pilots accustomed to OSM road context for ground-reference lose that view.
- *Mitigation*: Accepted by user choice (cycle-2 tile-scope = B). Tracked here so a future cycle can restore a street view via a different self-hosted source if demand arises.
**Risk 4: E2E flake during the tile-stub repurpose**
- *Risk*: Repurposing `e2e/stubs/tile/server.ts` to the new path may cause AZ-456 / AZ-474 / AZ-479 / AZ-480 e2e tests to flap during the transition.
- *Mitigation*: Land the suite-e2e compose change in the same PR as the source change so the harness is consistent in every commit. Add a short pre-flight check in `infrastructure.e2e.ts` that confirms the stub responds at the new path before downstream specs run.
**Risk 5: Silent broken-image rendering on auth failure**
- *Risk*: If cookie auth fails post-deploy, Leaflet renders blank tiles without surfacing a user-facing error.
- *Mitigation*: Add a `tileerror` listener on the `<MapContainer>` that, on the first error, logs a structured warning and (optionally) shows an inline banner ("Imagery unavailable; please re-sign-in"). This is a small follow-up; recommended as part of this task's deliverables but acceptable to defer to a follow-up if scope pressure builds.
## Contract
This task consumes the contract at `_docs/02_document/contracts/satellite-provider/tiles.md` (v1.0.0, status: draft).
The satellite-provider workspace owns producing/maintaining that contract. The UI MUST read that file — not this task spec — to discover the interface.
### Document Dependencies
- `_docs/02_document/contracts/satellite-provider/tiles.md` — slippy-tile API contract.
- `_docs/02_document/components/05_flights/description.md` — owning component description.
- `_docs/02_document/modules/src__features__flights.md` — module-layout mapping for the affected files.
@@ -0,0 +1,143 @@
# Externalize mission-planner OWM key + base URL; close AZ-482 source-scan gap
**Task**: AZ-499_mission_planner_weather_env
**Name**: mission-planner OWM env-var hardening
**Description**: Replace the hardcoded OpenWeatherMap API key and base URL in `mission-planner/src/services/WeatherService.ts` with Vite env vars (mirroring AZ-448 / AZ-449 on the main SPA), and close the AZ-482 source-scan gap that previously allowed the committed key to slip past the static check.
**Complexity**: 2 points
**Dependencies**: AZ-448 (Externalize OWM API key), AZ-449 (Externalize OWM base URL), AZ-482 (Secrets/banned-libs static check).
**Component**: 05_flights (mission-planner port-root)
**Tracker**: AZ-499
**Epic**: AZ-497
## Problem
`mission-planner/src/services/WeatherService.ts` lines 45 contain:
```ts
const apiKey = '335799082893fad97fa36118b131f919';
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;
```
Two issues:
1. **Compromised secret in source**: a real OpenWeatherMap API key is committed and has been in git history. Anyone with read access to the repo (or to any past mirror) can grab and abuse it.
2. **Hygiene gap**: AZ-448/AZ-449 closed the same pattern on the main SPA (`src/features/flights/flightPlanUtils.ts`), and AZ-482 was supposed to keep the key out via a static check. But AZ-482's `owm_key_in_dist` kind only scans the post-build `dist/` artifact, not the source tree, and only the main SPA bundle (not `mission-planner/`). STC-S5 keeps `mission-planner/` out of `dist/`, so today the key never reaches the bundle — but it remains plainly visible in source and survives every test run.
## Outcome
- `mission-planner/src/services/WeatherService.ts` reads `VITE_OWM_API_KEY` and `VITE_OWM_BASE_URL` from `import.meta.env`; never references the literal key.
- `getWeatherData` returns `null` when `VITE_OWM_API_KEY` is unset (same fail-soft contract as AZ-448 on the main SPA).
- `mission-planner/.env.example` and `mission-planner/src/vite-env.d.ts` declare both vars.
- A new banned-deps kind `owm_key_in_source` scans `src/` AND `mission-planner/` for the (now-rotated) old key literal and any future hardcoded fallback. STC-S? wires it into `scripts/run-tests.sh --static-only`.
- The compromised key `335799082893fad97fa36118b131f919` is revoked at the OpenWeatherMap dashboard out-of-band, before this task closes. The revocation is a deliverable, not just a recommendation.
## Scope
### Included
- `mission-planner/src/services/WeatherService.ts`: replace the two literals with `import.meta.env.VITE_OWM_API_KEY` and `import.meta.env.VITE_OWM_BASE_URL`; when the key is unset, return `null` without calling `fetch`.
- `mission-planner/.env.example`: add `VITE_OWM_API_KEY=<your-openweathermap-api-key>` and `VITE_OWM_BASE_URL=https://api.openweathermap.org/data/2.5`; mirror the docstring style of the main `.env.example`.
- `mission-planner/src/vite-env.d.ts`: add `VITE_OWM_API_KEY?: string` and `VITE_OWM_BASE_URL?: string`.
- `tests/security/banned-deps.json`: add a new `owm_key_in_source` kind:
- `ac`: NFT-SEC-09 (AC-1, source portion) — OpenWeatherMap key not present in source tree
- `scope`: `src/ and mission-planner/ (production sources; tests excluded)`
- `match`: `literal`
- `patterns`: `["335799082893fad97fa36118b131f919"]`
- `scripts/run-tests.sh`: add a new static-check row (e.g., `STC-S6`) that wires the new kind via `node scripts/check-banned-deps.mjs --kind=owm_key_in_source`.
- `_docs/02_document/modules/mission-planner.md` (or the closest existing mission-planner doc): note the env-var dependency under the WeatherService entry.
- Manual out-of-band: revoke the compromised key at `https://home.openweathermap.org/api_keys`; provision the new key in CI/dev `.env.local` for mission-planner.
### Excluded
- The broader F1 mission-planner deduplication work — tracked under its own future epic per `_docs/02_tasks/_dependencies_table.md` notes; this task is narrow security hygiene, not the duplication fix.
- Adding tests for `getWeatherData`'s `WeatherData` mapping logic (existing behavior, no test coverage today; out of scope here).
- Changing `getWeatherData`'s public signature.
## Acceptance Criteria
**AC-1: Env-var resolved API key**
Given `VITE_OWM_API_KEY=abc123` at build time,
When `getWeatherData(lat, lon)` is invoked,
Then the outgoing `fetch` URL contains `appid=abc123` and `units=metric`.
**AC-2: Env-var resolved base URL**
Given `VITE_OWM_BASE_URL=https://example.test/data/2.5` at build time,
When `getWeatherData(lat, lon)` is invoked,
Then the outgoing `fetch` URL starts with `https://example.test/data/2.5/weather?`.
**AC-3: Fail-soft when key is unset**
Given `VITE_OWM_API_KEY` is unset at build time,
When `getWeatherData(lat, lon)` is invoked,
Then no `fetch` is made and the function returns `null`.
**AC-4: Default base URL when only the URL var is unset**
Given `VITE_OWM_API_KEY` is set AND `VITE_OWM_BASE_URL` is unset at build time,
When `getWeatherData(lat, lon)` is invoked,
Then the outgoing URL falls back to `https://api.openweathermap.org/data/2.5/weather?...`.
**AC-5: Source-scan static check**
Given the static-only profile runs (`scripts/run-tests.sh --static-only`),
When the new `owm_key_in_source` check executes,
Then a fresh introduction of the literal `335799082893fad97fa36118b131f919` anywhere under `src/` or `mission-planner/` (excluding test files) FAILS the build; the migrated codebase passes.
**AC-6: Type declarations**
Given a TypeScript build of `mission-planner/`,
Then `ImportMetaEnv` includes `VITE_OWM_API_KEY?: string` and `VITE_OWM_BASE_URL?: string`.
**AC-7: Key revocation (deliverable)**
The previously-committed key `335799082893fad97fa36118b131f919` is revoked at the OpenWeatherMap dashboard. Closure of this AC is recorded in the implementation report by including a screenshot or a dashboard URL showing the key disabled — to keep the AC verifiable without re-exposing the new key.
## Non-Functional Requirements
**Security**
- The new key MUST never be committed; it lives only in `.env.local` (gitignored) for dev and in CI secrets for builds.
- The old key MUST be revoked at the OWM dashboard before this task is marked Done.
**Compatibility**
- `WeatherService.getWeatherData(lat, lon)` signature is preserved; callers see no behavioral change beyond `null` returned when the key is unset.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|------------------------------------------------------|---------------------------------------------------|
| AC-1 | env mocked with key only | URL contains `appid=<key>&units=metric` |
| AC-2 | env mocked with custom base URL | URL prefix matches the env-set base |
| AC-3 | env mocked with key unset | `getWeatherData` returns `null`; no `fetch` call |
| AC-4 | env mocked with key set, base URL unset | URL prefix = default production OWM base |
| AC-6 | TS compile against `ImportMetaEnv` | Compiles; new keys present, no `any` widening |
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------------------------------|--------------------------------------------------------------|------------------------------------|----------------|
| AC-5 | Static-only profile run | `check-banned-deps.mjs --kind=owm_key_in_source` | Pass on clean tree; fail on regression | NFT-SEC-09 |
## Constraints
- Do NOT change `WeatherService.getWeatherData`'s public signature.
- Do NOT add a new dependency to `mission-planner/package.json`. The change is configuration-only.
- The new banned-deps kind MUST follow the same JSON shape as existing entries in `tests/security/banned-deps.json` so `check-banned-deps.mjs` doesn't need branching logic.
## Risks & Mitigation
**Risk 1: New key leakage during rollout**
- *Risk*: The replacement OWM key could be committed by mistake when devs set it up locally.
- *Mitigation*: The new `owm_key_in_source` static check catches any literal value in source. Pair with a pre-commit hook (out of scope; flagged as a future improvement) for local enforcement.
**Risk 2: Mission-planner has no test runner today**
- *Risk*: `mission-planner/` doesn't have Vitest/Jest wired (module-layout.md note: tests TBD). The unit-test ACs above need a minimal test harness.
- *Mitigation*: Either (a) wire a minimal Vitest setup for `mission-planner/` (treat as a small in-task investment), or (b) move the unit-test ACs into integration coverage on the main SPA's harness if `mission-planner` shares a build context. Choose at implementation time; the simpler option wins.
**Risk 3: Revocation timing**
- *Risk*: If the old key is revoked before this code lands, every mission-planner build using the old key (dev/CI) breaks.
- *Mitigation*: Rotate the key AT THE SAME TIME the code change is merged: PR description includes the revocation timing; dev `.env.local` files updated in lock-step with merge.
## Contract
(Omitted — this task does not produce or consume an internal suite contract; OpenWeatherMap is an external 3rd-party API and its shape is owned by them.)
### Document Dependencies
- `_docs/02_tasks/done/AZ-448_refactor_owm_api_key.md` — main-SPA pattern this task mirrors.
- `_docs/02_tasks/done/AZ-449_refactor_owm_base_url.md` — same pattern for the base URL.
- `_docs/02_tasks/done/AZ-482_test_secrets_and_banned_libs.md` — the static-check scaffolding this task extends.