openapi: 3.1.0
info:
  title: CrossUpload API
  version: "1.0.0"
  description: |
    Embeddable phone/desktop upload sessions. Credential classes are distinct:
    server API keys (`sk_…`), desktop session capabilities (`cu_d_…`), phone
    upload capabilities (`cu_p_…`) and single-use pairing secrets (request body only).
    All errors use `{ "error": { "code", "message", "requestId" } }` and every
    response carries `X-Request-Id`.
  contact:
    email: master@crossupload.com
servers:
  - url: http://localhost:8080
    description: Local development
  - url: https://app.crossupload.com
    description: Production (configure API_BASE_URL)

components:
  securitySchemes:
    ServerKey:
      type: http
      scheme: bearer
      description: Secret project API key `sk_…`. Server-side only.
    DesktopCapability:
      type: http
      scheme: bearer
      description: "`cu_d_…` scoped to one session: session:read, session:upload, session:manage, events:read."
    PhoneCapability:
      type: http
      scheme: bearer
      description: "`cu_p_…` scoped to one session: session:upload only."
  parameters:
    SessionId:
      name: id
      in: path
      required: true
      schema: { type: string, example: ses_9fK2xY }
    FileId:
      name: id
      in: path
      required: true
      schema: { type: string, example: file_9fK2xY }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: files_pending }
            message: { type: string }
            requestId: { type: [string, "null"] }
    Constraints:
      type: object
      properties:
        maxFiles: { type: integer, minimum: 1, maximum: 10 }
        maxFileSize: { type: integer, description: Bytes (decimal units in UI) }
        allowedTypes: { type: array, items: { type: string } }
        allowedTypeLabels: { type: array, items: { type: string } }
    Pairing:
      type: object
      properties:
        url: { type: string, description: "Mobile URL; the single-use secret is in the fragment (#p=…)" }
        expiresAt: { type: string, format: date-time }
        qrSvg: { type: string, description: Server-rendered SVG of the URL }
    CreateSessionRequest:
      type: object
      additionalProperties: false
      properties:
        externalReference: { type: string, maxLength: 200, description: Opaque reference to your business record }
        maxFiles: { type: integer, description: Lowered to plan/project limits }
        maxFileSize: { type: integer, description: Bytes; lowered to plan/project limits }
        allowedTypes: { type: array, items: { type: string }, description: Intersected with project types }
        locale: { type: string, enum: [en, tr] }
        origin: { type: string, description: Page origin; must be in the project allowlist if one is set }
        siteName: { type: string, description: Shown on the phone page }
        ttlSeconds: { type: integer, minimum: 60, description: "Default and maximum: UPLOAD_SESSION_TTL_SECS (600)" }
    CreatedSession:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [open, completed, cancelled, expired] }
        desktopToken: { type: string }
        expiresAt: { type: string, format: date-time }
        pairing: { $ref: "#/components/schemas/Pairing" }
        constraints: { $ref: "#/components/schemas/Constraints" }
        apiBase: { type: string }
        externalReference: { type: [string, "null"] }
        branding:
          type: object
          properties:
            showAttribution: { type: boolean }
            brandColor: { type: [string, "null"] }
    FileResult:
      type: object
      required: [id, sessionId, name, size, mimeType, source, status]
      properties:
        id: { type: string }
        sessionId: { type: string }
        name: { type: string }
        size: { type: integer }
        mimeType: { type: string }
        source: { type: string, enum: [device, phone] }
        status: { type: string, enum: [uploading, processing, ready, rejected, failed, expired, deleted] }
        url: { type: string, description: Temporary delivery URL (ready files, desktop/server only) }
        urlExpiresAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, description: Retention deadline }
        clientKey: { type: string }
        error:
          type: object
          properties:
            code: { type: string, enum: [type_mismatch, malware_detected, file_too_large, empty_file, missing_object, verification_unavailable] }
            message: { type: string }
    ServerFile:
      allOf:
        - $ref: "#/components/schemas/FileResult"
        - type: object
          properties:
            projectId: { type: string }
            externalReference: { type: [string, "null"] }
            sha256: { type: [string, "null"] }
            createdAt: { type: string, format: date-time }
            readyAt: { type: [string, "null"], format: date-time }
            deletedAt: { type: [string, "null"], format: date-time }
    SessionView:
      type: object
      properties:
        id: { type: string }
        status: { type: string }
        expiresAt: { type: string, format: date-time }
        siteName: { type: string }
        constraints: { $ref: "#/components/schemas/Constraints" }
        phone:
          type: object
          properties:
            paired: { type: boolean }
            batchSent: { type: boolean }
        files: { type: array, items: { $ref: "#/components/schemas/FileResult" } }
        cursor: { type: integer, description: Durable event cursor for SSE resume }
    UploadRequest:
      type: object
      required: [files]
      additionalProperties: false
      properties:
        files:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: object
            required: [name, size, type]
            additionalProperties: false
            properties:
              clientKey: { type: string, maxLength: 64, description: Idempotency key for one logical file }
              name: { type: string }
              size: { type: integer, minimum: 1 }
              type: { type: string, description: Declared MIME type; must be allowed }
              retryOf: { type: string, description: ID of a rejected/failed file to retry }
    UploadSlot:
      type: object
      properties:
        file: { $ref: "#/components/schemas/FileResult" }
        upload:
          type: object
          properties:
            method: { type: string, enum: [POST] }
            url: { type: string }
            fields:
              type: array
              description: Ordered [name, value] pairs. Append the file last as field "file".
              items: { type: array, items: { type: string }, minItems: 2, maxItems: 2 }
            expiresAt: { type: string, format: date-time, description: At most five minutes }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

