openapi: 3.1.0
info:
  title: Skiff Public API
  version: "1.0"
  description: >
    Owner-scoped access to Skiff vessel data: vessels, the maintenance due
    feed, the logbook, inspections, service logging, and engine hours.
    Authenticate with a personal access key (Captain and Admiral plans),
    created at https://skiff.boatsmarthq.com/account/developer.
servers:
  - url: https://binranoynwqpydttvgbs.supabase.co/functions/v1/public-api
security:
  - bearerKey: []
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: "Personal access key, e.g. sk_live_…"
    connectionKey:
      type: http
      scheme: bearer
      description: >
        Connection key, e.g. ct_…. Issued per vessel connection, used only by
        the onboard system pushing telemetry.
  schemas:
    Vessel:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        vessel_type: { type: string }
        make: { type: [string, "null"] }
        model: { type: [string, "null"] }
        hin: { type: [string, "null"] }
        length_m: { type: [number, "null"] }
        marina: { type: [string, "null"] }
        health_score: { type: [integer, "null"] }
        engine_hours: { type: [integer, "null"] }
        service_level: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    DueItem:
      type: object
      properties:
        task_id: { type: string, format: uuid }
        title: { type: string }
        status: { type: string, enum: [ok, due_soon, overdue] }
        due_date: { type: [string, "null"], format: date-time }
        due_hours: { type: [integer, "null"] }
    ServiceLog:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        done_at: { type: string, format: date-time }
        engine_hours: { type: [integer, "null"] }
        cost: { type: [number, "null"] }
        currency: { type: [string, "null"] }
        route: { type: string, enum: [diy, supplier] }
        note: { type: [string, "null"] }
        task_id: { type: [string, "null"], format: uuid }
    EngineHourLog:
      type: object
      properties:
        id: { type: string, format: uuid }
        hours: { type: [integer, "null"] }
        litres: { type: [number, "null"] }
        fuel_cost: { type: [number, "null"] }
        currency: { type: [string, "null"] }
        source: { type: string, enum: [manual, inspection, service] }
        logged_at: { type: string, format: date-time }
    Inspection:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [in_progress, completed] }
        started_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        score: { type: [integer, "null"] }
        engine_hours: { type: [integer, "null"] }
        vir_code: { type: [string, "null"] }
    Error:
      type: object
      properties:
        error: { type: string }
    Reading:
      type: object
      description: >
        One normalized measurement. Providers speak their own dialect; the
        adapter maps into this vocabulary before sending.
      required: [metric]
      properties:
        metric:
          type: string
          enum:
            - engine_hours
            - engine_rpm
            - engine_coolant_temp
            - engine_oil_pressure
            - fuel_level_pct
            - fuel_rate
            - fuel_used
            - battery_voltage
            - battery_soc_pct
            - water_level_pct
            - waste_level_pct
            - bilge_water
            - bilge_pump_cycles
            - shore_power
            - position_lat
            - position_lon
            - depth
            - speed_over_ground
            - water_temp
            - fault_code
        value:
          type: [number, "null"]
          description: Required unless text_value is given.
        text_value:
          type: [string, "null"]
          maxLength: 120
          description: Used by fault_code.
        unit: { type: [string, "null"], maxLength: 16 }
        instance:
          type: [string, "null"]
          maxLength: 40
          description: Which engine, tank or battery, e.g. port, starboard, house.
        recorded_at:
          type: string
          format: date-time
          description: Defaults to arrival time.
    Provider:
      type: object
      properties:
        slug: { type: string }
        name: { type: string }
        summary: { type: string }
        direction:
          type: string
          enum: [push, pull]
          description: push means the boat sends to Skiff; pull needs an upstream grant.
        status: { type: string, enum: [live, beta, planned, unavailable] }
        covers:
          type: array
          items: { type: string }
          description: Engine and system brands this provider can carry.
        install_notes: { type: [string, "null"] }
        docs_url: { type: [string, "null"] }
    Connection:
      type: object
      properties:
        id: { type: string, format: uuid }
        provider: { type: string }
        label: { type: [string, "null"] }
        config: { type: object }
        ingest_key_prefix: { type: [string, "null"] }
        enabled: { type: boolean }
        last_seen_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    Camera:
      type: object
      properties:
        id: { type: string, format: uuid }
        label: { type: string }
        view:
          type: string
          enum: [cockpit, saloon, engine_bay, helm, deck, berth, other]
        enabled: { type: boolean }
        last_frame_at: { type: [string, "null"], format: date-time }
        last_error: { type: [string, "null"] }
        retain_frames: { type: integer }
        created_at: { type: string, format: date-time }
    Frame:
      type: object
      properties:
        id: { type: string, format: uuid }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
        bytes: { type: [integer, "null"] }
        trigger:
          type: string
          enum: [device, request, motion]
          description: Why the frame exists, from the camera's own schedule, an owner checking in, or motion.
        captured_at: { type: string, format: date-time }
        url:
          type: [string, "null"]
          description: Signed for 30 minutes.
