Files
Oleksandr Bezdieniezhnykh 8fca6e0209 [AZ-809] F-AZ809-1: cap geofences.polygons at 50 (security audit)
Closes the cycle-8 Medium DoS finding. Without the cap, an
authenticated caller could submit millions of bbox polygons in a
single 500 MiB request (Kestrel global limit) and saturate the
FluentValidation allocator on the validator hot path; each polygon
is ~90 bytes of JSON, so the body limit is not a useful gate.

Realistic use is 1-10 polygons per route — 50 leaves 5x headroom
while bounding the worst-case allocation.

Layers:
- CreateRouteRequestValidator: MaxPolygons = 50 + Must(...) chained
  before RuleForEach so the count error fires at "geofences.polygons"
  (not the leaf path).
- Unit: Validate_GeofencePolygonsTooMany_FailsCountRule.
- Integration: GeofencePolygonsTooMany_Returns400 (51 valid bbox
  polygons -> HTTP 400 + errors["geofences.polygons"]).
- Contract: route-creation.md -> v1.0.1 patch (tightening an
  existing range). New Inv-10, new geofence-polygons-too-many
  test case, changelog row.
- Test spec: BT-29 sub-case 9b + AZ-809 AC-1b row in the
  traceability matrix.
- Security report: F-AZ809-1 marked RESOLVED in cycle 8; verdict
  remains PASS_WITH_WARNINGS (Lows + carry-overs unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:29:10 +03:00

14 KiB

Contract: route-creation

Component: WebApi (SatelliteProvider.Api) producing rows via RouteManagement (SatelliteProvider.Services.RouteManagement) and feeding the background Route Map Processing flow (Flow F5) Producer task: AZ-809 — _docs/02_tasks/done/AZ-809_route_endpoint_validation.md (validator + this contract) Consumer tasks: gps-denied-onboard AZ-777 Phase 2 (preferred imagery-seeding path — route-based rather than bbox-based) Version: 1.0.1 Status: frozen Last Updated: 2026-05-23

Purpose

Defines the HTTP contract for POST /api/satellite/route — the route-onboarding endpoint that stores an ordered set of waypoints, interpolates intermediate points every ~200 m, and (optionally, when requestMaps=true) enqueues a region request per route point so background processing pre-fetches map tiles for the entire route corridor. Geofence polygons (optional) restrict which intermediate points get region-requests. Callers poll GET /api/satellite/route/{id} until mapsReady=true (when requestMaps=true) or read the response directly (when requestMaps=false).

This is v1.0.0 — published alongside AZ-809's validator landing. There is no prior contract document; the producer-doc surface before AZ-809 was modules/api_program.md::CreateRoute Handler + Flow F4 + Flow F5 only.

Endpoint

POST /api/satellite/route
Content-Type: application/json
Authorization: Bearer <JWT>

The request MUST carry a valid JWT (AZ-487). No permissions claim is required. Anonymous requests are rejected with HTTP 401.

Shape

Request body

{
  "id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
  "name": "derkachi-flight-1",
  "description": "AZ-777 Phase 2 seed route",
  "regionSizeMeters": 1000,
  "zoomLevel": 18,
  "points": [
    { "lat": 50.10, "lon": 36.10 },
    { "lat": 50.11, "lon": 36.11 }
  ],
  "geofences": {
    "polygons": [
      { "northWest": { "lat": 50.15, "lon": 36.05 },
        "southEast": { "lat": 50.05, "lon": 36.15 } }
    ]
  },
  "requestMaps": true,
  "createTilesZip": false
}

Per-field constraints:

Field Type Required Description Constraints
id UUID yes ([JsonRequired]) Caller-supplied idempotency key. POSTing twice with the same id returns the existing route resource. Non-zero GUID (validator rejects 00000000-...).
name string yes ([JsonRequired]) Human-readable route name (used in produced filenames). Length [1, 200]. Empty/whitespace rejected.
description string no Free-text description. Length [0, 1000] when present.
regionSizeMeters number yes ([JsonRequired]) Side length of the square region requested per route point. [100.0, 10000.0] (aligned with region-request.md::sizeMeters).
zoomLevel integer yes ([JsonRequired]) Slippy-map zoom level for region tiles. [0, 22].
points array yes ([JsonRequired]) Ordered waypoints. Server interpolates additional intermediate points every ~200 m between consecutive originals. Count [2, 500].
points[i].lat number yes ([JsonRequired]) WGS84 latitude. [-90.0, 90.0].
points[i].lon number yes ([JsonRequired]) WGS84 longitude. [-180.0, 180.0].
geofences object no When present, intermediate points outside ALL polygons get filtered before region enqueue. See nested shape below.
geofences.polygons array yes ([JsonRequired] when geofences present) One or more bbox polygons (NW corner + SE corner). Count [1, 50] when geofences present.
geofences.polygons[i].northWest object yes ([JsonRequired]) Polygon's northwest corner. See GeoPoint shape.
geofences.polygons[i].southEast object yes ([JsonRequired]) Polygon's southeast corner. See GeoPoint shape.
requestMaps bool yes ([JsonRequired]) When true, enqueue background region-requests for every route point inside the geofences (or all points if no geofences). No default — caller must declare intent.
createTilesZip bool yes ([JsonRequired]) When true, AFTER all region tiles are ready, package them into a ZIP at tilesZipPath. Requires requestMaps=true (can't zip what wasn't downloaded). No default. Cross-field invariant with requestMaps.

GeoPoint shape (used by northWest / southEast):

Field Type Required Constraints
lat number yes ([JsonRequired]) [-90.0, 90.0].
lon number yes ([JsonRequired]) [-180.0, 180.0].

Polygon corner cross-field invariant (GeofencePolygonValidator):

  • northWest.lat > southEast.lat (NW is genuinely north-of SE).
  • northWest.lon < southEast.lon (NW is genuinely west-of SE).

Response body (post-AC-2 unchanged from pre-AZ-809)

{
  "id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
  "name": "derkachi-flight-1",
  "description": "AZ-777 Phase 2 seed route",
  "regionSizeMeters": 1000,
  "zoomLevel": 18,
  "totalDistanceMeters": 132.4,
  "totalPoints": 3,
  "points": [
    { "latitude": 50.10, "longitude": 36.10, "pointType": "original", "sequenceNumber": 0, "segmentIndex": 0, "distanceFromPrevious": null },
    { "latitude": 50.105, "longitude": 36.105, "pointType": "intermediate", "sequenceNumber": 1, "segmentIndex": 0, "distanceFromPrevious": 66.2 },
    { "latitude": 50.11, "longitude": 36.11, "pointType": "original", "sequenceNumber": 2, "segmentIndex": 0, "distanceFromPrevious": 66.2 }
  ],
  "requestMaps": true,
  "mapsReady": false,
  "csvFilePath": null,
  "summaryFilePath": null,
  "stitchedImagePath": null,
  "tilesZipPath": null,
  "createdAt": "2026-05-22T14:00:00Z",
  "updatedAt": "2026-05-22T14:00:00Z"
}

Advisory AC-10: The response echoes points as {"latitude":..,"longitude":..} (legacy long form) but the request accepts {"lat":..,"lon":..} (OSM short form). This input/output asymmetry on the same RoutePoint round-trip is documented and intentional for v1.0.0 — fixing it would be a major contract break. A follow-up task can harmonize the response side.

Endpoint summary

Method Path Request Response Status codes
POST /api/satellite/route CreateRouteRequest body RouteResponse (route resource snapshot) 200, 400, 401

Error shape

All 400 responses conform to _docs/02_document/contracts/api/error-shape.md v1.0.0. Three sources produce identically-shaped ValidationProblemDetails bodies:

  1. Deserializer envelope (UnmappedMemberHandling.Disallow + [JsonRequired]) — rejects missing-required fields and unknown root/nested keys with errors[<path>] produced via GlobalExceptionHandler's JsonException path.
  2. CreateRouteRequestValidator — rejects non-zero-Id, name/description length, range checks on size / zoom / points-count, and the cross-field createTilesZip ⇒ requestMaps rule.
  3. RoutePointValidator + GeofencePolygonValidator — invoked via RuleForEach / SetValidator; rejects per-point lat/lon out-of-range, per-polygon corner out-of-range, and the NW-north-of-SE / NW-west-of-SE invariants.

Example body for a missing-id failure (probe-confirmed pre-AZ-809 silent zero-Guid coercion):

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "id": ["The JSON property 'id' is required, but a value was not supplied."]
  }
}

