openapi: 3.1.0

info:
  title: CloseFiles API
  version: 1.0.0
  summary: Temporary message and file sharing. Create, get a short code, share, retrieve.
  description: |
    CloseFiles is a temporary locker for text and files. Everything is a **drop**: a piece of
    text, one or more files, or both.

    **Private drops** are addressed by a 4-character retrieval code (`A7K2`).
    **Public drops** are addressed by a short slug in a shareable URL (`/p/abc23456`).

    > **A 4-character code is a retrieval token, not a password or an encryption key.**
    > The code space is 30^4 = 810,000 combinations and content is stored in plaintext.
    > CloseFiles does not claim to be encrypted. Do not use it for secrets you would not want the
    > server operator to be able to read. Rate limiting is the practical limit on guessing.

    ### No authentication
    There are no accounts, tokens or sessions. A drop is reachable by anyone who knows its code
    or slug. That is the entire product.

    ### Content negotiation
    Create endpoints accept either:
    * `application/json` for text-only drops, or
    * `multipart/form-data` to attach files alongside text.

    ### Expiration
    Send `expires_in` (a duration like `10m`, `1h`, `1d`, or bare seconds) **or** `expires_at`
    (ISO-8601). Omit both, or send `null`, for "never expire" — the server default for both types.

    The ceiling is `MAX_EXPIRATION` (**24 hours** by default): custom values past it are rejected
    with a `400 VALIDATION_ERROR`; an explicit `expires_in: null` ("never") is always honored.
    Expired content — rows and stored files — is auto-purged at boot and once per cleanup
    interval afterwards, so nothing lingers beyond its lifetime on disk.
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: http://localhost:3000
    description: Local development
  - url: https://closefiles.example
    description: Example deployment

tags:
  - name: Private drops
    description: Addressed by a 4-character code.
  - name: Public drops
    description: Addressed by a shareable URL, no code required.
  - name: Files
    description: Streaming download and inline preview.
  - name: System
    description: Health and metadata.

