openapi: 3.1.0
info:
  title: Конверт (paste-vault) API
  version: 1.0.0
  summary: Хранилище зашифрованных записок. Сервер принимает только готовый шифртекст.
  description: |
    Шифрование выполняется клиентом (WebCrypto). Сервер не получает ни ключ, ни пароль, ни открытый текст.

    **Схема шифртекста.**
    1. `K` = 32 случайных байта (base64url). Он остаётся во фрагменте ссылки `/p/{id}#K`.
    2. Если есть пароль: `P = PBKDF2-SHA256(NFKC(пароль), salt, iterations, 256 бит)`, `ikm = K || P`; без пароля `ikm = K`.
    3. `key = HKDF-SHA256(ikm, salt = пусто, info = "paste-vault/v1/aes-gcm")` → AES-256-GCM.
    4. `ct = base64url(iv(12) || AES-GCM(JSON {"t": текст, "l": язык}))`, additionalData = `pv1`.
    5. `chk = base64url(iv(12) || AES-GCM("pv-ok"))` с тем же ключом: проверка ключа и пароля до чтения записки.

    Все ответы с `Cache-Control: no-store` и `X-Robots-Tag: noindex`. Cookie не используются.
    Запросы с чужим `Origin` отклоняются (403), тело записи только `application/json`.
  license:
    name: Private portfolio project
servers:
  - url: https://ewsirdev-paste.ewsirdev.workers.dev
tags:
  - name: pastes
  - name: service
paths:
  /api/pastes:
    post:
      tags: [pastes]
      operationId: createPaste
      summary: Создать записку
      description: Не более 20 записок за 10 минут с одного IP.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRequest' }
      responses:
        '201':
          description: Создано
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/ForbiddenOrigin' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMedia' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/pastes/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [pastes]
      operationId: readPaste
      summary: Прочитать записку (расходует просмотр)
      description: |
        Для записок с `burn = true` чтение атомарно удаляет строку: при любом числе параллельных запросов успех только у одного.
        Для остальных увеличивает счётчик просмотров. Чтобы узнать параметры без расхода, используйте `/meta`.
      responses:
        '200':
          description: Шифртекст и параметры
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PasteFull' }
        '403': { $ref: '#/components/responses/ForbiddenOrigin' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [pastes]
      operationId: deletePaste
      summary: Удалить записку по токену
      security:
        - deleteToken: []
      responses:
        '204': { description: Удалено }
        '403': { $ref: '#/components/responses/ForbiddenOrigin' }
        '404':
          description: Нет такой записки или токен не подходит (ответы неразличимы намеренно)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/pastes/{id}/meta:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [pastes]
      operationId: pasteMeta
      summary: Параметры записки без шифртекста и без расхода
      responses:
        '200':
          description: Метаданные
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PasteMeta' }
        '403': { $ref: '#/components/responses/ForbiddenOrigin' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/health:
    get:
      tags: [service]
      operationId: health
      summary: Проверка живости (включая доступность базы)
      responses:
        '200':
          description: Работает
          content:
            application/json:
              schema:
                type: object
                required: [ok, time]
                properties:
                  ok: { type: boolean, const: true }
                  time: { type: integer, description: Unix-время сервера, секунды }
        '503':
          description: База недоступна
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: false }
components:
  securitySchemes:
    deleteToken:
      type: http
      scheme: bearer
      description: Токен удаления из ответа `POST /api/pastes` (22 символа base64url).
  parameters:
    Id:
      name: id
      in: path
      required: true
      description: 128-битный случайный идентификатор (22 символа base64url).
      schema: { type: string, pattern: '^[A-Za-z0-9_-]{22}$' }
  schemas:
    Base64Url:
      type: string
      pattern: '^[A-Za-z0-9_-]+$'
    Kdf:
      type: object
      description: Параметры PBKDF2-SHA256 для защиты паролем.
      additionalProperties: false
      required: [s, i]
      properties:
        s: { type: string, description: Соль, 16 байт в base64url, pattern: '^[A-Za-z0-9_-]{22}$' }
        i: { type: integer, minimum: 100000, maximum: 2000000, description: Число кругов (клиент использует 600000) }
    CreateRequest:
      type: object
      additionalProperties: false
      required: [ct, chk, ttl, burn]
      properties:
        ct:
          allOf: [{ $ref: '#/components/schemas/Base64Url' }]
          description: iv || шифртекст AES-GCM. После декодирования от 29 байт до 263 168 байт (256 КБ текста плюс служебное).
        chk:
          allOf: [{ $ref: '#/components/schemas/Base64Url' }]
          description: Проверочная метка, ровно 33 байта после декодирования.
        ttl:
          type: integer
          enum: [600, 3600, 86400, 259200, 604800]
          description: Срок жизни в секундах (10 минут, 1 час, 1 день, 3 дня, 7 дней).
        burn: { type: boolean, description: Удалить при первом чтении }
        kdf: { $ref: '#/components/schemas/Kdf' }
    CreateResponse:
      type: object
      required: [id, deleteToken, expiresAt]
      properties:
        id: { type: string, pattern: '^[A-Za-z0-9_-]{22}$' }
        deleteToken: { type: string, description: Показывается один раз, в базе хранится только SHA-256 }
        expiresAt: { type: integer, description: Unix-время истечения, секунды }
    PasteMeta:
      type: object
      required: [id, burn, pw, chk, createdAt, expiresAt, views, size]
      properties:
        id: { type: string }
        burn: { type: boolean }
        pw: { type: boolean, description: Записка защищена паролем }
        kdf: { $ref: '#/components/schemas/Kdf' }
        chk: { $ref: '#/components/schemas/Base64Url' }
        createdAt: { type: integer }
        expiresAt: { type: integer }
        views: { type: integer }
        size: { type: integer, description: Размер шифртекста в байтах }
    PasteFull:
      allOf:
        - $ref: '#/components/schemas/PasteMeta'
        - type: object
          required: [ct]
          properties:
            ct: { $ref: '#/components/schemas/Base64Url' }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          examples: [bad_json, bad_ttl, too_large, json_only, rate_limited, forbidden_origin, not_found]
  responses:
    BadRequest:
      description: Тело не прошло строгую проверку схемы
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ForbiddenOrigin:
      description: Origin не совпадает с хостом сервиса (защита от CSRF)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Нет такой записки, она истекла или сгорела
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooLarge:
      description: Тело больше 420 000 байт или шифртекст больше лимита
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMedia:
      description: Нужен Content-Type application/json
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Превышен лимит. Промахи по несуществующим id считаются в 5 раз дороже.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Секунд до начала следующего окна
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