Example body for a nested per-point failure:

{
  "errors": {
    "points[1].lat": ["`lat` must be between -90 and 90."]
  }
}

Example body for a polygon corner invariant failure:

{
  "errors": {
    "geofences.polygons[0].northWest": ["`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE)."]
  }
}

Invariants

  • Inv-1: id is a non-zero GUID, supplied by the caller. Re-POST with the same id returns the existing route (idempotent contract per IdempotentPostTests).
  • Inv-2: points has at least 2 entries (Flow F4 precondition) and at most 500 entries (cap to prevent runaway region-enqueue).
  • Inv-3: Every points[i].lat ∈ [-90, 90] and points[i].lon ∈ [-180, 180].
  • Inv-4: regionSizeMeters ∈ [100, 10000] (aligned with region-request.md::sizeMeters).
  • Inv-5: zoomLevel ∈ [0, 22] (slippy-map range, aligned with region-request.md Inv-5 and tile-inventory.md Inv-8).
  • Inv-6 (cross-field): createTilesZip=true ⇒ requestMaps=true (can't zip what wasn't downloaded).
  • Inv-7 (per-polygon shape): northWest AND southEast corners both present.
  • Inv-8 (per-polygon invariant): northWest.lat > southEast.lat AND northWest.lon < southEast.lon.
  • Inv-9: Unknown root or nested fields → 400 (deserializer's UnmappedMemberHandling.Disallow).
  • Inv-10: When geofences is present, geofences.polygons.Count is in [1, 50]. Cap to bound validator allocation worst case — see _docs/05_security/security_report_cycle8.md § F-AZ809-1. Realistic use is 1-10 polygons per route; the 50 cap leaves 5x headroom.

Non-Goals

  • Not covered: route mutation. No PUT / PATCH endpoint exists; routes are immutable post-creation.
  • Not covered: background processing (Flow F5) — Flow F5 docs cover the region enqueue, tile download, ZIP packaging, and mapsReady transition.
  • Not covered: response field renaming. The input/output naming asymmetry (points[i].lat request vs points[i].latitude response) is acknowledged in AC-10 advisory and tracked for a future major contract bump.
  • Not covered: the legacy RouteValidator in SatelliteProvider.Services.RouteManagement — it remains as defense-in-depth for direct service-layer callers; its checks are now redundant with this contract but a separate cleanup task should consolidate.

Versioning Rules

  • Patch (1.0.x): Documentation clarifications, additional invariants that do not change wire behavior.
  • Minor (1.x.0): Adding an optional field consumers may safely ignore; relaxing a range; supporting a new geofence shape type alongside the existing bbox.
  • Major (2.0.0): Renaming any request or response field; tightening any existing range; harmonizing the response point names to lat/lon (resolves AC-10); changing the createTilesZip ⇔ requestMaps cross-field rule semantics.

Test Cases

Case Input Expected Notes
happy-path-no-maps full body with requestMaps=false HTTP 200 + RouteResponse (mapsReady=false, no background processing) AC-2
happy-path-with-maps full body with requestMaps=true HTTP 200; background F5 enqueues regions; GET /api/satellite/route/{id} shows mapsReady=true within ~20s for a 2-point 132m route at z=18 AC-2 + existing RouteCreationTests
empty-body "" HTTP 400 Rule 1
missing-id body without id HTTP 400 + errors[id] ("required") Rule 2 (probe-confirmed gap)
zero-guid-id "id":"00000000-..." HTTP 400 + errors[id] ("non-zero GUID") Rule 2
empty-name "name":"" HTTP 400 + errors[name] Rule 3
description-too-long "description":<1001 chars> HTTP 400 + errors[description] Rule 4
regionSize-out-of-range "regionSizeMeters":1000000 HTTP 400 + errors[regionSizeMeters] Rule 5
zoom-out-of-range "zoomLevel":30 HTTP 400 + errors[zoomLevel] Rule 6
points-too-few 1-point array HTTP 400 + errors[points] Rule 7 (Flow F4 precondition)
points-too-many 501-point array HTTP 400 + errors[points] Rule 7 (cap)
point-lat-out-of-range "points":[..., {"lat":91,..}] HTTP 400 + errors["points[1].lat"] Rule 8
point-lon-out-of-range "points":[..., {"lat":..,"lon":181}] HTTP 400 + errors["points[1].lon"] Rule 8
geofence-nw-not-north NW.lat == SE.lat HTTP 400 + errors["geofences.polygons[0].northWest"] Rule 9 / Inv-8
geofence-nw-not-west NW.lon == SE.lon HTTP 400 + errors["geofences.polygons[0].northWest"] Rule 9 / Inv-8
geofence-polygons-too-many 51-polygon array HTTP 400 + errors["geofences.polygons"] ("must contain at most 50 polygons.") Rule 9b / Inv-10 (F-AZ809-1 follow-up)
missing-requestMaps body without requestMaps HTTP 400 + errors[requestMaps] Rule 10
createTilesZip-without-requestMaps "requestMaps":false,"createTilesZip":true HTTP 400 + errors[createTilesZip] Rule 12 (cross-field)
unknown-root-field extra "debug":"..." key HTTP 400 + errors[debug] Rule 13 (UnmappedMemberHandling.Disallow)
point-lat-type-mismatch "points":[{"lat":"fifty",..}, ..] HTTP 400 (nested JSON error) Rule 14 (GlobalExceptionHandler)
auth-anonymous no Bearer token HTTP 401 Standard .RequireAuthorization()
idempotent-replay re-POST same id HTTP 200 (echoes existing resource) IdempotentPostTests AC-2

Change Log

Version Date Change Author
1.0.0 2026-05-22 Initial contract for POST /api/satellite/route. Publishes the FluentValidation surface (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) + the 14 rules in AZ-809, including the probe-confirmed missing-id gap and the cross-field createTilesZip ⇒ requestMaps invariant. References error-shape.md v1.0.0, region-request.md v1.0.0 (F5 enqueue path), and Flows F4/F5 (cross-link). autodev (Step 10, cycle 8)
1.0.1 2026-05-23 Tighten geofences.polygons constraint from "non-empty" to "Count [1, 50]". New Inv-10 + test case geofence-polygons-too-many. Patch release per Versioning Rules (tightening an existing range). The 50-polygon cap closes a defence-gap surfaced by the cycle-8 security audit: without the cap, an authenticated caller could submit millions of polygons in a single 500 MiB request and saturate the validator's allocation heap. Realistic use is 1-10 polygons per route. autodev (Step 14 follow-up, cycle 8)