openapi: 3.1.0

info:
  title: Etablone Cloud API
  version: "0.1.0"
  description: >
    Device v0 board protocol (compatible with Zephyr/Linux e-tabelone clients)
    plus human admin API for orgs, teams, assets, schedules and telemetry.
    Boards use /node/v0 only — there is no newer board protocol.

servers:
  - url: http://127.0.0.1:8787
    description: Local wrangler dev
  - url: https://etablone.dynamicdevices.co.uk
    description: Production
  - url: https://dev.etablone.dynamicdevices.co.uk
    description: Staging / OTP-echo Worker
  - url: https://etablone-cloud.active-esl.workers.dev
    description: Production workers.dev (legacy)

tags:
  - name: health
  - name: admin-auth
  - name: admin-tenancy
  - name: admin-assets
  - name: device-v0

security:
  - bearerAuth: []

paths:
  /health:
    get:
      tags: [health]
      summary: Liveness
      security: []
      responses:
        "200":
          description: Up
          content:
            application/json:
              schema:
                type: object
                required: [ok, service]
                properties:
                  ok: { type: boolean }
                  service: { type: string }

  /health/ready:
    get:
      tags: [health]
      summary: Readiness — probes D1, KV, and R2
      security: []
      responses:
        "200":
          description: All bindings healthy
        "503":
          description: One or more bindings failed

  /admin/v1/auth/request:
    post:
      tags: [admin-auth]
      summary: Email a one-time sign-in code
      description: >
        Only provisioned users (or ADMIN_EMAILS allowlist) may request a code.
        Unknown emails receive 403.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Code sent (dev may echo)
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
                  devCode: { type: string }
        "400": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }

  /admin/v1/auth/verify:
    post:
      tags: [admin-auth]
      summary: Exchange email + OTP for token pair
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string }
      responses:
        "200":
          description: Tokens
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "401": { $ref: "#/components/responses/Error" }

  /admin/v1/auth/refresh:
    post:
      tags: [admin-auth]
      summary: Rotate refresh token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"

  /admin/v1/auth/logout:
    post:
      tags: [admin-auth]
      summary: Revoke refresh token
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refreshToken: { type: string }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }

  /admin/v1/me:
    get:
      tags: [admin-auth]
      summary: Current user
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user:
                    type: object
                    required: [id, email]
                    properties:
                      id: { type: string }
                      email: { type: string }

  /admin/v1/users:
    get:
      tags: [admin-tenancy]
      summary: List all users (platform admin)
      responses:
        "200":
          description: Users with org memberships
    post:
      tags: [admin-tenancy]
      summary: Provision user (platform admin)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string }
                is_admin: { type: boolean }
                org_id: { type: string }
                role: { type: string }
      responses:
        "201":
          description: Created or existing

  /admin/v1/users/{userId}:
    patch:
      tags: [admin-tenancy]
      summary: Promote/demote or disable/enable user (platform admin)
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                is_admin: { type: boolean }
                disabled: { type: boolean }
      responses:
        "200":
          description: Updated
    delete:
      tags: [admin-tenancy]
      summary: Delete user (platform admin)
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted

  /admin/v1/users/{userId}/orgs:
    post:
      tags: [admin-tenancy]
      summary: Assign user to organisation (platform admin)
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [org_id]
              properties:
                org_id: { type: string }
                role: { type: string }
      responses:
        "200":
          description: Assigned

  /admin/v1/users/{userId}/orgs/{orgId}:
    delete:
      tags: [admin-tenancy]
      summary: Remove user from organisation (platform admin)
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
        - name: orgId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Removed

  /admin/v1/orgs:
    get:
      tags: [admin-tenancy]
      summary: List orgs for caller
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                required: [orgs]
                properties:
                  orgs:
                    type: array
                    items: { type: object }
    post:
      tags: [admin-tenancy]
      summary: Create org + default team (platform admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "201":
          description: Created
        "403":
          description: Caller is not a platform admin

  /admin/v1/orgs/{orgId}/members:
    get:
      tags: [admin-tenancy]
      summary: List org members
      parameters:
        - $ref: "#/components/parameters/orgId"
      responses:
        "200":
          description: Members
    post:
      tags: [admin-tenancy]
      summary: Invite user into org (org_owner / org_admin / platform admin)
      description: >
        Creates the user account if needed. Only admins can add users —
        open self-registration is disabled.
      parameters:
        - $ref: "#/components/parameters/orgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                role:
                  type: string
                  enum: [org_owner, org_admin, operator, viewer]
      responses:
        "201":
          description: Invited
        "200":
          description: Role updated for existing member

  /admin/v1/orgs/{orgId}/members/{userId}:
    delete:
      tags: [admin-tenancy]
      summary: Remove org member
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Removed

  /admin/v1/orgs/{orgId}/devices:
    get:
      tags: [admin-tenancy]
      summary: List devices in an org
      parameters:
        - $ref: "#/components/parameters/orgId"
      responses:
        "200":
          description: Device list
    post:
      tags: [admin-tenancy]
      summary: Register device and mint device Bearer token
      parameters:
        - $ref: "#/components/parameters/orgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, team_id]
              properties:
                device_id: { type: string }
                team_id: { type: string }
                preferred_format:
                  type: string
                  enum: [jpeg, es6f]
                orientation: { type: integer }
                name: { type: string }
      responses:
        "201":
          description: Device + plaintext device_token (shown once)

  /admin/v1/orgs/{orgId}/teams:
    get:
      tags: [admin-tenancy]
      summary: List teams in an org
      parameters:
        - $ref: "#/components/parameters/orgId"
      responses:
        "200":
          description: Team list

  /admin/v1/orgs/{orgId}/devices/{deviceId}/token:
    post:
      tags: [admin-tenancy]
      summary: Rotate device Bearer token (previous token invalidated)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      responses:
        "200":
          description: New plaintext device_token (shown once)

  /admin/v1/orgs/{orgId}/devices/{deviceId}:
    patch:
      tags: [admin-tenancy]
      summary: Set or clear device map location (manual lock)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                latitude: { type: number, nullable: true }
                longitude: { type: number, nullable: true }
                location_accuracy_m: { type: number, nullable: true }
                location_source:
                  type: string
                  nullable: true
                  enum: [manual, device]
                  description: null unlocks board GNSS updates
                name: { type: string, nullable: true }
      responses:
        "200":
          description: Updated
    delete:
      tags: [admin-tenancy]
      summary: Delete device and its schedule/telemetry
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      responses:
        "200":
          description: Deleted

  /admin/v1/orgs/{orgId}/assets:
    get:
      tags: [admin-assets]
      summary: List assets in an org (name, type, byte_size, status, thumbs)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: q
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 24 }
        - name: page
          in: query
          schema: { type: integer, default: 1 }
      responses:
        "200":
          description: Asset list (includes byte_size when known)
    post:
      tags: [admin-assets]
      summary: Upload media — JPEG/PNG (cloud→ES6F) or pre-processed ES6F / ES6F.LZ4
      description: |
        **Standard images:** JPEG or PNG bodies are scaled to the panel, Floyd–Steinberg
        dithered to Spectra-6, and stored as ES6F (`processing: converted`).

        **Pre-processed e-ink:** Raw ES6F (magic `ES6F`) or LZ4-framed ES6F
        (magic `04 22 4D 18`) is validated and stored as-is (`processing: stored`).
        LZ4 uploads are decompressed server-side; R2 always keeps uncompressed ES6F.
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: X-Team-Id
          in: header
          required: true
          schema: { type: string }
        - name: X-Asset-Name
          in: header
          required: false
          schema: { type: string, maxLength: 200 }
        - name: X-Asset-Orientation
          in: header
          required: false
          schema: { type: string, enum: [portrait, landscape] }
      requestBody:
        required: true
        content:
          image/jpeg:
            schema: { type: string, format: binary }
          image/png:
            schema: { type: string, format: binary }
          application/vnd.etablone.es6f:
            schema: { type: string, format: binary }
          application/vnd.etablone.es6f+lz4:
            schema: { type: string, format: binary }
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "201":
          description: Asset created (includes source_format and processing)
        "415":
          description: Unsupported media type
        "422":
          description: Decode/convert or ES6F validation failed

  /admin/v1/orgs/{orgId}/assets/{assetId}:
    patch:
      tags: [admin-assets]
      summary: Update asset friendly name and/or orientation
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: assetId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  nullable: true
                  description: Friendly name; null or empty clears it
                orientation:
                  type: string
                  enum: [portrait, landscape]
                  description: Content layout relative to the panel
      responses:
        "200":
          description: Updated
    delete:
      tags: [admin-assets]
      summary: Delete asset (fails if referenced by schedules)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: assetId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted
        "409":
          description: Still referenced by a schedule

  /admin/v1/orgs/{orgId}/groups:
    get:
      tags: [admin-groups]
      summary: List board groups
      parameters:
        - $ref: "#/components/parameters/orgId"
      responses:
        "200":
          description: Groups with member/job counts
    post:
      tags: [admin-groups]
      summary: Create board group
      parameters:
        - $ref: "#/components/parameters/orgId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                device_ids:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Created

  /admin/v1/orgs/{orgId}/groups/{groupId}:
    get:
      tags: [admin-groups]
      summary: Get group with members and schedule
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Group detail
    patch:
      tags: [admin-groups]
      summary: Rename group
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
      responses:
        "200":
          description: Updated
    delete:
      tags: [admin-groups]
      summary: Delete group, members, and group schedule
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted

  /admin/v1/orgs/{orgId}/groups/{groupId}/members:
    put:
      tags: [admin-groups]
      summary: Replace group members
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_ids]
              properties:
                device_ids:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Updated

  /admin/v1/orgs/{orgId}/groups/{groupId}/schedule:
    get:
      tags: [admin-groups]
      summary: Get group schedule
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Group jobs
    put:
      tags: [admin-groups]
      summary: Replace group schedule (inherited by member devices)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schedule]
              properties:
                schedule:
                  type: array
                  items:
                    type: object
                    required: [asset_id, cron]
                    properties:
                      job_id: { type: string }
                      asset_id: { type: string }
                      cron: { type: string }
      responses:
        "200":
          description: Updated

  /admin/v1/orgs/{orgId}/devices/{deviceId}/schedule:
    get:
      tags: [admin-tenancy]
      summary: Get device schedule
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      responses:
        "200":
          description: Current schedule jobs and orientation
    put:
      tags: [admin-tenancy]
      summary: Replace device schedule
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schedule]
              properties:
                orientation: { type: integer }
                schedule:
                  type: array
                  items:
                    type: object
                    required: [job_id, asset_id, cron]
                    properties:
                      job_id: { type: string }
                      asset_id: { type: string }
                      cron: { type: string }
      responses:
        "200":
          description: Updated

  /admin/v1/orgs/{orgId}/devices/{deviceId}/telemetry:
    get:
      tags: [admin-tenancy]
      summary: Latest device telemetry
      parameters:
        - $ref: "#/components/parameters/orgId"
        - $ref: "#/components/parameters/deviceId"
      responses:
        "200":
          description: Latest row or null

  /admin/v1/orgs/{orgId}/assets/sdl-fixture:
    post:
      tags: [admin-assets]
      summary: Create an SDL-ready ES6F fixture (solid, lr, or bars)
      parameters:
        - $ref: "#/components/parameters/orgId"
        - name: X-Team-Id
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                pattern:
                  type: string
                  enum: [solid, lr, bars]
                color: { type: string }
                left: { type: string }
                right: { type: string }
      responses:
        "201":
          description: SDL-ready asset

  /node/v0/device/{deviceId}/config:
    get:
      tags: [device-v0]
      summary: Device config — packed ES6F (default LZ4-framed) or original
      parameters:
        - $ref: "#/components/parameters/deviceId"
        - name: format
          in: query
          schema:
            type: string
            enum: [es6f, es6f.lz4, original, jpeg]
          description: >
            es6f.lz4 = LZ4-framed ES6F (default for preferred_format es6f);
            es6f = raw 960032-byte ES6F; original/jpeg = source asset
        - name: X-Etablone-Client
          in: header
          schema:
            type: string
            enum: [sdl, zephyr, native_sim]
          description: When set to sdl/zephyr/native_sim, image URLs are ES6F
      responses:
        "200":
          description: Config
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceConfig"
        "401": { $ref: "#/components/responses/Error" }

  /node/v0/device/{deviceId}/telemetry:
    post:
      tags: [device-v0]
      summary: Post telemetry + schedule ack
      parameters:
        - $ref: "#/components/parameters/deviceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TelemetryRequest"
      responses:
        "200":
          description: Ack
          content:
            application/json:
              schema:
                type: object
                required: [serial_number, status]
                properties:
                  serial_number: { type: string }
                  status: { type: string }

  /node/v0/device/{deviceId}/assets/{assetId}:
    get:
      tags: [device-v0]
      summary: Download image bytes (ES6F or original)
      parameters:
        - $ref: "#/components/parameters/deviceId"
        - name: assetId
          in: path
          required: true
          schema: { type: string }
        - name: format
          in: query
          schema:
            type: string
            enum: [es6f, es6f.lz4, original]
      responses:
        "200":
          description: Binary asset
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Human JWT (admin) or opaque device token (device-v0)

  parameters:
    orgId:
      name: orgId
      in: path
      required: true
      schema: { type: string }
    deviceId:
      name: deviceId
      in: path
      required: true
      schema: { type: string }

  responses:
    Error:
      description: Error
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error: { type: string }

  schemas:
    TokenPair:
      type: object
      required: [accessToken, refreshToken, expiresIn, user]
      properties:
        accessToken: { type: string }
        refreshToken: { type: string }
        expiresIn: { type: integer }
        user:
          type: object
          required: [id, email]
          properties:
            id: { type: string }
            email: { type: string }

    DeviceConfig:
      type: object
      required: [orientation, images, schedule]
      properties:
        orientation: { type: integer }
        delivery_format:
          type: string
          enum: [es6f, es6f.lz4, original]
          description: Format used for images[].url in this response
        images:
          type: array
          items:
            type: object
            required: [image_id, url]
            properties:
              image_id: { type: string }
              url:
                type: string
                description: >
                  Default ends with .es6f.lz4 (LZ4 frame of ES6F).
                  Raw .es6f serves application/vnd.etablone.es6f (960032 bytes).
        schedule:
          type: array
          items:
            type: object
            required: [job_id, image_id, cron]
            properties:
              job_id: { type: string }
              image_id: { type: string }
              cron: { type: string }

    TelemetryRequest:
      type: object
      required: [telemetry]
      properties:
        telemetry:
          type: object
          properties:
            battery_capacity: { type: integer }
            next_wakeup_date: { type: string }
            current_displayed_job_id: { type: string }
            orientation: { type: integer }
            latitude: { type: number, description: WGS84 degrees }
            longitude: { type: number, description: WGS84 degrees }
            location_accuracy_m: { type: number }
        schedule:
          type: array
          items:
            type: object
            required: [job_id]
            properties:
              job_id: { type: string }
