openapi: 3.1.0
info:
  title: ProjectSkygrid API
  version: 0.9.0
  description: |
    API contract for ProjectSkygrid — airspace intelligence, anomaly detection, and watch-area alerting.
    Production ingest policy requires professional upstream providers (ADS-B Exchange, Aviation Edge)
    with OpenSky treated as non-production fallback only.
    The backend runs at https://skygrid-backend-wvuwn.ondigitalocean.app.
    The engine (harvester/worker) runs at https://skygrid-engine-mt9jd.ondigitalocean.app.
servers:
  - url: https://www.projectskygrid.com
    description: Production
  - url: http://localhost:8080
    description: Local development
security:
  - bearerAuth: []
paths:
  /api/v1/ops/uptime:
    get:
      summary: Service uptime and process start metadata
      operationId: getUptime
      tags: [Operations]
      security: []
      responses:
        "200":
          description: Uptime payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UptimeResponse"
  /api/v1/ops/slo:
    get:
      summary: Runtime SLO dashboard metrics
      operationId: getSLOStatus
      tags: [Operations]
      security:
        - publicApiKey: []
      responses:
        "200":
          description: Process lifetime latency and error rate metrics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SLOStatusResponse"
        "401":
          description: Missing or invalid public API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /healthz:
    get:
      summary: Service health check
      operationId: getHealth
      responses:
        "200":
          description: Service is healthy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
  /readyz:
    get:
      summary: Service readiness check
      operationId: getReadiness
      responses:
        "200":
          description: Service is ready to accept traffic
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadyResponse"
  /metrics:
    get:
      summary: Prometheus exposition metrics
      operationId: getMetrics
      tags: [Operations]
      security: []
      description: |
        Returns process-level metrics in Prometheus text exposition format.
        Intended for scraping by Prometheus, Grafana Agent, or compatible collectors.
      responses:
        "200":
          description: Prometheus text exposition payload
          content:
            text/plain:
              schema:
                type: string

  /api/v1/network/live:
    get:
      summary: Public live network snapshot
      operationId: listLiveNetwork
      tags: [Network]
      security: []
      description: |
        Returns recently active ground stations with privacy-preserving coordinate fuzzing.
        Latitude/longitude are rounded to one decimal place (~11km precision).
      responses:
        "200":
          description: Active nodes seen in the last 24 hours
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/LiveNetworkNodeResponse"
        "500":
          description: Server error while loading live network
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/network/stats:
    get:
      summary: Public network activity stats
      operationId: getNetworkStats
      tags: [Network]
      security: []
      description: Returns aggregate active node and satellite counts for the last 24 hours.
      responses:
        "200":
          description: Public network stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NetworkStatsResponse"
        "500":
          description: Server error while loading network stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/network/mission-summary:
    get:
      summary: Public mission summary metrics
      operationId: getMissionSummary
      tags: [Network]
      security: []
      description: Returns the live aircraft/anomaly snapshot plus platform watch-area and alert-routing totals.
      responses:
        "200":
          description: Mission summary metrics
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MissionSummaryResponse"
        "500":
          description: Server error while loading mission summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/network/anomaly-leaderboard:
    get:
      summary: Historical anomaly leaderboard metrics
      operationId: getHistoricalAnomalyLeaderboard
      tags: [Network]
      security: []
      description: Returns persisted historical anomaly leaderboards for regions, countries, and worst-offender aircraft over a selectable time window.
      parameters:
        - in: query
          name: window
          required: false
          schema:
            type: string
            enum: [24h, 7d, 30d]
            default: 7d
          description: Historical window used for leaderboard aggregation.
        - in: query
          name: type
          required: false
          schema:
            type: string
            enum: [all, loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
            default: all
          description: Optional anomaly-type filter applied before ranking.
        - in: query
          name: offenderQuery
          required: false
          schema:
            type: string
            maxLength: 64
          description: Optional callsign or ICAO substring filter for worst-offender results.
        - in: query
          name: quality
          required: false
          schema:
            type: string
            enum: [clean, raw]
            default: clean
          description: clean applies false-positive suppression heuristics; raw returns unfiltered historical events.
      responses:
        "200":
          description: Historical anomaly leaderboard payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HistoricalAnomalyLeaderboardResponse"
        "400":
          description: Invalid query parameter value
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Server error while loading anomaly leaderboard
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/roadmap/phases:
    get:
      summary: Phased release framework
      operationId: getReleasePhases
      tags: [Operations]
      security: []
      description: Returns the active and upcoming release phases for sequential rollout.
      responses:
        "200":
          description: Current framework and phase states
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReleaseFrameworkResponse"
  /api/v1/admin/release-phase:
    get:
      summary: Get active release phase
      operationId: getAdminReleasePhase
      tags: [Operations]
      security:
        - adminApiKey: []
      parameters:
        - in: header
          name: X-Admin-API-Key
          required: true
          schema:
            type: string
            minLength: 1
      responses:
        "200":
          description: Active release phase
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminReleasePhaseResponse"
        "401":
          description: Missing or invalid admin API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    put:
      summary: Promote active release phase
      operationId: updateAdminReleasePhase
      tags: [Operations]
      security:
        - adminApiKey: []
      parameters:
        - in: header
          name: X-Admin-API-Key
          required: true
          schema:
            type: string
            minLength: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminReleasePhaseUpdateRequest"
      responses:
        "200":
          description: Release phase updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminReleasePhaseResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid admin API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/maritime:
    get:
      summary: Enterprise maritime intelligence snapshot
      operationId: getMaritimeIntelligence
      tags: [Network]
      security:
        - publicApiKey: []
      description: Returns phase-gated maritime intelligence metrics derived from current network activity.
      responses:
        "200":
          description: Maritime intelligence payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MaritimeIntelligenceResponse"
        "401":
          description: Missing or invalid public API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Server error while loading maritime intelligence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/maritime/ais-packets:
    post:
      summary: Ingest raw AIS packet from node decoder
      operationId: ingestAISPacket
      tags: [Telemetry]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AISPacketIngestRequest"
      responses:
        "202":
          description: AIS packet accepted and decoded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AISPacketIngestResponse"
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Database unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/rf-intelligence:
    get:
      summary: Enterprise RF intelligence snapshot
      operationId: getRFIntelligence
      tags: [Network]
      security:
        - publicApiKey: []
      description: Returns phase-gated RF baseline metrics and anomaly indicators.
      responses:
        "200":
          description: RF intelligence payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RFIntelligenceResponse"
        "401":
          description: Missing or invalid public API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "500":
          description: Server error while loading RF intelligence
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/rf-intelligence/sweeps:
    post:
      summary: Ingest RF sweep summary from idle scan window
      operationId: ingestRFSweep
      tags: [Telemetry]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RFSweepIngestRequest"
      responses:
        "202":
          description: RF sweep accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RFSweepIngestResponse"
        "400":
          description: Invalid payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Database unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/v1/enterprise/composites/latest:
    get:
      summary: Latest stitched enterprise composite
      operationId: getLatestEnterpriseComposite
      tags: [Network]
      security: []
      description: Returns the latest stitched composite summary payload over the most recent 6 hour window.
      responses:
        "200":
          description: Latest stitched composite payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnterpriseCompositeResponse"
  /api/v1/enterprise/composites:
    get:
      summary: List recent stitched enterprise composites
      operationId: listEnterpriseComposites
      tags: [Network]
      security: []
      description: Returns recent stitched composite manifests, newest first.
      responses:
        "200":
          description: Recent stitched composite manifests
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/EnterpriseCompositeManifestResponse"
  /api/v1/enterprise/composites/{compositeId}:
    get:
      summary: Get stitched enterprise composite manifest
      operationId: getEnterpriseCompositeById
      tags: [Network]
      security: []
      parameters:
        - in: path
          name: compositeId
          required: true
          schema:
            type: string
            minLength: 1
      responses:
        "200":
          description: Composite manifest and source inputs
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnterpriseCompositeManifestResponse"
        "404":
          description: Composite not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/operators/leaderboard:
    get:
      summary: Top operators by successful captures
      operationId: getOperatorLeaderboard
      tags: [Network]
      security: []
      description: Returns top nodes sorted by capture count over the last 7 days.
      responses:
        "200":
          description: Operator leaderboard
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/OperatorLeaderboardEntry"
  /api/v1/passes/predict:
    get:
      summary: Predict next satellite passes by city or ZIP
      operationId: predictNextPasses
      tags: [Network]
      security: []
      parameters:
        - in: query
          name: q
          required: true
          schema:
            type: string
            minLength: 1
          description: Worldwide location query. Accepts city, zip, or lat,lon (for example New York, 10001, 48.8566,2.3522).
      responses:
        "200":
          description: Next 3 predicted passes
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/NextPassPrediction"
        "400":
          description: Invalid location query
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/enterprise/sandbox:
    get:
      summary: Request enterprise sandbox dataset link
      operationId: requestEnterpriseSandbox
      tags: [Network]
      security:
        - publicApiKey: []
      parameters:
        - in: query
          name: email
          required: true
          schema:
            type: string
            format: email
          description: Buyer or evaluator email used for lead capture.
        - in: query
          name: company
          required: false
          schema:
            type: string
          description: Optional company name for lead qualification.
        - in: query
          name: use_case
          required: false
          schema:
            type: string
          description: Optional use case summary.
      responses:
        "200":
          description: Time-bound sandbox dataset download URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SandboxDatasetResponse"
        "400":
          description: Invalid email parameter
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /api/network/engine-stats:
    get:
      summary: Engine health and live stats
      operationId: getEngineStats
      tags: [Network]
      security: []
      description: |
        Returns real-time stats from the skygrid-engine service.
        This is a server-side proxy — the frontend forwards the request to ENGINE_API_BASE_URL/api/stats
        so callers do not need direct access to the engine host.
      responses:
        "200":
          description: Engine stats payload
          content:
            application/json:
              schema:
                type: object
                required: [trackedAircraft, activeAnomalies, pollCounter, pollIntervalMs]
                properties:
                  trackedAircraft:
                    type: integer
                    description: Number of aircraft currently being tracked
                  activeAnomalies:
                    type: integer
                    description: Number of active anomalies detected in the current window
                  pollCounter:
                    type: integer
                    description: Total number of upstream provider polls since engine start
                  pollIntervalMs:
                    type: integer
                    description: Current configured poll interval in milliseconds
  /api/network/engine-stream:
    get:
      summary: Engine anomaly SSE stream (proxied)
      operationId: getEngineStream
      tags: [Network]
      security: []
      description: |
        Server-Sent Events stream of raw anomaly events from the skygrid-engine service.
        This is a server-side proxy to ENGINE_API_BASE_URL/api/stream/telemetry.
        Each SSE event carries an anomaly detection payload.
      responses:
        "200":
          description: SSE stream established
          content:
            text/event-stream:
              schema:
                type: string
        "503":
          description: Engine stream unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/network/aircraft-snapshot:
    get:
      summary: Current aircraft positions snapshot
      operationId: getAircraftSnapshot
      tags: [Network]
      security: []
      description: |
        Returns the latest aircraft state snapshot from the skygrid-engine service.
        This is a server-side proxy to ENGINE_API_BASE_URL/api/aircraft-snapshot.
        Snapshot age is included to indicate data freshness.
      responses:
        "200":
          description: Aircraft snapshot payload
          content:
            application/json:
              schema:
                type: object
                required: [generatedAt, snapshotAgeSec, aircraftCount, aircraft]
                properties:
                  generatedAt:
                    type: string
                    format: date-time
                    nullable: true
                  snapshotAgeSec:
                    type: number
                  aircraftCount:
                    type: integer
                  aircraft:
                    type: array
                    items:
                      type: object
                      required: [icao24, callsign, lat, lon]
                      properties:
                        icao24:
                          type: string
                        callsign:
                          type: string
                        lat:
                          type: number
                        lon:
                          type: number
                        altitudeM:
                          type: number
                          nullable: true
                        squawk:
                          type: string
                          nullable: true
        "503":
          description: Engine snapshot unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/analytics/events:
    post:
      summary: Capture product analytics event
      operationId: captureAnalyticsEvent
      tags: [Operations]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnalyticsEventRequest"
      responses:
        "202":
          description: Event accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AcceptedResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  # API key endpoints removed — API key generation is no longer a user-facing feature.
  /api/v1/auth/oauth-login:
    post:
      summary: Exchange trusted OAuth identity for backend JWT
      operationId: oauthLogin
      tags: [Auth]
      description: |
        Called by the trusted Next.js Auth.js tier after provider login.
        Persists or updates the user record and returns a JWT for backend API access.
      parameters:
        - in: header
          name: X-Auth-Exchange-Secret
          required: true
          schema:
            type: string
            minLength: 1
          description: Shared secret between Next.js and backend to authorize identity exchange.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthLoginRequest"
      responses:
        "200":
          description: Backend JWT issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Invalid exchange secret
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Backend dependency unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/register:
    post:
      summary: Register a new user with email and password
      operationId: registerUser
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, name]
              properties:
                email:
                  type: string
                  format: email
                name:
                  type: string
                  minLength: 1
                password:
                  type: string
                  minLength: 8
      responses:
        "201":
          description: User created; JWT returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Email already registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/credentials-login:
    post:
      summary: Sign in with email and password
      operationId: credentialsLogin
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
      responses:
        "200":
          description: JWT issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthTokenResponse"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/forgot-password:
    post:
      summary: Request a password reset email
      operationId: forgotPassword
      tags: [Auth]
      description: Always returns 200 to prevent email enumeration.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: Reset email queued (if account exists)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        "429":
          description: Rate limit exceeded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/reset-password:
    post:
      summary: Reset password using a reset token
      operationId: resetPassword
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                  description: 64-character hex token from the reset email
                password:
                  type: string
                  minLength: 8
      responses:
        "200":
          description: Password updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
        "400":
          description: Token invalid or expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  # /api/v1/keys/{keyId} endpoint removed — API key management is no longer user-facing.
  /api/v1/nodes:
    post:
      summary: Register a new ground station node
      operationId: createNode
      tags: [Nodes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGroundStationRequest"
      responses:
        "201":
          description: Node registered successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroundStationResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Node identifier conflict
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    get:
      summary: List ground stations owned by the authenticated user
      operationId: listNodes
      tags: [Nodes]
      responses:
        "200":
          description: Ground station list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/GroundStationResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/nodes/{nodeId}:
    patch:
      summary: Update node metadata or status
      operationId: updateNode
      tags: [Nodes]
      parameters:
        - in: path
          name: nodeId
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateGroundStationRequest"
      responses:
        "200":
          description: Node updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GroundStationResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Node not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      summary: Delete a ground station node
      operationId: deleteNode
      tags: [Nodes]
      parameters:
        - in: path
          name: nodeId
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Node deleted
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Node not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/nodes/{nodeId}/history:
    get:
      summary: Historical telemetry for a specific node
      operationId: getNodeHistory
      tags: [Nodes]
      parameters:
        - in: path
          name: nodeId
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Node history payload with raw packets and pass summaries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NodeHistoryResponse"
        "401":
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Node not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: History unavailable when database is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/rewards/status:
    get:
      summary: Rolling uptime and reward unlock status for authenticated operator
      operationId: getRewardsStatus
      tags: [Operators]
      responses:
        "200":
          description: Rewards status payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RewardsStatusResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/keys/premium:
    post:
      summary: Generate premium API key for unlocked operator
      operationId: createPremiumApiKey
      tags: [API Keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePremiumKeyRequest"
      responses:
        "201":
          description: Premium API key created successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Premium tier not unlocked yet
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/watch-grids:
    get:
      summary: List watch areas
      operationId: listWatchGrids
      tags: [Watch Grids]
      responses:
        "200":
          description: Watch grids owned by authenticated user
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WatchGridResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      summary: Create a watch area
      operationId: createWatchGrid
      tags: [Watch Grids]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWatchGridRequest"
      responses:
        "201":
          description: Watch grid created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchGridResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/watch-grids/allowance:
    get:
      summary: Get watch-area monthly allowance
      operationId: getWatchGridAllowance
      tags: [Watch Grids]
      responses:
        "200":
          description: Current user's watch-area cell allowance usage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchGridAllowanceResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/watch-grids/{watchGridId}:
    patch:
      summary: Update a watch area
      operationId: updateWatchGrid
      tags: [Watch Grids]
      parameters:
        - in: path
          name: watchGridId
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWatchGridRequest"
      responses:
        "200":
          description: Watch grid updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchGridResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Watch grid not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/watch-grids/{watchGridId}/history:
    get:
      summary: List recent anomaly hits for a watch area
      operationId: getWatchGridHistory
      tags: [Watch Grids]
      parameters:
        - in: path
          name: watchGridId
          required: true
          schema:
            type: string
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Recent anomaly events scoped to this watch area and its anomaly filters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WatchGridHistoryResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Watch grid not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      summary: Delete a watch area
      operationId: deleteWatchGrid
      tags: [Watch Grids]
      parameters:
        - in: path
          name: watchGridId
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Watch grid deleted
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Watch grid not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/webhooks:
    post:
      summary: Create a webhook destination
      operationId: createWebhookDestination
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookDestinationRequest"
      responses:
        "201":
          description: Webhook destination created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDestinationResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Referenced watch area not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    get:
      summary: List webhook destinations
      operationId: listWebhookDestinations
      tags: [Webhooks]
      responses:
        "200":
          description: Webhook destinations owned by authenticated user
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WebhookDestinationResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/webhooks/{webhookId}:
    patch:
      summary: Update a webhook destination
      operationId: updateWebhookDestination
      tags: [Webhooks]
      parameters:
        - in: path
          name: webhookId
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWebhookDestinationRequest"
      responses:
        "200":
          description: Webhook destination updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDestinationResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook or watch area not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      summary: Delete a webhook destination
      operationId: deleteWebhookDestination
      tags: [Webhooks]
      parameters:
        - in: path
          name: webhookId
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Webhook destination deleted
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/webhooks/{webhookId}/test:
    post:
      summary: Trigger a test webhook delivery
      operationId: triggerWebhookTest
      tags: [Webhooks]
      parameters:
        - in: path
          name: webhookId
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TriggerWebhookTestRequest"
      responses:
        "202":
          description: Delivery accepted and recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AlertDeliveryResponse"
        "400":
          description: Invalid request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Webhook not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Webhook is inactive
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "502":
          description: Delivery attempt failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/aircraft/{icao24}/enrichment:
    get:
      summary: Get AeroDataBox-enriched aircraft identity
      operationId: getAircraftEnrichment
      tags: [Aircraft Intelligence]
      parameters:
        - in: path
          name: icao24
          required: true
          schema:
            type: string
          description: ICAO 24-bit hex address of the aircraft
      responses:
        "200":
          description: Enriched aircraft identity from AeroDataBox
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AircraftEnrichmentResponse"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Plan does not include aircraft enrichment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Aircraft not found in AeroDataBox
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/flight-watches:
    get:
      summary: List active flight watches
      operationId: listFlightWatches
      tags: [Aircraft Intelligence]
      responses:
        "200":
          description: List of flight watches
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/FlightWatch"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Plan does not include flight watch alerts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      summary: Create a flight watch
      operationId: createFlightWatch
      tags: [Aircraft Intelligence]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateFlightWatchRequest"
      responses:
        "201":
          description: Flight watch created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FlightWatch"
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Plan does not include flight watch alerts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: Flight watch limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/flight-watches/{id}:
    delete:
      summary: Delete a flight watch
      operationId: deleteFlightWatch
      tags: [Aircraft Intelligence]
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Flight watch deleted
        "401":
          description: Missing or invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Flight watch not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  # ── Obsidian Dynamics Parent-Company Firehose (Internal) ──────────────
  /api/v1/internal/firehose/stream:
    get:
      summary: SSE stream of anomaly events, mission state, and network health
      description: |
        Server-Sent Events endpoint for the Obsidian Dynamics parent-company
        data ingestion pipeline. Pushes a JSON frame every 10 seconds containing
        new anomaly events since the last tick, mission summary counters, and
        infrastructure health indicators. NOT public-facing — enterprise internal only.
      operationId: firehoseStream
      tags: [Internal – Firehose]
      security:
        - firehoseKey: []
      responses:
        "200":
          description: SSE stream opened successfully
          content:
            text/event-stream:
              schema:
                type: string
                description: "Newline-delimited SSE frames (event: firehose)"
        "401":
          description: Missing X-Firehose-Key header
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Invalid firehose key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Firehose not configured on this instance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/internal/firehose/status:
    get:
      summary: Firehose configuration probe
      description: Returns whether the firehose is enabled, its cadence, and auth method.
      operationId: firehoseStatus
      tags: [Internal – Firehose]
      security:
        - firehoseKey: []
      responses:
        "200":
          description: Firehose status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FirehoseStatusResponse"
        "401":
          description: Missing X-Firehose-Key header
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/internal/firehose/snapshot:
    get:
      summary: Single point-in-time firehose frame
      description: |
        Returns a single firehose frame (last 60 seconds of anomaly events plus
        mission and network state) as JSON. Useful for batch polling instead of
        maintaining a persistent SSE connection.
      operationId: firehoseSnapshot
      tags: [Internal – Firehose]
      security:
        - firehoseKey: []
      responses:
        "200":
          description: Single firehose frame
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FirehoseFrame"
        "401":
          description: Missing X-Firehose-Key header
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Bearer authentication for API keys or backend-issued user JWTs. Include as Authorization: Bearer <token>"
    publicApiKey:
      type: apiKey
      in: header
      name: X-Public-API-Key
      description: Public API key for protected public endpoints (telemetry stream, sandbox, and ops metrics).
    adminApiKey:
      type: apiKey
      in: header
      name: X-Admin-API-Key
      description: Admin API key for release phase promotion and operations controls.
    firehoseKey:
      type: apiKey
      in: header
      name: X-Firehose-Key
      description: Pre-shared key for the Obsidian Dynamics parent-company firehose. Internal / enterprise only.
  schemas:
    HealthResponse:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [ok]
        timestamp:
          type: string
          format: date-time
      required: [status, timestamp]
    ReadyResponse:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [ready]
        timestamp:
          type: string
          format: date-time
      required: [status, timestamp]
    UptimeResponse:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
        started_at:
          type: string
          format: date-time
        uptime_sec:
          type: integer
        timestamp:
          type: string
          format: date-time
        request_id:
          type: string
      required: [status, started_at, uptime_sec, timestamp]
    SLOStatusResponse:
      type: object
      additionalProperties: false
      properties:
        window:
          type: string
        total_requests:
          type: integer
        error_requests:
          type: integer
        error_rate:
          type: number
        avg_latency_ms:
          type: number
        target_availability_pct:
          type: number
        timestamp:
          type: string
          format: date-time
      required: [window, total_requests, error_requests, error_rate, avg_latency_ms, target_availability_pct, timestamp]
    AnalyticsEventRequest:
      type: object
      additionalProperties: false
      properties:
        event:
          type: string
          minLength: 1
        page:
          type: string
        label:
          type: string
      required: [event]

    AcceptedResponse:
      type: object
      additionalProperties: false
      properties:
        accepted:
          type: boolean
        message:
          type: string
      required: [accepted, message]
    ErrorResponse:
      type: object
      additionalProperties: false
      properties:
        code:
          type: string
        message:
          type: string
      required: [code, message]

    CreateApiKeyRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Human-readable name for the key (e.g. "RaspberryPi-Node-42")
        userId:
          type: string
          minLength: 1
          maxLength: 255
          description: Authenticated dashboard user identifier that owns this key
        nodeId:
          type: string
          minLength: 1
          maxLength: 255
          description: Ground station identifier to link with this key
      required: [name, userId, nodeId]
    ApiKeyResponse:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
          description: Unique key identifier
        name:
          type: string
          description: Key name
        userId:
          type: string
          description: Owner user identifier
        nodeId:
          type: string
          description: Ground station identifier
        token:
          type: string
          description: "The actual API key. Store this securely; it will not be shown again."
        createdAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
          nullable: true
          description: Optional expiration date; null means no expiration
      required: [id, name, userId, nodeId, token, createdAt]
    ApiKeyInfo:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        name:
          type: string
        userId:
          type: string
        nodeId:
          type: string
        createdAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
          nullable: true
        lastUsed:
          type: string
          format: date-time
          nullable: true
          description: Last time this key was used for an API request
      required: [id, name, userId, nodeId, createdAt]
    CreateGroundStationRequest:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 255
          description: Optional explicit node identifier; generated when omitted
        name:
          type: string
          minLength: 1
          maxLength: 255
          description: Display name for the ground station
        latitude:
          type: number
          minimum: -90
          maximum: 90
          description: Optional station latitude in decimal degrees
        longitude:
          type: number
          minimum: -180
          maximum: 180
          description: Optional station longitude in decimal degrees
      required: [name]
    UpdateGroundStationRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 255
        isActive:
          type: boolean
        latitude:
          type: number
          minimum: -90
          maximum: 90
          description: Updated station latitude in decimal degrees
        longitude:
          type: number
          minimum: -180
          maximum: 180
          description: Updated station longitude in decimal degrees
      minProperties: 1
    GroundStationResponse:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        userId:
          type: string
        name:
          type: string
        isActive:
          type: boolean
        latitude:
          type: number
          nullable: true
        longitude:
          type: number
          nullable: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required: [id, userId, name, isActive, createdAt, updatedAt]
    LiveNetworkNodeResponse:
      type: object
      additionalProperties: false
      properties:
        node_name:
          type: string
          description: Ground station display name
        lat:
          type: number
          description: Privacy-fuzzed latitude rounded to 1 decimal place
        lon:
          type: number
          description: Privacy-fuzzed longitude rounded to 1 decimal place
        last_seen:
          type: string
          format: date-time
          description: Most recent telemetry timestamp from this station
      required: [node_name, lat, lon, last_seen]
    NetworkStatsResponse:
      type: object
      additionalProperties: false
      properties:
        active_nodes:
          type: integer
          minimum: 0
          description: Number of distinct nodes active in the last 24 hours
        tracked_satellites:
          type: integer
          minimum: 0
          description: Number of distinct satellites tracked in the last 24 hours
        passes_24h:
          type: integer
          minimum: 0
          description: Number of telemetry passes captured in the last 24 hours
      required: [active_nodes, tracked_satellites, passes_24h]
    MissionSummaryResponse:
      type: object
      additionalProperties: false
      properties:
        trackedAircraft:
          type: integer
          minimum: 0
          description: Number of aircraft currently tracked by the harvester engine
        activeAnomalies:
          type: integer
          minimum: 0
          description: Number of currently active anomalies emitted by the engine
        activeWatchGrids:
          type: integer
          minimum: 0
          description: Number of active watch areas configured on the platform
        alertsRouted24h:
          type: integer
          minimum: 0
          description: Number of alert delivery attempts recorded in the last 24 hours
        engineCadenceSec:
          type: integer
          minimum: 1
          description: Poll cadence of the airspace harvester engine in seconds
        engineHealthy:
          type: boolean
          description: Whether the backend can currently reach the harvester engine
        persistenceHealthy:
          type: boolean
          description: Whether watch-area and alert-routing persistence is currently available
        engineRateLimited:
          type: boolean
          description: Whether the engine is currently backing off due to OpenSky rate-limiting
        engineBackoffSec:
          type: integer
          minimum: 0
          description: Current OpenSky backoff window in seconds when rate-limited
        engineStatus:
          type: string
          enum: [live, rate_limited, offline]
          description: High-level engine status for homepage health indicators
      required: [trackedAircraft, activeAnomalies, activeWatchGrids, alertsRouted24h, engineCadenceSec, engineHealthy, persistenceHealthy, engineRateLimited, engineBackoffSec, engineStatus]
    HistoricalAnomalyLeaderboardBucket:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
        iso3:
          type: string
        continent:
          type: string
        loiter:
          type: integer
          minimum: 0
        ghost:
          type: integer
          minimum: 0
        squawk:
          type: integer
          minimum: 0
        rapid-descent:
          type: integer
          minimum: 0
        icao-spoof:
          type: integer
          minimum: 0
        formation-flight:
          type: integer
          minimum: 0
        callsign-duplicate:
          type: integer
          minimum: 0
        gps-jamming:
          type: integer
          minimum: 0
        total:
          type: integer
          minimum: 0
        metric:
          type: integer
          minimum: 0
      required: [name, loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming, total, metric]
    HistoricalAnomalyWorstOffender:
      type: object
      additionalProperties: false
      properties:
        callsign:
          type: string
        aircraftIcao24:
          type: string
        loiter:
          type: integer
          minimum: 0
        ghost:
          type: integer
          minimum: 0
        squawk:
          type: integer
          minimum: 0
        rapid-descent:
          type: integer
          minimum: 0
        icao-spoof:
          type: integer
          minimum: 0
        formation-flight:
          type: integer
          minimum: 0
        callsign-duplicate:
          type: integer
          minimum: 0
        gps-jamming:
          type: integer
          minimum: 0
        total:
          type: integer
          minimum: 0
        score:
          type: integer
          minimum: 0
      required: [callsign, aircraftIcao24, loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming, total, score]
    HistoricalAnomalyLeaderboardResponse:
      type: object
      additionalProperties: false
      properties:
        window:
          type: string
          enum: [24h, 7d, 30d]
        anomalyType:
          type: string
          enum: [all, loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
        generatedAt:
          type: string
          format: date-time
        windowStart:
          type: string
          format: date-time
        windowEnd:
          type: string
          format: date-time
        totalEvents:
          type: integer
          minimum: 0
        regions:
          type: array
          items:
            $ref: "#/components/schemas/HistoricalAnomalyLeaderboardBucket"
        countries:
          type: array
          items:
            $ref: "#/components/schemas/HistoricalAnomalyLeaderboardBucket"
        worstOffenders:
          type: array
          items:
            $ref: "#/components/schemas/HistoricalAnomalyWorstOffender"
      required: [window, anomalyType, generatedAt, windowStart, windowEnd, totalEvents, regions, countries, worstOffenders]
    ReleasePhase:
      type: object
      additionalProperties: false
      properties:
        phase_id:
          type: string
          enum: [phase-1, phase-2, phase-3]
        name:
          type: string
        status:
          type: string
          enum: [completed, active, upcoming]
        window:
          type: string
        goals:
          type: array
          items:
            type: string
        enabled_endpoints:
          type: array
          items:
            type: string
      required: [phase_id, name, status, window, goals, enabled_endpoints]
    ReleaseFrameworkResponse:
      type: object
      additionalProperties: false
      properties:
        program:
          type: string
        current_phase:
          type: string
          enum: [phase-1, phase-2, phase-3]
        generated_at:
          type: string
          format: date-time
        phases:
          type: array
          items:
            $ref: "#/components/schemas/ReleasePhase"
      required: [program, current_phase, generated_at, phases]
    MaritimeIntelligenceResponse:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [warming, ready]
        release_phase:
          type: string
          enum: [phase-1, phase-2, phase-3]
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        unique_vessels:
          type: integer
          minimum: 0
        direct_detections:
          type: integer
          minimum: 0
        observed_vessel_events:
          type: integer
          minimum: 0
        active_coastal_nodes:
          type: integer
          minimum: 0
        coastal_hotspots:
          type: array
          items:
            type: string
        hotspot_points:
          type: array
          items:
            $ref: "#/components/schemas/MaritimeHotspotPoint"
        freshness_sec:
          type: integer
          minimum: 0
        notes:
          type: string
      required: [status, release_phase, window_start, window_end, unique_vessels, direct_detections, observed_vessel_events, active_coastal_nodes, coastal_hotspots, hotspot_points, freshness_sec, notes]
    MaritimeHotspotPoint:
      type: object
      additionalProperties: false
      properties:
        label:
          type: string
        latitude:
          type: number
          minimum: -90
          maximum: 90
        longitude:
          type: number
          minimum: -180
          maximum: 180
        detection_count:
          type: integer
          minimum: 0
        unique_vessels:
          type: integer
          minimum: 0
      required: [label, latitude, longitude, detection_count, unique_vessels]
    RFScannerHotspot:
      type: object
      additionalProperties: false
      properties:
        label:
          type: string
        latitude:
          type: number
          minimum: -90
          maximum: 90
        longitude:
          type: number
          minimum: -180
          maximum: 180
        scanner_count:
          type: integer
          minimum: 0
        anomaly_events:
          type: integer
          minimum: 0
        peak_power_dbm:
          type: number
        spectrum_utilization_pct:
          type: number
          minimum: 0
          maximum: 100
      required: [label, latitude, longitude, scanner_count, anomaly_events, peak_power_dbm, spectrum_utilization_pct]
    RFIntelligenceResponse:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [warming, ready]
        release_phase:
          type: string
          enum: [phase-1, phase-2, phase-3]
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        noise_floor_p50_dbm:
          type: number
        noise_floor_p95_dbm:
          type: number
        anomaly_events:
          type: integer
          minimum: 0
        active_scanners:
          type: integer
          minimum: 0
        spectrum_utilization_pct:
          type: number
          minimum: 0
          maximum: 100
        scanner_hotspots:
          type: array
          items:
            $ref: "#/components/schemas/RFScannerHotspot"
        notes:
          type: string
      required: [status, release_phase, window_start, window_end, noise_floor_p50_dbm, noise_floor_p95_dbm, anomaly_events, active_scanners, spectrum_utilization_pct, scanner_hotspots, notes]
    AISPacketIngestRequest:
      type: object
      additionalProperties: false
      properties:
        nodeId:
          type: string
          minLength: 1
        nmea:
          type: string
          minLength: 1
        channel:
          type: string
        frequencyMhz:
          type: number
        rssiDbm:
          type: number
        capturedAt:
          type: string
          format: date-time
      required: [nodeId, nmea, capturedAt]
    AISPacketIngestResponse:
      type: object
      additionalProperties: false
      properties:
        accepted:
          type: boolean
        message:
          type: string
        message_type:
          type: integer
        mmsi:
          type: integer
        captured_at:
          type: string
          format: date-time
      required: [accepted, message, message_type, mmsi, captured_at]
    RFSweepIngestRequest:
      type: object
      additionalProperties: false
      properties:
        nodeId:
          type: string
          minLength: 1
        windowStart:
          type: string
          format: date-time
        windowEnd:
          type: string
          format: date-time
        capturedAt:
          type: string
          format: date-time
        bandStartMhz:
          type: number
        bandEndMhz:
          type: number
        binHz:
          type: integer
        noiseFloorP50Dbm:
          type: number
        noiseFloorP95Dbm:
          type: number
        occupiedBins:
          type: integer
        totalBins:
          type: integer
        maxPowerDbm:
          type: number
        scannerSessionRef:
          type: string
      required: [nodeId, windowStart, windowEnd, capturedAt, bandStartMhz, bandEndMhz, binHz, noiseFloorP50Dbm, noiseFloorP95Dbm, occupiedBins, totalBins, maxPowerDbm]
    RFSweepIngestResponse:
      type: object
      additionalProperties: false
      properties:
        accepted:
          type: boolean
        message:
          type: string
        captured_at:
          type: string
          format: date-time
        spectrum_usage_pct:
          type: number
        scanner_session_ref:
          type: string
      required: [accepted, message, captured_at, spectrum_usage_pct]
    AdminReleasePhaseUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        phase:
          type: string
          enum: [phase-1, phase-2, phase-3]
      required: [phase]
    AdminReleasePhaseResponse:
      type: object
      additionalProperties: false
      properties:
        phase:
          type: string
          enum: [phase-1, phase-2, phase-3]
        updated_at:
          type: string
          format: date-time
        source:
          type: string
        request_id:
          type: string
        last_editor:
          type: string
      required: [phase, updated_at, source]

    CompositeTopSatellite:
      type: object
      additionalProperties: false
      properties:
        satellite_id:
          type: string
        passes:
          type: integer
          minimum: 0
      required: [satellite_id, passes]
    CompositeSourceSummary:
      type: object
      additionalProperties: false
      properties:
        node_id:
          type: string
        node_name:
          type: string
        satellite_id:
          type: string
        captured_at:
          type: string
          format: date-time
        latitude:
          type: number
        longitude:
          type: number
        signal_dbm:
          type: number
          nullable: true
        image_path:
          type: string
        weight:
          type: number
      required: [node_id, node_name, satellite_id, captured_at, latitude, longitude, image_path, weight]
    EnterpriseCompositeResponse:
      type: object
      additionalProperties: false
      properties:
        composite_id:
          type: string
        status:
          type: string
          enum: [warming, ready]
        generated_at:
          type: string
          format: date-time
        window_hours:
          type: integer
          minimum: 1
        window_start:
          type: string
          format: date-time
        window_end:
          type: string
          format: date-time
        region:
          type: string
        algorithm_version:
          type: string
        total_passes:
          type: integer
          minimum: 0
        active_nodes:
          type: integer
          minimum: 0
        unique_satellites:
          type: integer
          minimum: 0
        source_capture_count:
          type: integer
          minimum: 0
        coverage_percent:
          type: number
          minimum: 0
          maximum: 100
        north_america_coverage_percent:
          type: number
          minimum: 0
          maximum: 100
          description: Geospatially computed percent coverage of the North America operational mask during the composite window.
        north_america_coverage_target:
          type: number
          minimum: 0
          maximum: 100
          description: Coverage target threshold for North America operations.
        north_america_coverage_gap:
          type: number
          minimum: 0
          maximum: 100
          description: Remaining percentage points needed to reach the North America target.
        north_america_coverage_status:
          type: string
          enum: [below_target, on_track, covered]
          description: Status derived from comparing North America coverage against target.
        quality_score:
          type: number
          minimum: 0
          maximum: 100
        top_satellites:
          type: array
          items:
            $ref: "#/components/schemas/CompositeTopSatellite"
        composite_image_path:
          type: string
          nullable: true
          description: Public path to the most recent stitched regional composite image.
        manifest_path:
          type: string
          nullable: true
        source_nodes:
          type: array
          items:
            type: string
      required: [composite_id, status, generated_at, window_hours, window_start, window_end, region, algorithm_version, total_passes, active_nodes, unique_satellites, source_capture_count, coverage_percent, north_america_coverage_percent, north_america_coverage_target, north_america_coverage_gap, north_america_coverage_status, quality_score, top_satellites]
    EnterpriseCompositeManifestResponse:
      allOf:
        - $ref: "#/components/schemas/EnterpriseCompositeResponse"
        - type: object
          additionalProperties: false
          properties:
            sources:
              type: array
              items:
                $ref: "#/components/schemas/CompositeSourceSummary"
          required: [sources]
    OperatorLeaderboardEntry:
      type: object
      additionalProperties: false
      properties:
        node_id:
          type: string
        node_name:
          type: string
        uptime_percent:
          type: number
        total_captures_7d:
          type: integer
      required: [node_id, node_name, uptime_percent, total_captures_7d]
    RewardsStatusResponse:
      type: object
      additionalProperties: false
      properties:
        unlocked:
          type: boolean
        uptime_percent:
          type: number
        required_percent:
          type: number
        active_nodes:
          type: integer
        captures_30d:
          type: integer
      required: [unlocked, uptime_percent, required_percent, active_nodes, captures_30d]
    CreatePremiumKeyRequest:
      type: object
      additionalProperties: false
      properties:
        nodeId:
          type: string
          minLength: 1
        name:
          type: string
          minLength: 1
      required: [nodeId]
    NextPassPrediction:
      type: object
      additionalProperties: false
      properties:
        satellite_id:
          type: string
        pass_time_utc:
          type: string
          format: date-time
        max_elevation_deg:
          type: number
        location:
          type: string
      required: [satellite_id, pass_time_utc, max_elevation_deg, location]
    SandboxDatasetResponse:
      type: object
      additionalProperties: false
      properties:
        download_url:
          type: string
        expires_at:
          type: string
          format: date-time
        message:
          type: string
        contact_email:
          type: string
          format: email
          description: Customer-facing contact for sandbox follow-up.
        recorded_fields:
          type: array
          description: Fields persisted when a lead requests the sandbox dataset.
          items:
            type: string
      required: [download_url, expires_at, message, contact_email, recorded_fields]
    TelemetryStreamEvent:
      type: object
      additionalProperties: false
      properties:
        nodeId:
          type: string
        satelliteId:
          type: string
        latitude:
          type: number
          description: Privacy-fuzzed latitude rounded to 1 decimal place
        longitude:
          type: number
          description: Privacy-fuzzed longitude rounded to 1 decimal place
        signalDbm:
          type: number
        capturedAt:
          type: string
          format: date-time
      required: [nodeId, satelliteId, latitude, longitude, signalDbm, capturedAt]
    NodeHistoryPacket:
      type: object
      additionalProperties: false
      properties:
        capturedAt:
          type: string
          format: date-time
        satelliteId:
          type: string
        elevationDeg:
          type: number
        azimuthDeg:
          type: number
        signalDbm:
          type: number
          nullable: true
      required: [capturedAt, satelliteId, elevationDeg, azimuthDeg]
    NodePassSummary:
      type: object
      additionalProperties: false
      properties:
        time:
          type: string
          format: date-time
        satelliteId:
          type: string
        maxElevationDeg:
          type: number
        maxSignalDbm:
          type: number
          nullable: true
      required: [time, satelliteId, maxElevationDeg]
    NodeHistoryResponse:
      type: object
      additionalProperties: false
      properties:
        nodeId:
          type: string
        packets:
          type: array
          items:
            $ref: "#/components/schemas/NodeHistoryPacket"
        passes:
          type: array
          items:
            $ref: "#/components/schemas/NodePassSummary"
      required: [nodeId, packets, passes]
    OAuthLoginRequest:
      type: object
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
          maxLength: 255
        name:
          type: string
          maxLength: 255
        provider:
          type: string
          description: OAuth provider identifier, e.g. github
        providerUserId:
          type: string
          description: Provider-specific subject/user id
      required: [email]
    AuthUser:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        email:
          type: string
          format: email
        name:
          type: string
      required: [id, email]
    AuthTokenResponse:
      type: object
      additionalProperties: false
      properties:
        accessToken:
          type: string
          description: Backend JWT to call protected endpoints.
        tokenType:
          type: string
          enum: [Bearer]
        expiresAt:
          type: string
          format: date-time
        user:
          $ref: "#/components/schemas/AuthUser"
      required: [accessToken, tokenType, expiresAt, user]
    CreateWatchGridRequest:
      type: object
      additionalProperties: false
      required: [name, countryCode]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type: string
          maxLength: 1000
        countryCode:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code
          example: GB
        region:
          type: string
          maxLength: 255
          description: Optional region or state within the country
        minLoiterSeconds:
          type: integer
          minimum: 30
          maximum: 86400
          default: 900
        anomalyFilters:
          type: array
          minItems: 1
          maxItems: 8
          uniqueItems: true
          items:
            type: string
            enum: [loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
        notifyOnLoiter:
          type: boolean
        notifyOnGhost:
          type: boolean
        notifyOnSquawk:
          type: boolean
    UpdateWatchGridRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type: string
          maxLength: 1000
        countryCode:
          type: string
          minLength: 2
          maxLength: 2
          description: ISO 3166-1 alpha-2 country code
        region:
          type: string
          maxLength: 255
        minLoiterSeconds:
          type: integer
          minimum: 30
          maximum: 86400
        anomalyFilters:
          type: array
          minItems: 1
          maxItems: 8
          uniqueItems: true
          items:
            type: string
            enum: [loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
        notifyOnLoiter:
          type: boolean
        notifyOnGhost:
          type: boolean
        notifyOnSquawk:
          type: boolean
        isActive:
          type: boolean
    WatchGridResponse:
      type: object
      additionalProperties: false
      required: [id, userId, name, isActive, minLoiterSeconds, anomalyFilters, countryCode, createdAt, updatedAt]
      properties:
        id:
          type: string
        userId:
          type: string
        name:
          type: string
        description:
          type: string
        isActive:
          type: boolean
        minLoiterSeconds:
          type: integer
        anomalyFilters:
          type: array
          items:
            type: string
            enum: [loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
        notifyOnLoiter:
          type: boolean
        notifyOnGhost:
          type: boolean
        notifyOnSquawk:
          type: boolean
        countryCode:
          type: string
          description: ISO 3166-1 alpha-2 country code
        region:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    WatchGridAllowanceResponse:
      type: object
      additionalProperties: false
      required: [monthlyCellAllowance, usedCells, remainingCells, activeWatchGrids, period]
      properties:
        monthlyCellAllowance:
          type: integer
        usedCells:
          type: integer
        remainingCells:
          type: integer
        activeWatchGrids:
          type: integer
        period:
          type: string
          enum: [monthly]
    WatchGridHistoryEvent:
      type: object
      additionalProperties: false
      required: [id, anomalyType, icao24, latitude, longitude, dwellSeconds, observedAt, createdAt]
      properties:
        id:
          type: string
        anomalyType:
          type: string
          enum: [loiter, ghost, squawk, rapid-descent, icao-spoof, formation-flight, callsign-duplicate, gps-jamming]
        icao24:
          type: string
        callsign:
          type: string
        latitude:
          type: number
        longitude:
          type: number
        altitudeM:
          type: number
        squawk:
          type: string
        dwellSeconds:
          type: integer
        observedAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
    WatchGridHistoryResponse:
      type: object
      additionalProperties: false
      required: [watchGridId, events]
      properties:
        watchGridId:
          type: string
        events:
          type: array
          items:
            $ref: "#/components/schemas/WatchGridHistoryEvent"
    CreateWebhookDestinationRequest:
      type: object
      additionalProperties: false
      required: [watchGridId, url]
      properties:
        watchGridId:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          maxLength: 2048
        sharedSecret:
          type: string
          maxLength: 512
        active:
          type: boolean
          default: true
        maxRetries:
          type: integer
          minimum: 0
          maximum: 10
          default: 3
    UpdateWebhookDestinationRequest:
      type: object
      additionalProperties: false
      properties:
        watchGridId:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          maxLength: 2048
        sharedSecret:
          type: string
          maxLength: 512
        active:
          type: boolean
        maxRetries:
          type: integer
          minimum: 0
          maximum: 10
    WebhookDestinationResponse:
      type: object
      additionalProperties: false
      required: [id, ownerSub, watchGridId, url, active, maxRetries, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
        ownerSub:
          type: string
        watchGridId:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        active:
          type: boolean
        maxRetries:
          type: integer
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    TriggerWebhookTestRequest:
      type: object
      additionalProperties: false
      properties:
        message:
          type: string
          maxLength: 500
    AlertDeliveryResponse:
      type: object
      additionalProperties: false
      required: [id, webhookId, status, attemptCount, createdAt]
      properties:
        id:
          type: string
          format: uuid
        webhookId:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, delivered, failed]
        responseCode:
          type: integer
          nullable: true
        responseBody:
          type: string
          nullable: true
        attemptCount:
          type: integer
          minimum: 1
        nextAttemptAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
    AircraftEnrichmentResponse:
      type: object
      additionalProperties: false
      required: [icao24]
      properties:
        icao24:
          type: string
          description: ICAO 24-bit hex address
        registration:
          type: string
          nullable: true
        airlineOperator:
          type: string
          nullable: true
        aircraftType:
          type: string
          nullable: true
          description: Aircraft type designator (e.g. B738, A320)
        aircraftModel:
          type: string
          nullable: true
          description: Full aircraft model name
        origin:
          type: string
          nullable: true
          description: Origin airport IATA code
        destination:
          type: string
          nullable: true
          description: Destination airport IATA code
        flightNumber:
          type: string
          nullable: true
        callsign:
          type: string
          nullable: true
        enrichedAt:
          type: string
          format: date-time
    CreateFlightWatchRequest:
      type: object
      additionalProperties: false
      required: [flightNumber]
      properties:
        flightNumber:
          type: string
          description: IATA flight number to watch (e.g. BA123)
          maxLength: 10
        label:
          type: string
          maxLength: 100
          description: Optional user-defined label
    FlightWatch:
      type: object
      additionalProperties: false
      required: [id, userId, flightNumber, createdAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
        flightNumber:
          type: string
        label:
          type: string
          nullable: true
        lastSeen:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
    EntitlementLimits:
      type: object
      additionalProperties: false
      properties:
        historyDays:
          type: integer
        maxRowsPerSession:
          type: integer
        maxWatchGrids:
          type: integer
        maxH3Cells:
          type: integer
        apiRequestsPerMinute:
          type: integer
        exportsPerDay:
          type: integer
        webhookDestinations:
          type: integer
        scheduledReports:
          type: integer
        teamSeats:
          type: integer
        maxFlightWatches:
          type: integer
        maxFleetAirlines:
          type: integer
    EntitlementFeatures:
      type: object
      additionalProperties: false
      properties:
        realtimeFeed:
          type: boolean
        airportAutoZones:
          type: boolean
        csvExport:
          type: boolean
        apiAccess:
          type: boolean
        emailAlerts:
          type: boolean
        webhookAlerts:
          type: boolean
        slackTeamsAlerts:
          type: boolean
        sharedInvestigations:
          type: boolean
        auditLogs:
          type: boolean
        sso:
          type: boolean
        advancedAnomalyTypes:
          type: boolean
        kinematicData:
          type: boolean
        loiterCostAnalysis:
          type: boolean
        crossDomainIntelligence:
          type: boolean
        rfSpectrumOccupancy:
          type: boolean
        webhookSigning:
          type: boolean
        aircraftEnrichment:
          type: boolean
        flightWatchAlerts:
          type: boolean
        fleetMonitoring:
          type: boolean
        enrichedReports:
          type: boolean
        historicalInvestigation:
          type: boolean
    Entitlements:
      type: object
      additionalProperties: false
      required: [plan, status, limits, features]
      properties:
        plan:
          type: string
          enum: [free, pro, team, enterprise]
        status:
          type: string
          enum: [active, trialing, past_due, canceled]
        limits:
          $ref: "#/components/schemas/EntitlementLimits"
        features:
          $ref: "#/components/schemas/EntitlementFeatures"
    # ── Firehose schemas (Obsidian Dynamics internal) ───────────────────
    FirehoseFrame:
      type: object
      additionalProperties: false
      required: [timestamp, sequence, anomalyEvents]
      properties:
        timestamp:
          type: string
          format: date-time
        sequence:
          type: integer
          format: int64
        anomalyEvents:
          type: array
          items:
            $ref: "#/components/schemas/FirehoseAnomaly"
        missionSummary:
          $ref: "#/components/schemas/FirehoseMissionState"
        networkHealth:
          $ref: "#/components/schemas/FirehoseNetworkHealth"
    FirehoseAnomaly:
      type: object
      additionalProperties: false
      required: [id, icao24, anomalyType, latitude, longitude, createdAt]
      properties:
        id:
          type: string
        icao24:
          type: string
        callsign:
          type: string
        anomalyType:
          type: string
        latitude:
          type: number
          format: double
        longitude:
          type: number
          format: double
        altitudeM:
          type: number
          format: double
        squawk:
          type: string
        createdAt:
          type: string
          format: date-time
    FirehoseMissionState:
      type: object
      additionalProperties: false
      properties:
        trackedAircraft:
          type: integer
        activeAnomalies:
          type: integer
        activeWatchGrids:
          type: integer
        alertsRouted24h:
          type: integer
        engineHealthy:
          type: boolean
        persistenceHealthy:
          type: boolean
    FirehoseNetworkHealth:
      type: object
      additionalProperties: false
      properties:
        activeNodes:
          type: integer
        databaseUp:
          type: boolean
        engineStatus:
          type: string
          enum: [online, degraded, offline]
    FirehoseStatusResponse:
      type: object
      additionalProperties: false
      required: [enabled, cadenceSeconds, endpoint, authMethod]
      properties:
        enabled:
          type: boolean
        cadenceSeconds:
          type: string
        endpoint:
          type: string
        authMethod:
          type: string
