openapi: 3.1.0
info:
  title: Storage API
  version: 0.1.0
  description: >
    Developer-first object storage on Cloudflare R2. One API key, logical
    buckets, signed-URL uploads, and webhooks. This spec documents the routes
    that are implemented today; CDN/custom-domains, image transforms, and
    multipart uploads are planned and intentionally not described here.
  contact:
    name: API Support
    url: https://docs.example.com
  license:
    name: Proprietary
    url: https://example.com/terms

servers:
  - url: https://api.example.com
    description: Production
  - url: http://localhost:8787
    description: Local (wrangler dev)

security:
  - bearerAuth: []

tags:
  - name: Buckets
    description: Create and manage logical buckets.
  - name: Objects
    description: Upload, download, list, copy, and delete objects.
  - name: Multipart
    description: Resumable multipart uploads for large objects.
  - name: Signed URLs
    description: Issue and consume time-limited HMAC URLs.
  - name: Webhooks
    description: Subscribe to object events.
  - name: API Keys
    description: Tenant self-serve API key management.
  - name: Admin
    description: Provision tenants and bootstrap keys (X-Admin-Secret).
  - name: System
    description: Health and operational endpoints.

paths:
  /health:
    get:
      tags: [System]
      operationId: healthCheck
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }

  /v1/buckets:
    post:
      tags: [Buckets]
      operationId: createBucket
      summary: Create a bucket
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateBucket" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bucket" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
    get:
      tags: [Buckets]
      operationId: listBuckets
      summary: List buckets
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Bucket" }

  /v1/buckets/{bucket}:
    parameters:
      - $ref: "#/components/parameters/Bucket"
    get:
      tags: [Buckets]
      operationId: getBucket
      summary: Get a bucket
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bucket" }
        "404": { $ref: "#/components/responses/Error" }
    patch:
      tags: [Buckets]
      operationId: updateBucket
      summary: Update a bucket
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateBucket" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Bucket" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [Buckets]
      operationId: deleteBucket
      summary: Delete a bucket (must be empty)
      responses:
        "204": { description: Deleted }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/buckets/{bucket}/objects:
    parameters:
      - $ref: "#/components/parameters/Bucket"
    get:
      tags: [Objects]
      operationId: listObjects
      summary: List objects
      parameters:
        - { name: prefix, in: query, schema: { type: string } }
        - { name: cursor, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 1000 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ObjectSummary" }
                  cursor: { type: [string, "null"] }

  /v1/buckets/{bucket}/objects/batch-delete:
    parameters:
      - $ref: "#/components/parameters/Bucket"
    post:
      tags: [Objects]
      operationId: batchDeleteObjects
      summary: Delete multiple objects
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keys]
              properties:
                keys:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: integer }

  /v1/buckets/{bucket}/objects/{key}:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - $ref: "#/components/parameters/Key"
    put:
      tags: [Objects]
      operationId: uploadObject
      summary: Upload an object
      description: >
        Raw binary body. Set Content-Type for the stored object; send custom
        metadata via x-metadata-* headers.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "201":
          description: Uploaded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    get:
      tags: [Objects]
      operationId: downloadObject
      summary: Download an object
      parameters:
        - name: download
          in: query
          description: When present, forces a Content-Disposition attachment.
          schema: { type: string }
      responses:
        "200":
          description: Object bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/Error" }
    head:
      tags: [Objects]
      operationId: headObject
      summary: Object metadata (headers only)
      responses:
        "200": { description: Metadata in headers }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [Objects]
      operationId: deleteObject
      summary: Delete an object
      responses:
        "204": { description: Deleted }

  /v1/buckets/{bucket}/objects/{key}/copy:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - $ref: "#/components/parameters/Key"
    post:
      tags: [Objects]
      operationId: copyObject
      summary: Copy an object
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destination]
              properties:
                destination: { type: string }
      responses:
        "201":
          description: Copied
          content:
            application/json:
              schema:
                type: object
                properties:
                  bucket: { type: string }
                  key: { type: string }
                  etag: { type: string }
        "404": { $ref: "#/components/responses/Error" }

  /v1/buckets/{bucket}/sign:
    parameters:
      - $ref: "#/components/parameters/Bucket"
    post:
      tags: [Signed URLs]
      operationId: createSignedUrl
      summary: Issue a signed URL
      description: >
        Returns a time-limited HMAC URL scoped to exactly one method + key,
        consumable at /signed/... without an API key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignedUrl" }
        "400": { $ref: "#/components/responses/Error" }

  /signed/{bucket}/{key}:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - $ref: "#/components/parameters/Key"
      - { name: t, in: query, required: true, schema: { type: string }, description: Tenant id (integrity-protected by the signature) }
      - { name: method, in: query, required: true, schema: { type: string, enum: [GET, PUT] } }
      - { name: exp, in: query, required: true, schema: { type: integer }, description: Unix expiry }
      - { name: sig, in: query, required: true, schema: { type: string }, description: Base64url HMAC-SHA256 }
      - { name: max, in: query, schema: { type: integer }, description: Max body bytes (PUT) }
    get:
      tags: [Signed URLs]
      operationId: signedDownload
      summary: Download via signed URL
      security: []
      responses:
        "200":
          description: Object bytes
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [Signed URLs]
      operationId: signedUpload
      summary: Upload via signed URL
      security: []
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "201":
          description: Uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  key: { type: string }
                  size: { type: integer }
                  etag: { type: string }
        "401": { $ref: "#/components/responses/Error" }
        "413": { $ref: "#/components/responses/Error" }

  /v1/webhooks:
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Create a webhook
      description: The signingSecret is returned in full only on creation.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhook" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/Error" }
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhooks
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }

  /v1/webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Get a webhook
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/Error" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWebhook" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/Error" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/Error" }

  /admin/tenants:
    post:
      tags: [Admin]
      operationId: createTenant
      summary: Provision a tenant
      security:
        - adminAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                region: { type: string, default: us }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  region: { type: string }
                  shard: { type: string }
        "401": { $ref: "#/components/responses/Error" }

  /admin/tenants/{id}/keys:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [Admin]
      operationId: bootstrapKey
      summary: Bootstrap a tenant's first API key
      security:
        - adminAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties: { name: { type: string } }
      responses:
        "201":
          description: Created (plaintext key returned once)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IssuedKey" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/account:
    get:
      tags: [System]
      operationId: getAccount
      summary: Account info + current-period usage
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      plan: { type: string }
                      region: { type: string }
                  period: { type: string }
                  usage:
                    type: object
                    properties:
                      storedBytes: { type: integer }
                      classAOps: { type: integer }
                      classBOps: { type: integer }
                      egressBytes: { type: integer }
                  storageLimitBytes: { type: [integer, "null"] }

  /v1/keys:
    post:
      tags: [API Keys]
      operationId: createApiKey
      summary: Issue a new API key
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties: { name: { type: string } }
      responses:
        "201":
          description: Created (plaintext key returned once)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IssuedKey" }
    get:
      tags: [API Keys]
      operationId: listApiKeys
      summary: List API keys (no key material)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }

  /v1/keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [API Keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      responses:
        "204": { description: Revoked }
        "404": { $ref: "#/components/responses/Error" }

  /v1/buckets/{bucket}/uploads:
    parameters:
      - $ref: "#/components/parameters/Bucket"
    post:
      tags: [Multipart]
      operationId: createMultipartUpload
      summary: Start a multipart upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key: { type: string }
                contentType: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  bucket: { type: string }
                  key: { type: string }
                  uploadId: { type: string }

  /v1/buckets/{bucket}/uploads/{uploadId}/parts/{n}:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - { name: uploadId, in: path, required: true, schema: { type: string } }
      - { name: n, in: path, required: true, schema: { type: integer, minimum: 1 } }
      - { name: key, in: query, required: true, schema: { type: string } }
    put:
      tags: [Multipart]
      operationId: uploadPart
      summary: Upload a part (>= 5 MiB except the last)
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  partNumber: { type: integer }
                  etag: { type: string }

  /v1/buckets/{bucket}/uploads/{uploadId}/complete:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - { name: uploadId, in: path, required: true, schema: { type: string } }
    post:
      tags: [Multipart]
      operationId: completeMultipartUpload
      summary: Complete a multipart upload
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, parts]
              properties:
                key: { type: string }
                parts:
                  type: array
                  items:
                    type: object
                    properties:
                      partNumber: { type: integer }
                      etag: { type: string }
      responses:
        "201":
          description: Completed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadResult" }

  /v1/buckets/{bucket}/uploads/{uploadId}:
    parameters:
      - $ref: "#/components/parameters/Bucket"
      - { name: uploadId, in: path, required: true, schema: { type: string } }
      - { name: key, in: query, required: true, schema: { type: string } }
    delete:
      tags: [Multipart]
      operationId: abortMultipartUpload
      summary: Abort a multipart upload
      responses:
        "204": { description: Aborted }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key, e.g. Authorization: Bearer sk_live_…"
    adminAuth:
      type: apiKey
      in: header
      name: X-Admin-Secret
      description: Shared admin secret for the provisioning plane.

  parameters:
    Bucket:
      name: bucket
      in: path
      required: true
      schema: { type: string }
    Key:
      name: key
      in: path
      required: true
      description: Object key. May contain '/' path separators.
      schema: { type: string }

  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId, docsUrl]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - forbidden
                - not_found
                - bucket_not_found
                - object_not_found
                - bucket_already_exists
                - invalid_request
                - invalid_key
                - payload_too_large
                - quota_exceeded
                - signature_invalid
                - signature_expired
                - rate_limited
                - internal
            message: { type: string }
            detail: {}
            requestId: { type: string }
            docsUrl: { type: string, format: uri }

    CreateBucket:
      type: object
      required: [name]
      properties:
        name: { type: string }
        visibility: { type: string, enum: [private, public], default: private }
        defaultCacheControl: { type: string }
        cors: { type: array, items: {} }

    UpdateBucket:
      type: object
      properties:
        visibility: { type: string, enum: [private, public] }
        defaultCacheControl: { type: string }
        cors: { type: array, items: {} }

    Bucket:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        visibility: { type: string, enum: [private, public] }
        defaultCacheControl: { type: [string, "null"] }
        cors: { type: array, items: {} }
        createdAt: { type: string, format: date-time }

    ObjectSummary:
      type: object
      properties:
        key: { type: string }
        size: { type: integer }
        etag: { type: string }
        uploadedAt: { type: string, format: date-time }

    UploadResult:
      type: object
      properties:
        bucket: { type: string }
        key: { type: string }
        size: { type: integer }
        etag: { type: string }
        contentType: { type: string }

    SignRequest:
      type: object
      required: [key]
      properties:
        key: { type: string }
        method: { type: string, enum: [GET, PUT], default: GET }
        expiresIn:
          description: Seconds, or a duration string like "15m" / "7d".
          oneOf:
            - { type: string }
            - { type: integer }
        contentType: { type: string }
        maxBytes: { type: integer }

    SignedUrl:
      type: object
      properties:
        url: { type: string, format: uri }
        method: { type: string }
        expiresAt: { type: string, format: date-time }

    CreateWebhook:
      type: object
      required: [url, events]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }

    UpdateWebhook:
      type: object
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
        active: { type: boolean }

    IssuedKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        key: { type: string, description: Plaintext key — shown only once. }
        prefix: { type: string }

    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string }
        lastUsedAt: { type: [string, "null"] }
        revokedAt: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }

    WebhookEvent:
      type: string
      enum: [object.created, object.deleted, upload.completed]

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEvent" }
        active: { type: boolean }
        signingSecret:
          type: string
          description: Full value only in the create response; masked otherwise.
        createdAt: { type: string, format: date-time }