paths:
  /v1/vessels:
    get:
      summary: List vessels
      responses:
        "200":
          description: The account's vessels
          content:
            application/json:
              schema:
                type: object
                properties:
                  vessels:
                    type: array
                    items: { $ref: "#/components/schemas/Vessel" }
  /v1/vessels/{id}:
    get:
      summary: One vessel
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: The vessel
          content:
            application/json:
              schema:
                type: object
                properties:
                  vessel: { $ref: "#/components/schemas/Vessel" }
        "404":
          description: Not found or not yours
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /v1/vessels/{id}/due:
    get:
      summary: Maintenance due feed
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Per-task due status
          content:
            application/json:
              schema:
                type: object
                properties:
                  vessel_id: { type: string, format: uuid }
                  engine_hours: { type: [integer, "null"] }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/DueItem" }
  /v1/vessels/{id}/logbook:
    get:
      summary: Service entries and engine-hour logs
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Newest first, up to 200 each
          content:
            application/json:
              schema:
                type: object
                properties:
                  service_logs:
                    type: array
                    items: { $ref: "#/components/schemas/ServiceLog" }
                  engine_hour_logs:
                    type: array
                    items: { $ref: "#/components/schemas/EngineHourLog" }
  /v1/vessels/{id}/inspections:
    get:
      summary: Inspection history
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Last 50 inspections
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspections:
                    type: array
                    items: { $ref: "#/components/schemas/Inspection" }
  /v1/inspections/{id}:
    get:
      summary: One inspection with items
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Inspection detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection: { $ref: "#/components/schemas/Inspection" }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        template_item_id: { type: [string, "null"], format: uuid }
                        verdict:
                          type: [string, "null"]
                          enum: [operational, attention, non_operational, null]
                        note: { type: [string, "null"] }
                        updated_at: { type: string, format: date-time }
  /v1/vessels/{id}/service-log:
    post:
      summary: Create a service entry
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, maxLength: 120 }
                done_at: { type: string, format: date-time }
                engine_hours: { type: integer }
                cost: { type: number }
                currency: { type: string, maxLength: 3 }
                route: { type: string, enum: [diy, supplier], default: diy }
                note: { type: string, maxLength: 2000 }
                task_id: { type: string, format: uuid }
      responses:
        "201":
          description: Created
  /v1/vessels/{id}/hours:
    post:
      summary: Log engine hours
      description: Hours must not be below the vessel's current meter.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hours]
              properties:
                hours: { type: integer, minimum: 0 }
                litres: { type: number }
                fuel_cost: { type: number }
                currency: { type: string, maxLength: 3 }
                logged_at: { type: string, format: date-time }
                source:
                  type: string
                  enum: [manual, signalk, api]
                  default: manual
                  description: Where the reading came from; shown in the app.
      responses:
        "201":
          description: Created; the vessel meter is updated too
        "422":
          description: hours_below_current
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/telemetry:
    post:
      summary: Push telemetry readings
      description: >
        Authenticated with a connection key (ct_…), not a personal access key.
        A connection key belongs to one box on one boat and can only write
        telemetry for that vessel. Up to 200 readings per request, at most one
        request every five seconds per connection. Unknown metric names are
        reported in `rejected` and the rest of the batch is still stored.
        An engine_hours reading also moves the vessel's hour meter, floored to
        whole hours and only ever forward.
      security:
        - connectionKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [readings]
              properties:
                readings:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { $ref: "#/components/schemas/Reading" }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: integer }
                  rejected:
                    type: array
                    items:
                      type: object
                      properties:
                        metric: { type: string }
                        reason: { type: string }
                  engine_hours_applied: { type: boolean }
        "401":
          description: invalid_connection_key
        "403":
          description: connection_disabled
        "413":
          description: too_many_readings
        "429":
          description: too_frequent
        "503":
          description: ingest_disabled

  /v1/telemetry/providers:
    get:
      summary: List telemetry providers
      description: >
        The systems Skiff can take data from. Providers with direction `push`
        and a status other than `unavailable` can be connected now; `pull`
        providers need the other side to grant access.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers:
                    type: array
                    items: { $ref: "#/components/schemas/Provider" }

  /v1/telemetry/brand-requests:
    post:
      summary: Register interest in a system Skiff cannot reach
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [system_name]
              properties:
                system_name: { type: string, minLength: 2, maxLength: 80 }
                boat_make: { type: string, maxLength: 60 }
                vessel_id: { type: string, format: uuid }
                note: { type: string, maxLength: 500 }
      responses:
        "201": { description: Created }

  /v1/vessels/{id}/connections:
    get:
      summary: List a vessel's connections
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  connections:
                    type: array
                    items: { $ref: "#/components/schemas/Connection" }
    post:
      summary: Create a connection
      description: >
        Returns the connection key once. Only push providers can be connected.
        Config must not carry a password for another service; Skiff reads the
        boat's own data and never signs in on the owner's behalf.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provider]
              properties:
                provider: { type: string, example: nmea2000_gateway }
                label: { type: string, maxLength: 60 }
                config: { type: object }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  connection: { $ref: "#/components/schemas/Connection" }
                  ingest_key: { type: string, example: "ct_…" }
        "400":
          description: unknown_provider or credentials_not_accepted
        "409":
          description: already_connected
        "422":
          description: provider_not_connectable

  /v1/connections/{id}:
    delete:
      summary: Remove a connection
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: Removed }
        "404": { description: not_found }

  /v1/cameras/frames:
    post:
      summary: Send a still from an onboard camera
      description: >
        Authenticated with a connection key (ct_…), not a personal access key.
        Stills only; Skiff stores no video. Pass `camera_id` once you have one,
        or a `label` and the camera registers itself on its first frame (up to
        four cameras per boat). At most one frame every ten seconds per camera,
        except when an owner has a check-in waiting, which lets a frame through
        straight away. 8 MiB per image. Frames beyond the camera's
        `retain_frames` are dropped as new ones land.
      security:
        - connectionKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_base64]
              properties:
                camera_id: { type: string, format: uuid }
                label: { type: string, maxLength: 60, example: Cockpit }
                view:
                  type: string
                  enum: [cockpit, saloon, engine_bay, helm, deck, berth, other]
                image_base64: { type: string, description: Raw base64 or a data URL. }
                mime:
                  type: string
                  enum: [image/jpeg, image/png, image/webp]
                  default: image/jpeg
                width: { type: integer }
                height: { type: integer }
                trigger: { type: string, enum: [device, motion] }
                captured_at: { type: string, format: date-time }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  frame_id: { type: string, format: uuid }
                  camera_id: { type: string, format: uuid }
                  answered_check_in: { type: boolean }
        "413": { description: frame_too_large }
        "415": { description: unsupported_mime }
        "422": { description: camera_limit or upload_failed }
        "429": { description: too_frequent }

  /v1/cameras/pending:
    get:
      summary: Check whether anyone is waiting on a picture
      description: >
        Polled by the camera. Returns the owner check-ins still open for the
        cameras on this connection; answer one by posting a frame. Requests
        past their window are closed here, so a boat that was offline never
        answers a day-old tap with a picture and calls it current.
      security:
        - connectionKey: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        camera_id: { type: string, format: uuid }
                        label: { type: [string, "null"] }
                        requested_at: { type: string, format: date-time }
                        expires_at: { type: string, format: date-time }
                  poll_after_ms: { type: integer, example: 30000 }

  /v1/vessels/{id}/cameras:
    get:
      summary: List a vessel's cameras
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  cameras:
                    type: array
                    items: { $ref: "#/components/schemas/Camera" }

  /v1/cameras/{id}/frames:
    get:
      summary: Read a camera's stills, newest first
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 10, maximum: 50 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  camera_id: { type: string, format: uuid }
                  frames:
                    type: array
                    items: { $ref: "#/components/schemas/Frame" }

  /v1/cameras/{id}/check-in:
    post:
      summary: Ask a camera for a fresh picture
      description: >
        Queues a request the camera answers on its next poll, within five
        minutes. Nothing here waits on the boat being awake.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "202":
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      requested_at: { type: string, format: date-time }
                      expires_at: { type: string, format: date-time }
        "403": { description: camera_disabled }
        "404": { description: not_found }

  /v1/vessels/{id}/telemetry:
    get:
      summary: Read stored telemetry
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: metric, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  readings:
                    type: array
                    items: { $ref: "#/components/schemas/Reading" }
