openapi: 3.1.0
info:
  title: Stelno Partner API
  version: 1.0.0
  summary: One OAuth API for EFBs, terminal vendors, and Stelno's own apps.
  description: |
    The Stelno Partner API lets a flight-planning app (EFB), a fuel-terminal vendor, or any
    integrator embed Stelno self-serve fueling: OAuth-link a pilot's Stelno account, start a
    fuel session, render the one-time pump code natively, follow live fueling progress, and
    receive cryptographically signed receipts and webhooks.

    **Design rule:** Stelno's own pilot app is customer #1 of this API. If you can't do
    something through this API, neither can we.

    ### Environments
    Sandbox base URL: `https://htnolhvggbtvvcqfcpfg.supabase.co/functions/v1/partner`

    ### Authentication
    OAuth 2.0. Two flows:
    - **Authorization code + PKCE** (user-linked, for EFBs and apps): send the pilot to
      `/oauth/authorize` on the Stelno app origin, exchange the returned code at `/oauth/token`.
      Public clients use PKCE (S256) and no client secret.
    - **Client credentials** (server-to-server, e.g. equipment/controller cloud): confidential
      clients exchange `client_id` + `client_secret` at `/oauth/token`.

    Access tokens are opaque Bearer tokens, 1 hour TTL. Refresh tokens rotate on every use
    (the previous access + refresh pair is revoked).

    ### Scopes
    | Scope | Grants |
    |---|---|
    | `profile:read` | Pilot display name, verification tier, payment-method summary |
    | `aircraft:read` | Linked aircraft, approved fuel grades, fuel capacity |
    | `fuel_sessions:write` | Create and cancel fuel sessions |
    | `fuel_sessions:read` | Session status incl. live dispensed gallons |
    | `receipts:read` | Signed receipt objects |
    | `airports:read` | Airport/price map, pump listings, route planning |

    ### Webhooks
    Register an HTTPS endpoint and receive `fuel_session.*` events for sessions created by
    your client. Each delivery is signed: header `Stelno-Signature: t=<unix ts>,v1=<hex>`
    where `v1 = HMAC_SHA256(secret, "<t>.<raw request body>")`. Reject deliveries whose
    timestamp is older than 5 minutes or whose signature does not match. Deliveries retry
    up to 5 times with exponential backoff.

    ### Signed receipts
    Every captured session yields a receipt object plus an HMAC-SHA256 signature over the
    canonical string
    `v<version>|session_id|captured_at|site|airport_id|pump|fuel_grade|n_number|gallons|ppg_cents|amount_cents|ledger_txn_id`
    (numeric fields with trailing zeros trimmed). Verify locally or via `POST /v1/receipts/verify`.
  contact:
    name: Stelno developer support
    url: https://stelno.app
servers:
  - url: https://htnolhvggbtvvcqfcpfg.supabase.co/functions/v1/partner
    description: Sandbox
tags:
  - name: OAuth
  - name: Pilot
  - name: Fuel sessions
  - name: Receipts
  - name: Airports & routing
  - name: Equipment
