mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 11:31:14 +00:00
8fca6e0209
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>
217 lines
14 KiB
Markdown
217 lines
14 KiB
Markdown
# 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
|
|
|
|
```jsonc
|
|
{
|
|
"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)
|
|
|
|
```jsonc
|
|
{
|
|
"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):
|
|
|
|
```jsonc
|
|
{
|
|
"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:
|
|
|
|
```jsonc
|
|
{
|
|
"errors": {
|
|
"points[1].lat": ["`lat` must be between -90 and 90."]
|
|
}
|
|
}
|
|
```
|
|
|
|
Example body for a polygon corner invariant failure:
|
|
|
|
```jsonc
|
|
{
|
|
"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) |
|