{
  "openapi": "3.0.3",
  "info": {
    "title": "MCrashCraft Alert System (MAS) API",
    "description": "Location-aware weather/hazard alerting API behind the MAS site (radar map, MAS Radio 24/7 broadcast, admin panel). Every location-aware endpoint accepts the SAME location parameters (see the `LocationParams` shared parameters below) and returns the SAME response envelope shape. Real data is used wherever a free, keyless, legal feed exists; a handful of endpoints fall back to clearly-labeled mock data only where no such feed exists (see each endpoint's `mock`/`note` fields at runtime) -- this spec documents the real routes and shapes, not a promise that every field is always non-mock.\n\n**Auth model (added 2026-07-22):** every read-only GET endpoint is fully public -- no key, no login. Anything that WRITES data (BOLO, reports, manual/test alerts) requires either an admin session (the web UI) or an API key, sent as `X-API-Key: <key>` or `Authorization: Bearer <key>`. Keys are generated in the admin panel (`/admin.html`) -- see the Admin (session required) tag's `/api/admin/api-keys` endpoints -- and only ever stored as a hash server-side; the real key is shown once, at creation.",
    "version": "1.0.0",
    "contact": { "name": "MCrashCraft", "email": "mas@mcrashcraft.com" }
  },
  "servers": [
    { "url": "https://mas.mcrashcraft.com", "description": "Primary (also reachable as https://mes.mcrashcraft.com during the MES->MAS transition)" },
    { "url": "http://localhost", "description": "Local/LAN" }
  ],
  "tags": [
    { "name": "Real-time feeds", "description": "Simple cached passthroughs of upstream public data sources, no location parameters." },
    { "name": "Location-aware", "description": "Accept the shared location parameters; return the standard envelope." },
    { "name": "Broadcast", "description": "The MAS Radio 24/7 live broadcast feed and its combined alert pipeline." },
    { "name": "Utility", "description": "Status, history, point conditions, CAP export, text-to-speech." },
    { "name": "Admin (session required)", "description": "Station-operator-only endpoints behind a login session ONLY -- never an API key, even for key management itself (a leaked key must never be able to mint more keys). Fail closed with 401/503 when not authenticated." },
    { "name": "Write access (session or API key)", "description": "Anything that WRITES or triggers a broadcast (BOLO, reports, manual/test alerts) -- accepts either the admin session OR a valid API key, Michael's own distinction between \"the UI\" and \"programmatic access.\" Generate/revoke keys in /admin.html (see the /api/admin/api-keys endpoints, which stay session-only)." }
  ],
  "components": {
    "securitySchemes": {
      "AdminSession": { "type": "apiKey", "in": "cookie", "name": "mwes_admin_session", "description": "Set by /api/admin/login. Browser UI use." },
      "ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "Generated in /admin.html. Programmatic/third-party write access." },
      "ApiKeyBearer": { "type": "http", "scheme": "bearer", "description": "Same key as ApiKeyHeader, alternate header form: Authorization: Bearer <key>." }
    },
    "parameters": {
      "lat": { "name": "lat", "in": "query", "schema": { "type": "number" }, "description": "Latitude, -90..90. Pair with `lon`. Highest-priority location method." },
      "lon": { "name": "lon", "in": "query", "schema": { "type": "number" }, "description": "Longitude, -180..180. Pair with `lat`." },
      "zip": { "name": "zip", "in": "query", "schema": { "type": "string", "pattern": "^\\d{5}$" }, "description": "US 5-digit ZIP code. Used if lat/lon not given." },
      "city": { "name": "city", "in": "query", "schema": { "type": "string" }, "description": "City name. Best paired with `state`. Used if lat/lon/zip not given." },
      "county": { "name": "county", "in": "query", "schema": { "type": "string" }, "description": "County name, alternative to `city`. Best paired with `state`." },
      "state": { "name": "state", "in": "query", "schema": { "type": "string" }, "description": "US state, full name or 2-letter postal code (e.g. `OH` or `Ohio`). Can be used alone to resolve a state centroid, or alongside city/county." },
      "category": { "name": "category", "in": "query", "schema": { "type": "string" }, "description": "Comma-separated: any of `weather`, `fire`, `emergency` (OR'd together). Added 2026-07-25 so external projects can subscribe to only the alerts they want on this one backend -- e.g. `?category=fire` for real Fire Warning-type alerts only. `emergency` covers AMBER/Blue/civil-emergency alerts. Omit for unfiltered (default) behavior." },
      "types": { "name": "types", "in": "query", "schema": { "type": "string" }, "description": "Comma-separated substrings matched against the alert's event name, independent of category (OR'd together) -- e.g. `?types=tornado,flood` returns anything with \"tornado\" or \"flood\" in the event name. Omit for unfiltered (default) behavior." }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "description": "The standard response shape for every location-aware endpoint.",
        "properties": {
          "endpoint": { "type": "string", "example": "/api/nws-alerts" },
          "status": { "type": "string", "enum": ["ok", "error"] },
          "generated": { "type": "string", "format": "date-time", "example": "2026-07-22T18:51:01+00:00" },
          "mock": { "type": "boolean", "description": "True only for the handful of endpoints/regions with no real free feed available; always clearly noted." },
          "location": {
            "type": "object", "nullable": true,
            "properties": {
              "lat": { "type": "number" }, "lon": { "type": "number" },
              "city": { "type": "string", "nullable": true }, "county": { "type": "string", "nullable": true },
              "state": { "type": "string", "nullable": true }, "label": { "type": "string", "example": "Lorain, Ohio" },
              "resolved_from": { "type": "string", "enum": ["latlon", "zip", "city", "county", "state"] }
            }
          },
          "count": { "type": "integer" },
          "data": { "type": "array", "items": {} },
          "note": { "type": "string", "description": "Present on several endpoints -- explains scope, honesty caveats, or mock-data reasoning in plain language." }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "example": "error" },
          "error": { "type": "string", "example": "lat/lon must be numeric" },
          "hint": { "type": "string", "example": "pass lat+lon, zip, city[&state], county[&state], or state" }
        }
      },
      "AdminErrorResponse": {
        "type": "object",
        "properties": { "ok": { "type": "boolean", "example": false }, "error": { "type": "string" } }
      }
    },
    "responses": {
      "LocationError": {
        "description": "Missing/invalid location parameters.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "NotAuthenticated": {
        "description": "No valid admin session cookie.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminErrorResponse" }, "example": { "ok": false, "error": "not authenticated" } } }
      },
      "NotConfigured": {
        "description": "The admin panel has never had a password set (fail-safe default -- every admin endpoint 503s until `python server.py --set-admin-password` or the trusted-network web setup form has been used once).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminErrorResponse" }, "example": { "ok": false, "error": "admin panel not configured" } } }
      }
    }
  },
  "paths": {
    "/api/status": {
      "get": {
        "tags": ["Utility"], "summary": "Server status + live endpoint list",
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": {
          "ok": true, "uptime_s": 184, "db": "weather-radar.sqlite", "retention_days": 7,
          "tts_available": true, "ai_forecast_configured": true,
          "endpoints": ["/api/alerts", "/api/amber", "...(generated live from the actual route table, never hand-maintained)"]
        } } } } }
      }
    },
    "/api/alerts": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Active US watches/warnings/advisories (real: NOAA WWA)",
        "description": "Simplified-geometry GeoJSON of every active NWS watch/warning/advisory polygon, US-wide. No location filter -- the map applies its own viewport/type filtering client-side.",
        "responses": { "200": { "description": "GeoJSON FeatureCollection", "content": { "application/json": { "example": {
          "type": "FeatureCollection", "count": 2056,
          "features": [ { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[-69.79, 45.74], [-69.69, 45.61]]] },
            "properties": { "prod_type": "Severe Thunderstorm Warning", "expiration": "2026-07-22T15:30:00-04:00", "onset": "2026-07-22T14:46:00-04:00", "url": "https://api.weather.gov/alerts/urn:oid:..." } } ]
        } } } } }
      }
    },
    "/api/amber": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Active AMBER (Child Abduction Emergency) alerts, US-wide (real: NWS CAE feed)",
        "responses": { "200": { "description": "GeoJSON FeatureCollection", "content": { "application/json": { "example": {
          "@context": { "@version": "1.1" }, "type": "FeatureCollection", "features": [],
          "title": "Current Child Abduction Emergency events", "updated": "2026-07-22T18:50:11+00:00"
        } } } } }
      }
    },
    "/api/quakes": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Earthquakes in the past 24h, worldwide (real: USGS)",
        "responses": { "200": { "description": "GeoJSON FeatureCollection", "content": { "application/json": { "example": {
          "type": "FeatureCollection", "metadata": { "title": "USGS All Earthquakes, Past Day", "count": 209 },
          "features": [ { "type": "Feature", "properties": { "mag": 4.8, "place": "Kermadec Islands region", "time": 1784744979600 } } ]
        } } } } }
      }
    },
    "/api/fires": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Confirmed active wildfire incidents (real: NIFC/WFIGS)",
        "responses": { "200": { "description": "GeoJSON FeatureCollection", "content": { "application/json": { "example": {
          "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": { "type": "Point", "coordinates": [-103.216293, 35.714159] },
            "properties": { "IncidentName": "Frontera", "IncidentSize": 45, "PercentContained": null, "POOState": "US-NM", "POOCounty": "Quay" } } ]
        } } } } }
      }
    },
    "/api/disasters": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Global disaster alerts (real: GDACS)",
        "responses": { "200": { "description": "GeoJSON FeatureCollection", "content": { "application/json": { "example": {
          "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "eventtype": "FL", "eventname": "Flood in United States",
            "description": "Flood in United States" } } ]
        } } } } }
      }
    },
    "/api/dispatch": {
      "get": {
        "tags": ["Real-time feeds"], "summary": "Recent public-safety dispatch calls, server-proxied (real: Seattle Fire 911 open-data CAD feed)",
        "description": "Public open-data dispatch logs, not call audio. No mock fallback -- if the feed is down it's simply absent, never simulated. Addresses are shown to the BLOCK level only (e.g. \"2800 Block of S Hanford St\"), never an exact house number, and coordinates are rounded to a ~500m grid cell -- never the exact reported location. This endpoint only proxies Seattle's feed; the live map additionally shows Austin, Calgary, San Francisco, and Montgomery County MD dispatch calls fetched directly client-side from each city's own open-data API (same censoring applied in the browser before display).",
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": {
          "count": 32, "note": "public CAD dispatch logs, not call audio -- address shown to the block level only, coordinates rounded to ~500m, never an exact residence",
          "calls": [ { "feed": "seattle-fire-911", "id": "F260102350", "type": "Aid Response", "address": "6700 Block of East Green Lake Way N",
            "datetime_local": "2026-07-22T11:26:00.000", "lat": 47.68, "lon": -122.33 } ]
        } } } } }
      }
    },
    "/api/amber-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "AMBER + Blue Alert, filtered to one location (real: NWS CAE feed)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Envelope" } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/nws-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "Official NWS watches/warnings/advisories for one location (real: api.weather.gov)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" }, { "$ref": "#/components/parameters/category" }, { "$ref": "#/components/parameters/types" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/nws-alerts", "status": "ok", "generated": "2026-07-22T18:51:01+00:00", "mock": false,
            "location": { "lat": 41.4528, "lon": -82.1824, "city": "Lorain", "county": "Lorain", "state": "OH", "label": "Lorain, Ohio", "resolved_from": "city" },
            "count": 0, "data": []
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/ipaws-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "Non-NWS IPAWS alerts (real county/state/tribal civil authorities; FEMA's public OpenFEMA archive, ~1 day lag)",
        "description": "NWS-originated IPAWS records are excluded here (they're the same alert /api/nws-alerts already carries) -- this is genuinely non-weather civil-authority alerts only (evacuation orders, shelter-in-place, etc.). An archive, not a live feed -- filtered to still-unexpired records so the lag can't show something as current that isn't.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" }, { "$ref": "#/components/parameters/category" }, { "$ref": "#/components/parameters/types" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Envelope" } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/hrrr-summary": {
      "get": {
        "tags": ["Location-aware"], "summary": "18-hour storm-risk model summary (real: Open-Meteo HRRR/GFS blend)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/hrrr-summary", "status": "ok", "mock": false,
            "data": [ { "id": "hrrr-41.4528--82.1824", "model": "HRRR/GFS blend (Open-Meteo /v1/gfs)", "horizon_hours": 18, "storm_risk": "none" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/storm-reports": {
      "get": {
        "tags": ["Location-aware"], "summary": "Today's tornado/hail/wind storm reports near a location (real: SPC)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/storm-reports", "status": "ok", "count": 12,
            "data": [ { "id": "spc-hail-000-61a1dc", "type": "hail", "magnitude": "1.00 in", "location": "3 NW Salem", "county": "Rockingham" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/tropical": {
      "get": {
        "tags": ["Location-aware"], "summary": "NHC tropical cyclone outlooks/tracks/watches, filtered near a location (real: NHC)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/tropical", "status": "ok", "count": 24,
            "data": [ { "id": "nhc-3-1", "kind": "outlook", "storm": null, "name": "Seven-Day: Potential Development Region", "basin": "Pacific" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/rivers": {
      "get": {
        "tags": ["Location-aware"], "summary": "River/stream gauge levels near a location, colored by flood stage (real: NOAA NWPS)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/rivers", "status": "ok", "count": 133,
            "data": [ { "id": "ELRO1", "name": "Black River at Elyria", "state": "OH", "lat": 41.380324, "lon": -82.10459, "distance_km": 10.3 } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/early-warnings": {
      "get": {
        "tags": ["Location-aware"], "summary": "MAS's own model-derived nowcast predictions, always labeled as forecasts (not official NWS warnings)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/early-warnings", "status": "ok", "count": 0, "data": [],
            "note": "MAS forecast events from HRRR/GFS model data — these are predictions with lead time, not current events"
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/safety-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "AMBER + Blue Alert with person/vehicle details extracted, for one location (real: official NWS feeds)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" }, { "$ref": "#/components/parameters/category" }, { "$ref": "#/components/parameters/types" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/safety-alerts", "status": "ok", "count": 0, "data": [],
            "note": "AMBER (Child Abduction Emergency) and Blue Alerts from the official NWS dissemination feeds, with person/vehicle details extracted"
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/noaa-radio": {
      "get": {
        "tags": ["Broadcast"], "summary": "One full NWR-style broadcast cycle for a location, including the live position",
        "description": "Returns the routine + hazard broadcast cycle script/segments for this location. The `live` block (added 2026-07-22) is the authoritative, server-side 24/7 clock: `current_segment_index`/`current_segment_name` name what's airing RIGHT NOW for this location, so a client can tune in mid-cycle instead of restarting from the top -- see MAINTENANCE.md's live-broadcast-feed entry. A cycle's content is only recomputed at a genuine cycle boundary, not on every request.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "noaa-radio", "status": "ok", "mock": false, "count": 1,
            "data": [ {
              "id": "nwr-43eadb136c-1",
              "script": "This is the Official MCrashCraft Alert System. M A S, broadcasting continuously, twenty-four hours a day, for Lorain, Ohio. The time is 2:31 PM ...",
              "segments": [ { "name": "station", "text": "..." }, { "name": "alerts", "tone": false, "text": "..." }, { "name": "conditions", "text": "..." }, { "name": "forecast", "text": "..." }, { "name": "outlook", "text": "..." }, { "name": "signoff", "text": "..." } ],
              "has_warning": false, "hazard_level": "none", "suspended": [],
              "alerts_included": 0, "alerts_spoken": 0, "alerts_available": true,
              "word_count": 260, "est_read_seconds": 102, "attention_tone_hz": 1050, "status": "ready",
              "live": {
                "cycle_seq": 1, "cycle_started_at": "2026-07-22T18:31:38.313438+00:00",
                "current_segment_index": 2, "current_segment_name": "conditions",
                "elapsed_in_segment_s": 10.3, "cycle_total_est_seconds": 102,
                "server_time": "2026-07-22T18:31:56.346332+00:00"
              }
            } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/goes-imagery": {
      "get": {
        "tags": ["Utility"], "summary": "Tile URL templates for MAS's live GOES-19 (GOES-East) satellite imagery layers (real: NASA GIBS)",
        "description": "No location needed -- these are global tile layers, republishing real NOAA GOES-19 ABI imagery already reprojected into standard web-map tiles by NASA GIBS (the same free, no-auth service this app's map already uses for its VIIRS 'Earth from space' basemap layer). `tile_url_template` uses `{Time}`=`default` for whatever is currently the latest available tile; GIBS updates roughly every 10 minutes, matching GOES-19 ABI's own scan schedule.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/goes-imagery", "status": "ok", "count": 2,
            "data": [
              { "product": "geocolor", "layer": "GOES-East_ABI_GeoColor", "tile_matrix_set": "GoogleMapsCompatible_Level7",
                "tile_url_template": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GOES-East_ABI_GeoColor/default/{Time}/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png" },
              { "product": "ir", "layer": "GOES-East_ABI_Band13_Clean_Infrared", "tile_matrix_set": "GoogleMapsCompatible_Level6",
                "tile_url_template": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GOES-East_ABI_Band13_Clean_Infrared/default/{Time}/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png" }
            ],
            "satellite": "GOES-19", "update_cadence": "~10 minutes, matching GOES-19 ABI's own CONUS/full-disk scan schedule"
          } } } }
        }
      }
    },
    "/api/glm-lightning": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real-time GOES-19 GLM (satellite lightning) flashes near a location (real: NOAA GOES-19 GLM L2 LCFA)",
        "description": "Satellite-based total lightning (in-cloud + cloud-to-ground), read directly from NOAA's public Open Data Dissemination (NODD) AWS bucket -- free, no API key. Complements, rather than replaces, this app's ground-based Blitzortung lightning layer. `flash_rate_trend` compares the last ~5 minutes to the preceding ~5 minutes near this point -- a rising rate is a genuine convective-intensification signal, also fed into MAS's own radar-based nowcast predictions (see /api/mwes-alerts' `basis: \"radar+goes\"` predictions).",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" },
          { "name": "radius_km", "in": "query", "schema": { "type": "number", "default": 150 }, "description": "Search radius in kilometers (10-400)." }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/glm-lightning", "status": "ok", "count": 3, "satellite": "GOES-19", "radius_km": 150,
            "data": [ { "lat": 41.42, "lon": -82.31, "energy_j": 6.9e-15, "age_s": 42.1, "distance_km": 12.4 } ],
            "flash_rate_trend": { "recent_count": 3, "prior_count": 0, "rising": true }
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/hourly-forecast": {
      "get": {
        "tags": ["Location-aware"], "summary": "NWS gridpoint hourly forecast, next 24 hours (real: api.weather.gov)",
        "description": "US locations only (api.weather.gov's own coverage limit) -- fails soft to an empty list elsewhere rather than erroring.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/hourly-forecast", "status": "ok", "count": 24,
            "data": [ { "start": "2026-07-22T23:00:00-04:00", "end": "2026-07-23T00:00:00-04:00", "temperature": 64, "temperature_unit": "F", "wind_speed": "2 mph", "short_forecast": "Partly Cloudy", "precip_probability_pct": 1, "relative_humidity_pct": 69 } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/buoys": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real-time marine buoy observations near a location (real: NOAA NDBC)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" },
          { "name": "radius_km", "in": "query", "schema": { "type": "number", "default": 300 }, "description": "Search radius in kilometers (20-800)." }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/buoys", "status": "ok", "count": 2,
            "data": [ { "id": "44097", "name": "Block Island, RI", "lat": 40.967, "lon": -71.124, "distance_km": 11.0, "wave_height_m": 2.4, "water_temp_c": 19.8 } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/tides": {
      "get": {
        "tags": ["Location-aware"], "summary": "Tide predictions for the nearest coastal station (real: NOAA CO-OPS)",
        "description": "Empty (with a note) for locations nowhere near the coast -- this is a genuinely coastal-only product.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/tides", "status": "ok", "count": 4, "station_id": "8454000", "station_name": "Providence, State Pier no.1",
            "data": [ { "time": "2026-07-22 02:50", "height_ft": 3.783, "type": "High" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/mesoscale-discussions": {
      "get": {
        "tags": ["Utility"], "summary": "Active SPC Mesoscale Discussions (real: NOAA SPC, via IEM's structured mirror)",
        "description": "Informal short-fuse discussions SPC issues ahead of a possible watch -- distinct from Tornado/Severe Thunderstorm Watches themselves, which already flow through /api/mwes-alerts and /api/nws-alerts as regular NWS/CAP alerts (not duplicated here). No location needed -- nationally only ever a handful active at once.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/mesoscale-discussions", "status": "ok", "count": 0, "data": []
          } } } }
        }
      }
    },
    "/api/cpc-outlook": {
      "get": {
        "tags": ["Location-aware"], "summary": "CPC 6-10 day temperature/precipitation outlook + seasonal drought outlook (real: NOAA CPC)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/cpc-outlook", "status": "ok", "count": 2,
            "data": [ { "kind": "temperature", "category": "Below", "probability_pct": 33 }, { "kind": "precipitation", "category": "Normal", "probability_pct": 36 } ],
            "drought_outlook": { "outlook": "No_Drought" }
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/snow-imagery": {
      "get": {
        "tags": ["Utility"], "summary": "Tile info for MAS's NOHRSC national snow analysis map layer (real: NOAA NOHRSC)",
        "description": "No location needed -- national raster layer, free WMS, no API key.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/snow-imagery", "status": "ok", "count": 2,
            "data": [ { "product": "snow_depth", "wms_layer": "0" }, { "product": "snow_water_equivalent", "wms_layer": "4" } ]
          } } } }
        }
      }
    },
    "/api/space-weather": {
      "get": {
        "tags": ["Utility"], "summary": "NOAA SWPC space weather: planetary K-index + active alerts + aurora visibility oval",
        "description": "No location needed -- global phenomena. `aurora_points` is NOAA's own OVATION model output, pre-filtered server-side to cells with >=10% aurora-visibility chance (the raw feed is a full 65,160-point world grid, mostly 0% at any given moment).",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/space-weather", "status": "ok", "count": 1,
            "planetary_k_index": { "time_tag": "2026-07-23T00:00:00", "Kp": 2.0 },
            "data": [ { "id": "A20F", "issued": "2026-07-22 19:25:10.567", "message": "WATCH: Geomagnetic Storm Category G1 Predicted..." } ],
            "aurora_points": [ [270, -81, 20] ]
          } } } }
        }
      }
    },
    "/api/velocity-imagery": {
      "get": {
        "tags": ["Utility"], "summary": "Real NEXRAD Level II Doppler velocity, point-sampled, for the KCLE (Cleveland) radar",
        "description": "No location needed -- fixed to NEXRAD_SITE (KCLE, this app's home coverage area). Real Level II velocity data (via Unidata THREDDS, free/no-key), point-sampled (~1km spacing along each radial, not a continuous image). Positive m/s = moving away from the radar, negative = toward it. Feeds MAS's rotation/tornado prediction (see /api/mwes-alerts' `basis: \"rotation\"` predictions) as well as this display layer.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/velocity-imagery", "status": "ok", "count": 35679, "site": "KCLE", "tilt_deg": 0.48,
            "data": [ { "lat": 41.3993, "lon": -81.8773, "velocity_ms": 2.0 } ]
          } } } }
        }
      }
    },
    "/api/canada-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real Environment Canada (ECCC/MSC) weather alerts near a lat/lon (Canada only)",
        "description": "Requires lat/lon (this app's US-only city/zip/county lookup can't resolve Canadian places, so `label` will just say \"the local area\" for a Canadian point -- the alert data itself is still real and correct). Source: MSC GeoMet's OGC API Features `weather-alerts` collection (api.weather.gc.ca), free, no auth.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/canada-alerts", "status": "ok", "count": 1,
            "data": [ { "event": "special weather statement", "area": "Annapolis County", "alert_type": "statement",
              "text": "Locations: most of mainland Nova Scotia...", "sent": "2026-07-22T17:51:50.038Z", "expires": "2026-07-23T09:51:50.038Z" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/canada-earthquakes": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real Canadian earthquakes near a lat/lon, last 30 days",
        "description": "Source: Earthquakes Canada / NRCan Atom+GeoRSS feed, free, no auth.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "name": "radius_km", "in": "query", "schema": { "type": "number", "default": 500 } } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/canada-earthquakes", "status": "ok", "count": 1,
            "data": [ { "title": "2026-07-22 08:40:57 UTC: M2.62 216 km SW of Port Hardy, BC", "lat": 49.3247, "lon": -129.5946, "distance_km": 12.4 } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/canada-wildfires": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real active Canadian wildfire hotspots near a lat/lon, satellite-detected, last 24h",
        "description": "Source: CWFIS (Canadian Wildland Fire Information System), free, no auth. `fire_weather_index` is the Canadian Fire Weather Index System's FWI value at that hotspot.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "name": "radius_km", "in": "query", "schema": { "type": "number", "default": 300 } } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/canada-wildfires", "status": "ok", "count": 1,
            "data": [ { "lat": 55.2408, "lon": -102.12189, "agency": "SK", "fire_weather_index": 18.07, "frp_mw": 6.23, "distance_km": 0.1 } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/europe-alerts": {
      "get": {
        "tags": ["Location-aware"], "summary": "Real MeteoAlarm CAP weather alerts for one European country",
        "description": "Per-country, not lat/lon-based (MeteoAlarm's feeds are one-per-country). `country` must be one of a verified-working list of ~23 countries (e.g. france, germany, united-kingdom, spain, italy, netherlands, belgium, portugal, austria, switzerland, ireland, poland, sweden, norway, denmark, finland, greece, hungary, romania, croatia, slovenia, slovakia, czechia) -- not exhaustive of every country MeteoAlarm covers, just the ones confirmed live. Source: feeds.meteoalarm.org, free, no auth.",
        "parameters": [ { "name": "country", "in": "query", "required": true, "schema": { "type": "string" }, "description": "e.g. france, germany, united-kingdom, italy, spain" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/europe-alerts", "status": "ok", "count": 1,
            "data": [ { "event": "Moderate high-temperature warning", "area": "Vaucluse", "severity": "Moderate", "urgency": "Future", "certainty": "Likely", "expires": "2026-07-24T22:00:00+00:00" } ]
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/mwes-alerts": {
      "get": {
        "tags": ["Broadcast"], "summary": "Combined alert feed: real NWS/IPAWS alerts + MAS's own predictions + any active admin test/manual alerts",
        "description": "Every item is tagged `official: true/false` and `source` (`nws`, `ipaws`, `mwes-prediction`, `mwes-test`, `mwes-manual`). MAS's own predictions always carry the MWES_PREDICTION_LABEL text so they can never be mistaken for an official warning even if a consumer only reads free text. This is what radio.html's watchTick polls every 60s to decide whether to interrupt the broadcast with a tone + announcement. Supports `?category=`/`?types=` filtering (added 2026-07-25) so an external project can subscribe to only the alerts it wants from this one backend, e.g. only weather, or only fire -- see those parameters below. AMBER/Blue civil alerts specifically live on /api/safety-alerts (also filterable), not merged into this array, to avoid changing this endpoint's existing shape for consumers already using it.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" }, { "$ref": "#/components/parameters/category" }, { "$ref": "#/components/parameters/types" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": {
            "endpoint": "/api/mwes-alerts", "status": "ok", "count": 0, "data": [],
            "note": "MAS combined alert feed: official National Weather Service alerts (source: nws, official: true) plus MAS-generated nowcast predictions built from HRRR model data and the Storm Prediction Center's outlook (source: mwes-prediction, official: false, label: 'MAS PREDICTION — NOT AN OFFICIAL NWS WARNING'). Predictions must always be shown/spoken with that label — never as an official government warning."
          } } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/point": {
      "get": {
        "tags": ["Utility"], "summary": "Current conditions + air quality + 24h outlook for one point (real: Open-Meteo)",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" } ],
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": {
          "weather": { "latitude": 41.45495, "longitude": -82.19452, "timezone": "America/New_York",
            "current": { "time": "2026-07-22T14:45" } }
        } } } } }
      }
    },
    "/api/history": {
      "get": {
        "tags": ["Utility"], "summary": "Everything the server has seen and stored in the last N hours (SQLite, 7-day retention)",
        "parameters": [
          { "name": "hours", "in": "query", "schema": { "type": "integer", "default": 168 }, "description": "Lookback window." },
          { "name": "kind", "in": "query", "schema": { "type": "string", "enum": ["warning", "amber", "dispatch", "fire", "prediction", "tropical"] }, "description": "Filter to one kind; omit for all. \"tropical\" is one row per active NHC storm per advisory (basin+storm_num+advisory_num) -- query it over time for a genuine position/intensity track, not just the latest position." }
        ],
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": {
          "count": 1927, "hours": 24,
          "rows": [ { "id": "warn|https://api.weather.gov/alerts/urn:oid:...", "kind": "warning", "event": "Flash Flood Warning" } ]
        } } } } }
      }
    },
    "/api/cap": {
      "get": {
        "tags": ["Utility"], "summary": "The combined alert feed re-emitted as real CAP v1.2 XML",
        "description": "Without `id`: every current alert wrapped in an MAS-only `<mwesCapFeed>` container (CAP has no standard multi-alert envelope). With `?id=<alert id>`: that one alert as its own standalone, fully spec-compliant CAP document. MAS predictions always emit `status=\"Exercise\"` (never \"Actual\") with a `<sender>` naming MAS, not NWS.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/county" }, { "$ref": "#/components/parameters/state" },
          { "name": "id", "in": "query", "schema": { "type": "string" }, "description": "Return just this one alert's id as a standalone CAP document." }
        ],
        "responses": {
          "200": { "description": "CAP v1.2 XML", "content": { "application/cap+xml": { "example": "<?xml version='1.0' encoding='utf-8'?>\n<mwesCapFeed generated=\"2026-07-22T18:51:19+00:00\" note=\"...\" />" } } },
          "400": { "$ref": "#/components/responses/LocationError" }
        }
      }
    },
    "/api/tts": {
      "post": {
        "tags": ["Utility"], "summary": "Synthesize speech via Kokoro (local neural TTS), cached by text hash",
        "description": "Falls back to 503 if Kokoro isn't installed/set up or synthesis fails for any reason. There is deliberately no device/browser-voice fallback (the client, assets/site.js WX.speakServer, retries a couple of times and then simply doesn't speak that chunk rather than switching to a different-sounding voice) -- see MAINTENANCE.md's \"Kokoro TTS + no device voice\" entry.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["text"], "properties": { "text": { "type": "string", "maxLength": 2000 } } } } } },
        "responses": {
          "200": { "description": "MP3 audio", "content": { "audio/mpeg": {} } },
          "503": { "description": "TTS unavailable", "content": { "application/json": { "example": { "ok": false, "error": "tts unavailable" } } } }
        }
      }
    },
    "/api/bolo": {
      "get": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Active BOLO vehicle entries (admin session or API key)",
        "description": "Moved behind the admin login 2026-07-22 -- previously public. Auto-extracted from AMBER + manual entries.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "count": 0, "note": "hobby BOLO board: AMBER-derived + manual entries", "entries": [] } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" },
          "503": { "$ref": "#/components/responses/NotConfigured" }
        }
      },
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Add a manual BOLO entry (admin session or API key)",
        "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": {
          "vehicle": { "type": "string", "maxLength": 90 }, "plate": { "type": "string", "maxLength": 12 }, "details": { "type": "string", "maxLength": 200 }
        } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      }
    },
    "/api/reports": {
      "get": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Reports & Records: feed-ingested AMBER/Blue alerts + manual entries (admin session or API key)",
        "description": "Moved behind the admin login 2026-07-22 -- previously public.",
        "parameters": [ { "name": "status", "in": "query", "schema": { "type": "string" }, "description": "Filter to one status (e.g. active)." } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "count": 0, "note": "MAS reports/records: feed-ingested AMBER/Blue alerts + manual entries; active reports stay until cancelled", "reports": [] } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" },
          "503": { "$ref": "#/components/responses/NotConfigured" }
        }
      },
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Create a report, or update an existing one's status (admin session or API key)",
        "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": {
          "type": { "type": "string", "enum": ["amber", "missing-child", "missing-adult", "blue", "bolo"] },
          "id": { "type": "integer", "description": "Provide with `status` to update an existing report instead of creating one." },
          "status": { "type": "string" }, "person_name": { "type": "string" }, "child_name": { "type": "string" },
          "description": { "type": "string" }, "vehicle": { "type": "string" }, "plate": { "type": "string" },
          "city": { "type": "string" }, "state": { "type": "string" }
        } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "id": 42, "lat": 41.4528, "lon": -82.1824 } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      }
    },
    "/api/admin/session": {
      "get": {
        "tags": ["Admin (session required)"], "summary": "Current admin session status (no auth required to CHECK status itself)",
        "description": "`username` is only ever non-null when actually needed -- an already-logged-in session, or first-time setup from a trusted network -- never revealed to an unauthenticated visitor just for opening the login page (2026-07-22 hardening).",
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": {
          "configured": true, "logged_in": false, "username": null, "trusted_for_setup": true
        } } } } }
      }
    },
    "/api/admin/setup": {
      "post": {
        "tags": ["Admin (session required)"], "summary": "First-time password creation from the web UI",
        "description": "Only usable while unconfigured (can never overwrite an existing password), and only from a trusted network (localhost/LAN/Tailscale) -- rejected from the public internet even if reachable via Funnel/Serve.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["password", "password2"], "properties": { "password": { "type": "string", "minLength": 8 }, "password2": { "type": "string" } } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true } } } },
          "403": { "description": "Not a trusted connection" },
          "409": { "description": "Already configured" }
        }
      }
    },
    "/api/admin/login": {
      "post": {
        "tags": ["Admin (session required)"], "summary": "Log in, sets a session cookie",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["username", "password"], "properties": { "username": { "type": "string" }, "password": { "type": "string" } } } } } },
        "responses": {
          "200": { "description": "OK, Set-Cookie issued" },
          "401": { "description": "Wrong username or password (one generic error either way)" },
          "429": { "description": "Too many failed attempts from this IP (5/15 min lockout)" }
        }
      }
    },
    "/api/admin/logout": { "post": { "tags": ["Admin (session required)"], "summary": "Clear the session cookie", "responses": { "200": { "description": "OK" } } } },
    "/api/admin/active": {
      "get": { "tags": ["Admin (session required)"], "summary": "Currently active tests/manual alerts", "responses": {
        "200": { "description": "OK" }, "401": { "$ref": "#/components/responses/NotAuthenticated" } } }
    },
    "/api/admin/test": {
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Queue a generic test broadcast (plain tone, never the alarm)",
        "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "message": { "type": "string", "maxLength": 300 } } } } } },
        "responses": { "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "id": 12 } } } }, "401": { "$ref": "#/components/responses/NotAuthenticated" } }
      }
    },
    "/api/admin/test-alert-type": {
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Queue a TEST of one specific alert type's real tone/repeat behavior",
        "description": "Added 2026-07-22. Looks up the given event's real SAME code and alarm-tier classification (the exact same rule a genuine alert of that type gets) so the test fires the CORRECT tone automatically -- loud two-tone EAS repeated twice for Warning-tier/civil-emergency types, the lighter single beep for Watches/routine tests. Spoken text always says \"THIS IS A TEST... NOT a real [type]\", never the real \"announcing [event]\" opening. Optional city/state/radius_km (added 2026-07-22) geo-scope the test the same way a real manual alert is scoped -- see /api/admin/manual-alert; omitted, the test is unscoped (audible everywhere) for a quick sound check.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["event"], "properties": {
          "event": { "type": "string", "example": "Tornado Warning", "description": "Any event name from the real NWR event-code catalog (see admin.html's dropdown for the curated list), or \"Required Weekly Test\"/\"Required Monthly Test\"." },
          "city": { "type": "string", "description": "Optional target city -- geo-scopes the test. Omit for an unscoped, everywhere-audible test." },
          "state": { "type": "string" }, "radius_km": { "type": "number", "default": 50, "minimum": 5, "maximum": 500 }
        } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "id": 13, "is_alarm_tier": true, "area": "Grafton", "radius_km": 50 } } } },
          "400": { "description": "Missing event, or unresolvable city/state" },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      }
    },
    "/api/admin/manual-alert": {
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "Broadcast a real operator-issued alert",
        "description": "Geo-scoped (added 2026-07-22, Michael's ask): only heard by listeners within radius_km of the target city -- an alert for Grafton, OH isn't heard in Pittsburgh or Columbus. Tone is driven by the event's real alarm-tier classification (same rule /api/admin/test-alert-type uses) OR'd with severity===\"Warning\" -- whichever says louder wins, so picking a real Warning-tier event always gets the alarm even if severity was left at its default.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["event", "message", "city"], "properties": {
          "event": { "type": "string" }, "message": { "type": "string", "maxLength": 600 },
          "severity": { "type": "string", "enum": ["Advisory", "Watch", "Warning"], "default": "Advisory" },
          "city": { "type": "string" }, "state": { "type": "string" },
          "radius_km": { "type": "number", "default": 50, "minimum": 5, "maximum": 500, "description": "Coverage radius around the target city -- only listeners within this range hear the tone/announcement." },
          "minutes": { "type": "integer", "minimum": 5, "maximum": 1440, "default": 60 }
        } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "id": 7, "area": "Grafton", "same_code": "039093", "radius_km": 50, "tone": true } } } },
          "400": { "description": "Validation error (missing field, unresolvable location, bad severity)" },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      }
    },
    "/api/admin/cancel": {
      "post": {
        "tags": ["Write access (session or API key)"], "security": [{"AdminSession": []}, {"ApiKeyHeader": []}, {"ApiKeyBearer": []}], "summary": "End a test/manual alert early",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } } } } },
        "responses": { "200": { "description": "OK" }, "401": { "$ref": "#/components/responses/NotAuthenticated" } }
      }
    },
    "/api/admin/api-keys": {
      "get": {
        "tags": ["Admin (session required)"], "summary": "List API keys (metadata only -- never the real key)",
        "description": "Added 2026-07-22. Returns id/name/key_prefix/created/last_used/revoked for every key ever generated. The real key can never be retrieved again after creation -- only a SHA-256 hash is stored.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "keys": [
            { "id": 1, "name": "Weather dashboard integration", "key_prefix": "mas_a1B2c3D4…", "created": 1784700000, "last_used": 1784750000, "revoked": false }
          ] } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      },
      "post": {
        "tags": ["Admin (session required)"], "summary": "Generate a new API key",
        "description": "Session-only -- never key-accessible itself, so a leaked key alone can never mint more keys. The full key is returned exactly once, here; save it immediately, it cannot be shown again.",
        "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string", "maxLength": 80, "example": "Weather dashboard integration" } } } } } },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "id": 1, "name": "Weather dashboard integration", "key": "mas_a1B2c3D4...(only ever shown this once)", "warning": "This is the only time the full key is shown — save it now." } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" }
        }
      }
    },
    "/api/admin/api-keys/revoke": {
      "post": {
        "tags": ["Admin (session required)"], "summary": "Revoke an API key by id",
        "description": "Session-only. Immediate -- any request already using that key fails its next auth check.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } } } } },
        "responses": { "200": { "description": "OK" }, "401": { "$ref": "#/components/responses/NotAuthenticated" } }
      }
    },
    "/api/admin/ai-forecast": {
      "get": {
        "tags": ["Admin (session required)"], "summary": "AI-written forecast discussion from a local LLM (LM Studio) — internal/operator use only",
        "description": "Never shown on the public site. Built from MAS's own real data (current conditions, active alerts, the real NWS forecast text, the HRRR outlook); the model is explicitly told never to invent conditions/numbers. Always labeled AI_FORECAST_LABEL, never presented as an official NWS product. 503s with a plain explanation if LM Studio isn't configured/reachable/loaded.",
        "parameters": [ { "$ref": "#/components/parameters/lat" }, { "$ref": "#/components/parameters/lon" }, { "$ref": "#/components/parameters/zip" }, { "$ref": "#/components/parameters/city" }, { "$ref": "#/components/parameters/state" } ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "example": { "ok": true, "label": "MAS AI Forecast — generated by a local AI model, not an official National Weather Service forecast.", "text": "..." } } } },
          "401": { "$ref": "#/components/responses/NotAuthenticated" },
          "503": { "description": "LM Studio unavailable" }
        }
      }
    }
  }
}