paths:
  /oauth/token:
    post:
      tags: [OAuth]
      operationId: token
      summary: Exchange a grant for tokens
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code, refresh_token, client_credentials]
                client_id: { type: string }
                client_secret:
                  type: string
                  description: Confidential clients only. May also be sent via HTTP Basic auth.
                code: { type: string, description: authorization_code grant }
                code_verifier: { type: string, description: PKCE verifier (public clients) }
                redirect_uri: { type: string, description: Must exactly match the authorize request }
                refresh_token: { type: string, description: refresh_token grant }
                scope: { type: string, description: client_credentials grant; space-separated }
      responses:
        "200":
          description: Token issued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: OAuth error (`invalid_client`, `invalid_grant`, `invalid_scope`, `unsupported_grant_type`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
  /v1/me:
    get:
      tags: [Pilot]
      operationId: getMe
      summary: Linked pilot profile
      description: Requires `profile:read`. Aircraft list requires `aircraft:read` (included when granted).
      responses:
        "200":
          description: Pilot profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
  /v1/fuel_sessions:
    post:
      tags: [Fuel sessions]
      operationId: createFuelSession
      summary: Start a fuel session (issues the one-time pump code)
      description: |
        Requires `fuel_sessions:write`. Creates a preauthorized session with a spending
        ceiling; returns a single-use, pump-bound 6-digit code valid for 90 seconds.
        Misfueling protection: rejected if the pump's grade is not approved for the
        aircraft (override requires `grade_override: true`, which is logged).
        A soft `capacity_warning` is returned when the ceiling implies more gallons
        than the aircraft's usable fuel capacity.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pump_id, ceiling_cents]
              properties:
                pump_id: { type: string, format: uuid }
                ceiling_cents: { type: integer, minimum: 100 }
                aircraft_id: { type: string, format: uuid }
                n_number: { type: string, description: "Alternative to aircraft_id (e.g. N528DP)" }
                grade_override: { type: boolean, default: false }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionCreated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "422":
          description: Rejected (grade mismatch, pump offline, no payment method, aircraft not linked)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
  /v1/fuel_sessions/{session_id}:
    get:
      tags: [Fuel sessions]
      operationId: getFuelSession
      summary: Session status (incl. live dispensed gallons)
      description: Requires `fuel_sessions:read`. Poll during fueling; `dispensed_gal_live` updates ~1/s while fuel flows.
      parameters: [{ $ref: "#/components/parameters/SessionId" }]
      responses:
        "200":
          description: Session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/fuel_sessions/{session_id}/cancel:
    post:
      tags: [Fuel sessions]
      operationId: cancelFuelSession
      summary: Cancel a pending session
      description: Requires `fuel_sessions:write`. Only `pending` sessions (code not yet used at the pump) can be canceled; outstanding codes are invalidated.
      parameters: [{ $ref: "#/components/parameters/SessionId" }]
      responses:
        "200":
          description: Canceled
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string, format: uuid }
                  status: { type: string, enum: [canceled] }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Not cancelable (already authorized/captured)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
  /v1/fuel_sessions/{session_id}/receipt:
    get:
      tags: [Receipts]
      operationId: getReceipt
      summary: Signed receipt for a captured session
      description: Requires `receipts:read`. Line items are suitable for EFB logbook/expense import.
      parameters: [{ $ref: "#/components/parameters/SessionId" }]
      responses:
        "200":
          description: Signed receipt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignedReceipt" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/receipts/verify:
    post:
      tags: [Receipts]
      operationId: verifyReceipt
      summary: Verify a receipt signature
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignedReceipt" }
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
  /v1/airports:
    get:
      tags: [Airports & routing]
      operationId: mapAirports
      summary: Airports with fuel prices in a bounding box
      description: |
        Requires `airports:read`. Returns US fuel airports (FAA NASR) with per-grade prices.
        Stelno sites return live verified pump prices; other airports return unverified
        book prices. Same data source as the Stelno app's fuel map.
      parameters:
        - { name: min_lat, in: query, required: true, schema: { type: number } }
        - { name: min_lon, in: query, required: true, schema: { type: number } }
        - { name: max_lat, in: query, required: true, schema: { type: number } }
        - { name: max_lon, in: query, required: true, schema: { type: number } }
        - { name: grade, in: query, schema: { type: string, enum: [100LL, UL94, JET_A, JET_A_PLUS, MOGAS] } }
        - { name: min_rwy_ft, in: query, schema: { type: integer } }
        - { name: verified_only, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, default: 400, maximum: 1000 } }
      responses:
        "200":
          description: Airports
          content:
            application/json:
              schema:
                type: object
                properties:
                  airports:
                    type: array
                    items: { $ref: "#/components/schemas/Airport" }
  /v1/pumps:
    get:
      tags: [Airports & routing]
      operationId: listPumps
      summary: Online pumps at a Stelno site
      description: Requires `airports:read`. Pass `site_id` (from an airport's `stelno_site_id`) or `airport_id`.
      parameters:
        - { name: site_id, in: query, schema: { type: string, format: uuid } }
        - { name: airport_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Pumps
          content:
            application/json:
              schema:
                type: object
                properties:
                  pumps:
                    type: array
                    items: { $ref: "#/components/schemas/Pump" }
  /v1/route_plans:
    post:
      tags: [Airports & routing]
      operationId: createRoutePlan
      summary: Fastest vs cheapest fuel routing
      description: |
        Requires `airports:read`. EV-style route planning on fuel prices: fastest route,
        cheapest route within a time tolerance, up to 5 ranked alternates per stop, and
        cheap top-off suggestions for nonstop-capable trips. Cost model prices every gallon
        burned (buy-as-needed at stops + replace-at-destination).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin, destination]
              properties:
                origin: { type: string, description: ICAO or FAA id (KPAO) }
                destination: { type: string }
                aircraft_model: { type: string, description: Autofills perf from Stelno's model database }
                cruise_speed_kts: { type: number }
                cruise_burn_gph: { type: number }
                usable_fuel_gal: { type: number }
                reserve_hours: { type: number, minimum: 0.5, maximum: 2, default: 1 }
                detour_tolerance: { type: number, minimum: 0.03, maximum: 0.05, default: 0.04 }
                fuel_grade: { type: string }
                min_rwy_ft: { type: integer }
      responses:
        "200":
          description: Route plan (fastest + cheapest + per-stop alternates)
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
  /equipment/v1/authorizations:
    post:
      tags: [Equipment]
      operationId: equipmentAuthorize
      summary: Controller-initiated authorization (Path B / Stelno Link)
      description: |
        Client-credentials token with `equipment` scope. A fuel controller presents a
        one-time code + pump id; Stelno responds approve/decline with the session's
        preauthorized ceiling. The controller — never the cloud — enforces fuel cutoff.
        (Sandbox: this flow is served by the `equipment` function's `/authorize` route;
        documented here as the certified-controller contract.)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pump_id, code]
              properties:
                pump_id: { type: string, format: uuid }
                code: { type: string, pattern: "^[0-9]{6}$" }
      responses:
        "200":
          description: Approve/decline with ceiling
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean }
                  session_id: { type: string, format: uuid }
                  ceiling_cents: { type: integer }
                  max_gallons: { type: number }
  /equipment/v1/captures:
    post:
      tags: [Equipment]
      operationId: equipmentCapture
      summary: Report actual gallons at fueling end
      description: |
        Client-credentials token with `equipment` scope. Actual dispensed gallons are
        clamped at the session ceiling; capture posts balanced double-entry ledger
        entries and triggers `fuel_session.captured` webhooks.
        (Sandbox: served by the `equipment` function's `/capture` route.)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session_id, gallons]
              properties:
                session_id: { type: string, format: uuid }
                gallons: { type: number }
      responses:
        "200":
          description: Captured
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string, format: uuid }
                  captured_gallons: { type: number }
                  captured_cents: { type: integer }