paths:
  /v1/sessions:
    post:
      summary: Create a constrained upload session
      security: [{ ServerKey: [] }]
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string, maxLength: 100 }
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateSessionRequest" }
      responses:
        "201":
          description: Created (or idempotent replay with fresh credentials)
          content: { application/json: { schema: { $ref: "#/components/schemas/CreatedSession" } } }
        "403": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }
  /v1/sessions/{id}:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    get:
      summary: Current session state
      description: Desktop capability (full view with URLs), phone capability (own uploads only, no URLs) or server key.
      security: [{ DesktopCapability: [] }, { PhoneCapability: [] }, { ServerKey: [] }]
      responses:
        "200":
          description: Session
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionView" } } }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      summary: Cancel the session
      security: [{ DesktopCapability: [] }]
      responses:
        "204": { description: Cancelled }
        "403": { $ref: "#/components/responses/Error" }
  /v1/sessions/{id}/pairing:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      summary: Refresh the QR pairing secret (before a phone has paired)
      security: [{ DesktopCapability: [] }]
      responses:
        "200":
          description: New pairing
          content: { application/json: { schema: { $ref: "#/components/schemas/Pairing" } } }
        "409": { $ref: "#/components/responses/Error" }
  /v1/pair:
    post:
      summary: Redeem a single-use pairing secret for a phone capability
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [secret]
              properties:
                secret: { type: string }
      responses:
        "200":
          description: Paired
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string }
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
                  siteName: { type: string }
                  siteOrigin: { type: [string, "null"] }
                  locale: { type: string }
                  constraints: { $ref: "#/components/schemas/Constraints" }
                  showAttribution: { type: boolean }
                  brandColor: { type: [string, "null"] }
        "404": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "410": { $ref: "#/components/responses/Error" }
  /v1/sessions/{id}/uploads:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      summary: Reserve upload slots and receive signed POST policies
      security: [{ DesktopCapability: [] }, { PhoneCapability: [] }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/UploadRequest" } } }
      responses:
        "200":
          description: Slots
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploads: { type: array, items: { $ref: "#/components/schemas/UploadSlot" } }
        "400": { $ref: "#/components/responses/Error" }
        "402": { $ref: "#/components/responses/Error" }
        "410": { $ref: "#/components/responses/Error" }
  /v1/uploads/{id}/complete:
    parameters: [{ $ref: "#/components/parameters/FileId" }]
    post:
      summary: Hint that bytes were transferred; triggers independent verification (idempotent)
      security: [{ DesktopCapability: [] }, { PhoneCapability: [] }]
      responses:
        "202":
          description: Accepted for verification
          content: { application/json: { schema: { $ref: "#/components/schemas/FileResult" } } }
  /v1/sessions/{id}/finish:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      summary: Finalize the batch (desktop) or mark the phone batch sent (phone)
      security: [{ DesktopCapability: [] }, { PhoneCapability: [] }]
      responses:
        "200": { description: Finished }
        "409": { $ref: "#/components/responses/Error" }
  /v1/sessions/{id}/events:
    parameters:
      - $ref: "#/components/parameters/SessionId"
      - name: Last-Event-ID
        in: header
        schema: { type: string }
      - name: cursor
        in: query
        schema: { type: integer }
    get:
      summary: Authenticated server-sent events (use fetch; EventSource cannot send Authorization)
      security: [{ DesktopCapability: [] }]
      responses:
        "200":
          description: text/event-stream of durable session events
          content: { text/event-stream: { schema: { type: string } } }
  /v1/sessions/{id}/funnel:
    parameters: [{ $ref: "#/components/parameters/SessionId" }]
    post:
      summary: Report picker_opened / phone_chosen funnel stages
      security: [{ DesktopCapability: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                stage: { type: string, enum: [picker_opened, phone_chosen] }
      responses:
        "204": { description: Recorded }
  /v1/files:
    get:
      summary: Files of one of your sessions
      security: [{ ServerKey: [] }]
      parameters:
        - name: sessionId
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Files
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/ServerFile" } }
  /v1/files/{id}:
    parameters: [{ $ref: "#/components/parameters/FileId" }]
    get:
      summary: File metadata and status (project scoped)
      security: [{ ServerKey: [] }]
      responses:
        "200":
          description: File
          content: { application/json: { schema: { $ref: "#/components/schemas/ServerFile" } } }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      summary: Permanently delete (stops URL issuance; removes all versions; storage released after removal)
      security: [{ ServerKey: [] }]
      parameters:
        - name: invalidateCdn
          in: query
          schema: { type: boolean }
      responses:
        "202": { description: Deletion queued }
        "409": { $ref: "#/components/responses/Error" }
  /v1/files/{id}/url:
    parameters: [{ $ref: "#/components/parameters/FileId" }]
    post:
      summary: Issue a fresh temporary delivery URL
      security: [{ ServerKey: [] }]
      responses:
        "200":
          description: File with url and urlExpiresAt
          content: { application/json: { schema: { $ref: "#/components/schemas/ServerFile" } } }
        "409": { $ref: "#/components/responses/Error" }
  /v1/usage:
    get:
      summary: Account and project usage
      security: [{ ServerKey: [] }]
      responses:
        "200": { description: Usage summary }
  /webhooks/stripe:
    post:
      summary: Stripe billing webhook (signature verified over the raw body)
      responses:
        "200": { description: Received }
        "400": { $ref: "#/components/responses/Error" }
  /health/live:
    get:
      summary: Liveness
      responses: { "200": { description: OK } }
  /health/ready:
    get:
      summary: Readiness (database, storage, scanner)
      responses:
        "200": { description: Ready }
        "503": { description: Degraded }