paths:
  /api/v1/drops:
    post:
      tags: [Private drops]
      operationId: createPrivateDrop
      summary: Create a private drop
      description: |
        Creates a private drop and returns a unique 4-character retrieval code.

        Accepts `application/json` (text only) or `multipart/form-data` (text and/or files).
        The code uses an alphabet with no `0`/`O`, `1`/`I`/`L`, so it can be dictated or retyped
        without ambiguity. Readable characters are case-insensitive on lookup.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDropRequest'
            examples:
              textOnly:
                summary: Text only, never expires
                value:
                  text: Hello from CloseFiles
              expiring:
                summary: Text with a 1 hour expiration
                value:
                  text: Here is the deploy key fingerprint
                  expires_in: 1h
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CreateDropMultipart'
            examples:
              fileUpload:
                summary: One file plus a note, expiring in 1 day
                value:
                  text: Release notes attached
                  expires_in: 1d
      responses:
        '201':
          description: Drop created.
          headers:
            Location:
              description: Canonical URL of the new drop.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Drop' }
              examples:
                created:
                  value:
                    id: 6f1c9a1e-2a4b-4c7d-8e9f-0a1b2c3d4e5f
                    type: private
                    code: A7K2
                    text: Hello from CloseFiles
                    created_at: '2026-09-28T10:15:00.000Z'
                    expires_at: null
                    expired: false
                    file_count: 0
                    total_size: 0
                    files: []
                    urls:
                      self: /api/v1/drops/A7K2
                      retrieve_page: /view/A7K2
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/drops/{code}:
    get:
      tags: [Private drops]
      operationId: getPrivateDrop
      summary: Retrieve a private drop by code
      description: |
        Returns the text and file metadata for a private drop.

        Distinguishes the two failure modes on purpose:
        * `410 DROP_EXPIRED` — the drop existed but its expiration has passed.
        * `404 DROP_NOT_FOUND` — no such code (or it has already been purged).
      security: []
      parameters:
        - $ref: '#/components/parameters/Code'
      responses:
        '200':
          description: The drop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Drop' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/public:
    post:
      tags: [Public drops]
      operationId: createPublicDrop
      summary: Create a public drop
      description: |
        Creates a publicly accessible drop addressed by a slug, reachable by anyone with the URL.

        Public drops **do not expire by default** and require no code. Point the returned
        `urls.public` at a browser, a webhook, or `curl`. Give them `expires_in` if they should
        self-destruct; the same `MAX_EXPIRATION` ceiling (24h by default) applies.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDropRequest'
            examples:
              publicText:
                value:
                  text: Public status page fragment
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CreateDropMultipart'
            examples:
              publicFile:
                value:
                  text: Anyone with the link can read this
                  files: (binary)
      responses:
        '201':
          description: Drop created.
          headers:
            Location:
              description: Canonical URL of the new drop.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Drop' }
              examples:
                created:
                  value:
                    id: 8c2d0b3f-4e5a-4b6c-9d7e-1f2a3b4c5d6e
                    type: public
                    slug: abc23456
                    text: Public status page fragment
                    created_at: '2026-09-28T10:20:00.000Z'
                    expires_at: null
                    expired: false
                    file_count: 0
                    total_size: 0
                    files: []
                    urls:
                      self: /api/v1/public/abc23456
                      public: /p/abc23456
        '400': { $ref: '#/components/responses/BadRequest' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/public/{id}:
    get:
      tags: [Public drops]
      operationId: getPublicDrop
      summary: Retrieve a public drop by slug
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: The drop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Drop' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/drops/{code}/files/{fileId}:
    get:
      tags: [Files]
      operationId: downloadPrivateDropFile
      summary: Download (or preview) a file from a private drop
      description: |
        Streams the file; never buffers it in memory. Safe content types
        (`image/*` except SVG, `video/*`, `audio/*`, `application/pdf`, `text/*`) are served
        inline with `?disposition=inline`; everything else is forced to
        `application/octet-stream` with `Content-Disposition: attachment`.
        Add `?download=1` to force download.
      security: []
      parameters:
        - $ref: '#/components/parameters/Code'
        - $ref: '#/components/parameters/FileId'
        - name: disposition
          in: query
          schema: { type: string, enum: [inline, attachment] }
          description: Override the disposition. Ignored for unsafe types.
        - name: download
          in: query
          schema: { type: integer, enum: [0, 1] }
          description: Set to `1` to force a download.
      responses:
        '200':
          description: File bytes.
          headers:
            Content-Disposition:
              schema: { type: string }
            Content-Length:
              schema: { type: integer }
            X-CloseFiles-Filename:
              description: Original filename, percent-encoded for safe display.
              schema: { type: string }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/public/{id}/files/{fileId}:
    get:
      tags: [Files]
      operationId: downloadPublicDropFile
      summary: Download (or preview) a file from a public drop
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/FileId'
        - name: disposition
          in: query
          schema: { type: string, enum: [inline, attachment] }
        - name: download
          in: query
          schema: { type: integer, enum: [0, 1] }
      responses:
        '200':
          description: File bytes.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/health:
    get:
      tags: [System]
      operationId: getHealth
      summary: Liveness and dependency health
      description: Reports database reachability and storage health. Use for load-balancer probes and uptime checks.
      security: []
      responses:
        '200':
          description: Everything is reachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
        '503':
          description: A dependency is unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

components:
  parameters:
    Code:
      name: code
      in: path
      required: true
      description: |
        4-character retrieval code. Lookup is case-insensitive and tolerates separators, so
        `a7k2`, `A7K2` and `A-7 K 2` all resolve to the same drop. The pattern below accepts either
        case; codes are always *generated* in uppercase.
        I, L, O, U, 0 and 1 are not in the alphabet, so a code can be read aloud or retyped without
        ambiguity.
      schema: { type: string, pattern: '^[A-HJKMNP-TVWXYZ2-9a-hjkmnp-tvwxyz]{4}$', minLength: 4, maxLength: 4 }
      example: A7K2
    Slug:
      name: id
      in: path
      required: true
      description: Public drop slug, as returned in `slug` and in `urls.public`. Same alphabet as a code.
      schema: { type: string, pattern: '^[A-HJKMNP-TVWXYZ2-9a-hjkmnp-tvwxyz]{8}$', minLength: 8, maxLength: 8 }
      example: abc23456
    FileId:
      name: fileId
      in: path
      required: true
      schema: { type: string, format: uuid }
      example: 4a1f0b52-6c77-4a19-9d3e-2f0b8c5d1e44

  schemas:
    CreateDropRequest:
      type: object
      description: JSON body for a text-only drop. Use multipart to attach files.
      properties:
        text:
          type: string
          maxLength: 1048576
          description: Message body. A drop must have text, at least one file, or both.
        expires_in:
          type: [string, 'null']
          description: >
            Duration from now until expiry: `30s`, `10m`, `1h`, `1d`, or bare seconds.
            `null`, `never` or `0` means no expiration. Capped by the server's `MAX_EXPIRATION`
            (24h by default) — anything further out is a `VALIDATION_ERROR`.
          examples: ['10m', '1h', '1d', null]
        expires_at:
          type: [string, 'null']
          format: date-time
          description: Absolute expiry. Must be in the future. Mutually exclusive with `expires_in`.
          examples: ['2026-09-28T12:00:00.000Z', null]
      additionalProperties: false

    CreateDropMultipart:
      type: object
      description: Multipart body. Any part with a filename is treated as a file; the rest as fields.
      properties:
        text: { type: string, description: Message body. }
        expires_in: { type: string, description: 'Duration, e.g. `1h`. `never` for no expiration.' }
        expires_at: { type: string, format: date-time }
        files:
          type: array
          description: Repeat this part once per file. Limits are `MAX_FILES_PER_DROP` (10) and `MAX_TOTAL_SIZE` (all files combined, 500 MB by default) — there is no per-file cap.
          items: { type: string, format: binary }
      required: []

    File:
      type: object
      properties:
        id: { type: string, format: uuid }
        filename:
          type: string
          description: Original filename as supplied by the uploader. Display only — never used as a path.
          example: release-notes.md
        mime_type: { type: string, example: text/markdown }
        size: { type: integer, minimum: 0, example: 20481 }
        created_at: { type: string, format: date-time }
        previewable:
          type: boolean
          description: True when the type is safe to render inline in a browser.
        download_url: { type: string, example: /api/v1/drops/A7K2/files/4a1f0b52-6c77-4a19-9d3e-2f0b8c5d1e44 }

    Drop:
      type: object
      properties:
        id: { type: string, format: uuid, description: Internal identity, stable for the life of the drop. }
        type: { type: string, enum: [private, public] }
        code:
          type: [string, 'null']
          description: 4-character retrieval code. Non-null only for `private` drops.
          examples: [A7K2, null]
        slug:
          type: [string, 'null']
          description: Public slug. Non-null only for `public` drops.
          examples: [abc23456, null]
        text: { type: [string, 'null'], description: Message body, or null when the drop is files only. }
        created_at: { type: string, format: date-time }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: Null means it never expires.
        expired: { type: boolean }
        file_count: { type: integer, minimum: 0 }
        total_size: { type: integer, minimum: 0, description: Sum of all file sizes in bytes. }
        files: { type: array, items: { $ref: '#/components/schemas/File' } }
        urls:
          type: object
          description: Ready-made relative links. `public` is present only for public drops.
          properties:
            self: { type: string, description: API endpoint for this drop. }
            public: { type: string, description: Shareable page, e.g. /p/abc23456. }
            retrieve_page: { type: string, description: Human-facing page, e.g. /view/A7K2. }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status]
          properties:
            code:
              type: string
              description: Stable machine-readable identifier. Branch on this, not on `message`.
              enum:
                - VALIDATION_ERROR
                - INVALID_CODE
                - INVALID_SLUG
                - EMPTY_DROP
                - TEXT_TOO_LONG
                - TOO_MANY_FILES
                - DROP_NOT_FOUND
                - FILE_NOT_FOUND
                - DROP_EXPIRED
                - UPLOAD_TOO_LARGE
                - UNSUPPORTED_MEDIA_TYPE
                - MALFORMED_UPLOAD
                - RATE_LIMITED
                - INTERNAL_ERROR
                - STORAGE_UNAVAILABLE
                - CODE_EXHAUSTED
            message: { type: string, description: Human-readable explanation. }
            status: { type: integer }
            request_id: { type: string, description: Correlates with the server log line for this request. }
            details:
              type: object
              description: Optional field-level detail, e.g. which field failed and why.
              additionalProperties: true

    Health:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded] }
        version: { type: string }
        uptime_s: { type: number }
        checks:
          type: object
          properties:
            database: { type: string, enum: [ok, error] }
            storage:
              type: object
              properties:
                driver: { type: string, enum: [local, s3, memory] }
                status: { type: string, enum: [ok, error] }

  responses:
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidCode:
              value:
                error:
                  code: INVALID_CODE
                  message: A CloseFiles code is 4 characters using letters and digits 2-9.
                  status: 400
                  request_id: req_01H9Z...
            expirationBeyondCeiling:
              summary: Custom expiration past MAX_EXPIRATION (24h by default)
              value:
                error:
                  code: VALIDATION_ERROR
                  message: Expiration is further out than the server allows (7d).
                  status: 400
                  request_id: req_01J2K...
    NotFound:
      description: No such drop or file.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              value:
                error:
                  code: DROP_NOT_FOUND
                  message: This CloseFiles does not exist.
                  status: 404
                  request_id: req_01H9Z...
    Gone:
      description: The drop existed but has expired.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            expired:
              value:
                error:
                  code: DROP_EXPIRED
                  message: This CloseFiles has expired.
                  status: 410
                  request_id: req_01H9Z...
    PayloadTooLarge:
      description: Upload exceeds the configured limits — `MAX_TOTAL_SIZE` (all files combined, 500 MB by default), `MAX_FILES_PER_DROP` (10), or the body limit. There is no per-file cap; a single file may use the entire total.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: Content-Type is not supported on this endpoint.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServiceUnavailable:
      description: A dependency is unavailable, or no unused code could be allocated.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