components:
  parameters:
    SessionId:
      name: session_id
      in: path
      required: true
      schema: { type: string, format: uuid }
  responses:
    Unauthorized:
      description: Missing/expired/revoked token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    InsufficientScope:
      description: Token lacks the required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    NotFound:
      description: Not found (or not owned by the linked pilot)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
  securitySchemes:
    oauth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://id-preview--e7ee429a-ad09-4f0d-b537-ae8625306fa2.lovable.app/oauth/authorize
          tokenUrl: https://htnolhvggbtvvcqfcpfg.supabase.co/functions/v1/partner/oauth/token
          scopes:
            profile:read: Pilot profile
            aircraft:read: Linked aircraft
            fuel_sessions:write: Create/cancel fuel sessions
            fuel_sessions:read: Session status
            receipts:read: Signed receipts
            airports:read: Airports, prices, routing
        clientCredentials:
          tokenUrl: https://htnolhvggbtvvcqfcpfg.supabase.co/functions/v1/partner/oauth/token
          scopes:
            equipment: Controller authorization/capture
  schemas:
    TokenResponse:
      type: object
      properties:
        access_token: { type: string, description: "Opaque, prefixed spat_" }
        refresh_token: { type: string, description: "Opaque, prefixed sprt_; rotates on use" }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string }
    OAuthError:
      type: object
      properties:
        error: { type: string }
        error_description: { type: string }
    ApiError:
      type: object
      properties:
        error: { type: string }
        error_description: { type: string }
    Profile:
      type: object
      properties:
        pilot_id: { type: string, format: uuid }
        display_name: { type: string }
        verification_tier: { type: string }
        aircraft:
          type: array
          items:
            type: object
            properties:
              aircraft_id: { type: string, format: uuid }
              n_number: { type: string }
              make_model: { type: string }
              fuel_grade: { type: string }
              fuel_capacity_gal: { type: number }
        payment_method:
          type: object
          nullable: true
          properties:
            brand: { type: string }
            last4: { type: string }
    SessionCreated:
      type: object
      properties:
        session_id: { type: string, format: uuid }
        code: { type: string, description: One-time 6-digit pump code }
        expires_in_seconds: { type: integer, example: 90 }
        pump: { type: string }
        capacity_warning:
          type: object
          nullable: true
          properties:
            type: { type: string, enum: [over_capacity] }
            requested_gal: { type: number }
            capacity_gal: { type: number }
            n_number: { type: string }
            message: { type: string }
    Session:
      type: object
      properties:
        session_id: { type: string, format: uuid }
        status: { type: string, enum: [pending, authorized, captured, declined, canceled] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        ceiling_cents: { type: integer }
        price_per_gallon_cents: { type: integer }
        captured_gallons: { type: number, nullable: true }
        captured_cents: { type: integer, nullable: true }
        dispensed_gal_live: { type: number, nullable: true, description: Live meter reading while fueling }
        aircraft:
          type: object
          properties:
            n_number: { type: string }
            make_model: { type: string }
        pump:
          type: object
          properties:
            pump_id: { type: string, format: uuid }
            label: { type: string }
            pump_number: { type: integer, nullable: true }
            fuel_grade: { type: string }
        site:
          type: object
          properties:
            site_id: { type: string, format: uuid }
            name: { type: string }
            airport_id: { type: string, format: uuid, nullable: true }
    SignedReceipt:
      type: object
      properties:
        receipt:
          type: object
          properties:
            receipt_version: { type: integer }
            session_id: { type: string, format: uuid }
            captured_at: { type: string, format: date-time }
            site: { type: string }
            airport_id: { type: string, format: uuid, nullable: true }
            pump: { type: string }
            fuel_grade: { type: string }
            n_number: { type: string }
            gallons: { type: number }
            price_per_gallon_cents: { type: integer }
            amount_cents: { type: integer }
            ledger_transaction_id: { type: string, format: uuid, nullable: true }
        signature:
          type: object
          properties:
            alg: { type: string, enum: [HMAC-SHA256] }
            kid: { type: string, example: stelno-rcpt-1 }
            canon: { type: string, example: stelno-receipt-canonical-v1 }
            sig: { type: string, description: hex HMAC over the canonical receipt string }
    Airport:
      type: object
      properties:
        airport_id: { type: string, format: uuid }
        faa_id: { type: string }
        icao_id: { type: string, nullable: true }
        name: { type: string }
        city: { type: string }
        state_code: { type: string }
        lat: { type: number }
        lon: { type: number }
        elevation_ft: { type: number, nullable: true }
        max_rwy_len_ft: { type: integer, nullable: true }
        fuel_grades: { type: array, items: { type: string } }
        on_stelno: { type: boolean }
        stelno_site_id: { type: string, format: uuid, nullable: true }
        pump_online: { type: boolean, nullable: true }
        prices:
          type: object
          description: Per-grade `{ price_cents, verified, as_of }`
          additionalProperties: true
    Pump:
      type: object
      properties:
        pump_id: { type: string, format: uuid }
        label: { type: string }
        pump_number: { type: integer, nullable: true }
        fuel_grade: { type: string }
        price_per_gallon_cents: { type: integer }
        site:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            airport_id: { type: string, format: uuid, nullable: true }
security:
  - oauth2: []
webhooks:
  fuel_session_events:
    post:
      summary: Fuel session lifecycle events
      description: |
        Sent to your registered endpoint for sessions created by your client.
        Types: `fuel_session.created`, `fuel_session.authorized`, `fuel_session.captured`,
        `fuel_session.declined`, `fuel_session.canceled`.
        Verify `Stelno-Signature: t=<ts>,v1=<hex>` where `v1 = HMAC_SHA256(secret, t + "." + body)`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string, format: uuid, description: Event id }
                type: { type: string, example: fuel_session.captured }
                created_at: { type: string, format: date-time }
                data:
                  type: object
                  additionalProperties: true
                  description: Event payload (always includes `session_id`)
      responses:
        "2XX":
          description: Acknowledge with any 2xx within 5 seconds
