openapi: 3.0.3
info:
  title: SlopHub API
  version: 1.0.0
  description: |
    Публичное API SlopHub — базы знаний про вайбкодинг, ИИ-агентов и инструменты
    вокруг них.

    Весь контент сайта доступен здесь без регистрации: ленту изменений, новости,
    идеи, ссылки, мемы и статьи можно забрать и показать где угодно, минуя
    фронтенд. Ключ не нужен — он лишь поднимает лимит запросов.

    ## Лимиты

    Анонимно — 30 запросов в минуту с одного IP. С ключом в заголовке
    `X-API-Key` — 600. Столько же у вошедшего: счётчик тогда свой, на человека,
    а не общий на адрес.

    Числа могут поменяться, и запоминать их не надо: точное состояние всегда
    приезжает в заголовках ответа.

    | Заголовок               | Что значит                                  |
    | ----------------------- | ------------------------------------------- |
    | `X-RateLimit-Limit`     | сколько запросов в минуту разрешено         |
    | `X-RateLimit-Remaining` | сколько осталось в текущем окне             |
    | `X-RateLimit-Reset`     | unix-время, когда окно обнулится            |

    Превысили — `429` и заголовок `Retry-After`.

    ## Пагинация

    Курсорная, а не по номеру страницы: контент постоянно добавляется, и
    смещения начинают врать. Берите `next_cursor` из ответа и передавайте его
    в параметре `cursor`. Пустой `next_cursor` — записи кончились.

    ## Формат тела

    У записи есть `body_md` (исходный markdown) и `body_html` (готовая
    разметка). В списках их нет — они приезжают в ответе на запрос одной
    записи или если передать `with_body=true`.
  contact:
    name: SlopHub
    url: https://slophub.ru
  license:
    name: MIT
servers:
  - url: https://api.slophub.ru/api/v1
    description: Боевой сервер
  - url: http://localhost:8080/api/v1
    description: Локальная разработка
tags:
  - name: entries
    description: Записи всех типов — лента, новости, идеи, ссылки, мемы, статьи
  - name: taxonomy
    description: Теги и сводная статистика
  - name: media
    description: Картинки и файлы
  - name: wiki
    description: |
      Поиск по вики — она живёт отдельным сервисом со своей базой, и мы лишь
      передаём ей запрос. Её содержимое в остальном приезжает в ленту
      записями с тегом «вики».
  - name: auth
    description: |
      Вход через телеграм. Нужен только тем, кто пишет в SlopHub, — читать
      можно и без всего этого.
  - name: slops
    description: |
      Демки на поддоменах `slop-<имя>.slophub.ru`. Лежат выключенными,
      поднимаются по первому запросу и засыпают, когда к ним перестают
      ходить.

      Здесь только витрина. Заливка и управление — в админке.
paths:
  /entries:
    get:
      operationId: listEntries
      tags: [entries]
      summary: Список записей
      description: |
        Опубликованные записи, свежие сверху. Фильтры комбинируются по «и»:
        `kind=news&tag=agents` вернёт новости с тегом agents.

        Неопубликованное наружу не отдаётся никогда — черновики и то, что ждёт
        модерации, видно только в админке.
      parameters:
        - $ref: '#/components/parameters/Kind'
        - $ref: '#/components/parameters/TagFilter'
        - $ref: '#/components/parameters/Query'
        - $ref: '#/components/parameters/Since'
        - $ref: '#/components/parameters/Until'
        - $ref: '#/components/parameters/Order'
        - $ref: '#/components/parameters/WithBody'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Страница списка
          headers:
            X-RateLimit-Limit: {$ref: '#/components/headers/RateLimitLimit'}
            X-RateLimit-Remaining: {$ref: '#/components/headers/RateLimitRemaining'}
            X-RateLimit-Reset: {$ref: '#/components/headers/RateLimitReset'}
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EntryPage'
              examples:
                feed:
                  summary: Две записи ленты изменений
                  value:
                    items:
                      - id: 42
                        kind: feed
                        slug: 2026-07-28-changelog
                        title: Лента изменений
                        summary: Появилась страница с историей правок
                        feed_type: added
                        tags: [сайт]
                        published_at: '2026-07-28T12:00:00Z'
                        url_path: /feed/2026-07-28-changelog/
                      - id: 41
                        kind: feed
                        slug: 2026-07-28-theme
                        title: Светлая тема
                        summary: Переключатель темы в шапке
                        feed_type: added
                        tags: [сайт, дизайн]
                        published_at: '2026-07-28T10:30:00Z'
                        url_path: /feed/2026-07-28-theme/
                    next_cursor: eyJwIjoiMjAyNi0wNy0yOFQxMDozMDowMFoiLCJpIjo0MX0
                    total: 17
        '400': {$ref: '#/components/responses/BadRequest'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /entries/{kind}/{slug}:
    get:
      operationId: getEntry
      tags: [entries]
      summary: Одна запись целиком
      description: |
        Возвращает запись вместе с телом в markdown и в HTML. Пара «тип + slug»
        уникальна, поэтому ссылка стабильна и совпадает с адресом на сайте.
      parameters:
        - name: kind
          in: path
          required: true
          description: Тип записи
          schema: {$ref: '#/components/schemas/EntryKind'}
        - name: slug
          in: path
          required: true
          description: Человекочитаемый идентификатор внутри типа
          schema:
            type: string
            pattern: '^[a-z0-9]+(-[a-z0-9]+)*$'
            example: kak-ustroen-slophub
      responses:
        '200':
          description: Запись
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Entry'
        '404': {$ref: '#/components/responses/NotFound'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /tags:
    get:
      operationId: listTags
      tags: [taxonomy]
      summary: Все теги с числом записей
      description: Только теги, у которых есть хотя бы одна опубликованная запись.
      parameters:
        - name: kind
          in: query
          description: Считать записи только этого типа
          schema: {$ref: '#/components/schemas/EntryKind'}
      responses:
        '200':
          description: Список тегов, по убыванию количества
          content:
            application/json:
              schema:
                type: object
                required: [items]
                properties:
                  items:
                    type: array
                    items: {$ref: '#/components/schemas/Tag'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /stats:
    get:
      operationId: getStats
      tags: [taxonomy]
      summary: Сводка по контенту
      description: |
        Сколько чего опубликовано и когда последнее обновление. Удобно, чтобы
        решить, надо ли перечитывать ленту, не выкачивая её целиком.
      responses:
        '200':
          description: Сводка
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Stats'}
        '500': {$ref: '#/components/responses/InternalError'}
  /ideas:
    post:
      operationId: suggestIdea
      tags: [entries]
      summary: Предложить идею
      description: |
        Идея с сайта, без регистрации и без входа. Публикуется не сразу:
        запись уходит в очередь модерации, и её увидят, только когда одобрят.
        Автора у такой записи нет — прислать её может кто угодно.

        Это единственная ручка публичного API, которая пишет. Отсюда и лимит
        строже общего: несколько идей в час с одного адреса. Превысили —
        `429` с `Retry-After`.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/IdeaSuggestion'}
      responses:
        '202':
          description: |
            Принято в очередь. Тела записи в ответе нет намеренно: пока
            модератор не решил, показывать нечего.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message:
                    type: string
                    example: Идея отправлена на модерацию
        '400': {$ref: '#/components/responses/BadRequest'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /slops:
    get:
      operationId: listSlops
      tags: [slops]
      summary: Витрина демок
      description: |
        Опубликованные демки, свежие сверху. Каждая живёт на своём поддомене
        и открывается по ссылке из `url`.

        Демка лежит выключенной и поднимается по первому запросу, поэтому
        первое открытие занимает несколько секунд — гостю в это время
        показывается заставка. Состояния контейнера здесь нет намеренно:
        витрине оно не нужно, а спрашивать его на каждый список означало бы
        ходить в докер за каждым читателем.

        Неопубликованное наружу не отдаётся: то, что ждёт модерации, видно
        только в админке.
      responses:
        '200':
          description: Демки, свежие сверху
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items: {$ref: '#/components/schemas/Slop'}
                  total: {type: integer}
        '500': {$ref: '#/components/responses/InternalError'}
  /wiki/search:
    get:
      operationId: searchWiki
      tags: [wiki]
      summary: Поиск по вики
      description: |
        Спрашивает вики её же поиском и отдаёт найденные страницы. Записи
        сайта здесь не участвуют вовсе — за ними в `/entries` с параметром
        `q`.

        Ищем от лица гостя, поэтому в выдаче нет ни черновиков, ни закрытых
        страниц: ровно то, что и так открыто любому читателю.

        Вики не настроена или не отвечает — приходит пустой список, а не
        ошибка: поиск по сайту не должен падать из-за соседнего сервиса.
      parameters:
        - name: q
          in: query
          required: true
          description: Что искать
          schema: {type: string, minLength: 2, maxLength: 200}
          example: агенты
        - name: limit
          in: query
          description: Сколько страниц вернуть
          schema: {type: integer, minimum: 1, maximum: 25, default: 10}
      responses:
        '200':
          description: Найденные страницы, в порядке, который выдала вики
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items: {$ref: '#/components/schemas/WikiPage'}
                  total:
                    type: integer
                    description: Сколько всего нашлось, до обрезки по `limit`
                    example: 3
        '400': {$ref: '#/components/responses/BadRequest'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /media/{id}:
    get:
      operationId: getMedia
      tags: [media]
      summary: Файл
      description: |
        Отдаёт сам файл, а не метаданные: картинку или видео. Содержимое
        неизменяемо: ответ приходит с `ETag` и
        `Cache-Control: public, max-age=31536000, immutable`.
      parameters:
        - name: id
          in: path
          required: true
          description: Идентификатор файла
          schema:
            type: integer
            format: int64
            example: 12
      responses:
        '200':
          description: Содержимое файла
          headers:
            ETag:
              description: Хеш содержимого
              schema: {type: string}
            Cache-Control:
              description: Файл неизменяем, можно кэшировать навсегда
              schema: {type: string}
          content:
            image/*:
              schema:
                type: string
                format: binary
            video/*:
              schema:
                type: string
                format: binary
        '304':
          description: Не изменилось с прошлого раза
        '404': {$ref: '#/components/responses/NotFound'}
        '500': {$ref: '#/components/responses/InternalError'}
  /auth/telegram/start:
    post:
      operationId: startTelegramLogin
      tags: [auth]
      summary: Начать вход
      description: |
        Заводит одноразовый код и отдаёт ссылку на бота. Страница входа
        показывает её кнопкой и QR-кодом, а сама начинает опрашивать
        `/auth/telegram/poll`.

        Код живёт пять минут и срабатывает один раз.
      responses:
        '200':
          description: Код заведён
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LoginStart'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /auth/telegram/poll:
    get:
      operationId: pollTelegramLogin
      tags: [auth]
      summary: Проверить, вошёл ли человек
      description: |
        `202` — код ещё не подтверждён, спросите снова через пару секунд.
        `200` — подтверждён: в теле токен доступа, а refresh уезжает
        в httpOnly-куке.

        Опрашивайте раз в две-три секунды, не чаще: лимит запросов здесь общий
        с остальным API, и слишком частый поллинг сам себя и заблокирует.
      parameters:
        - name: nonce
          in: query
          required: true
          description: Код из `/auth/telegram/start`
          schema:
            type: string
            minLength: 16
            maxLength: 128
      responses:
        '200':
          description: Вход состоялся
          headers:
            Set-Cookie:
              description: refresh-токен, httpOnly
              schema: {type: string}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Session'}
        '202':
          description: Ещё ждём подтверждения в боте
          content:
            application/json:
              schema: {$ref: '#/components/schemas/LoginPending'}
        '404':
          description: Кода нет, он истёк или уже использован
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Error'}
        '429': {$ref: '#/components/responses/TooManyRequests'}
        '500': {$ref: '#/components/responses/InternalError'}
  /auth/refresh:
    post:
      operationId: refreshSession
      tags: [auth]
      summary: Обновить токен доступа
      description: |
        Берёт refresh-токен из куки и выдаёт новый токен доступа. Сам refresh
        при этом меняется: старый перестаёт работать сразу.

        Если пришёл уже погашенный refresh — значит его копия у кого-то ещё.
        Все сессии этого человека в таком случае рубятся, и войти надо заново.
      responses:
        '200':
          description: Новая пара токенов
          headers:
            Set-Cookie:
              description: новый refresh-токен
              schema: {type: string}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Session'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '500': {$ref: '#/components/responses/InternalError'}
  /auth/logout:
    post:
      operationId: logout
      tags: [auth]
      summary: Выйти
      description: Гасит сессию и стирает куку. Повторный вызов безвреден.
      responses:
        '204':
          description: Вышли
          headers:
            Set-Cookie:
              description: пустая кука с истёкшим сроком
              schema: {type: string}
        '500': {$ref: '#/components/responses/InternalError'}
  /me:
    get:
      operationId: getMe
      tags: [auth]
      summary: Кто я
      security:
        - Bearer: []
      responses:
        '200':
          description: Профиль
          content:
            application/json:
              schema: {$ref: '#/components/schemas/Me'}
        '401': {$ref: '#/components/responses/Unauthorized'}
        '500': {$ref: '#/components/responses/InternalError'}
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Необязателен. Без ключа читать можно точно так же, просто с меньшим
        лимитом. Ключи выдаются в админке.
    Bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Токен доступа из `/auth/telegram/poll` или `/auth/refresh`. Живёт
        пятнадцать минут и нужен только для записи и админских ручек.
  parameters:
    Kind:
      name: kind
      in: query
      description: |
        Типы записей через запятую. Не указан — вернутся все.
      style: form
      explode: false
      schema:
        type: array
        items: {$ref: '#/components/schemas/EntryKind'}
    TagFilter:
      name: tag
      in: query
      description: |
        Теги через запятую, например `агенты,сайт`. Запись должна иметь все
        перечисленные. Подходит и слаг, и само название тега.
      style: form
      explode: false
      schema:
        type: array
        items: {type: string}
    Query:
      name: q
      in: query
      description: |
        Полнотекстовый поиск по заголовку, анонсу и телу. Морфология русская:
        «агентов» найдёт «агенты».
      schema:
        type: string
        minLength: 2
        maxLength: 200
    Since:
      name: since
      in: query
      description: Опубликованные не раньше этого момента, например `2026-07-01T00:00:00Z`
      schema:
        type: string
        format: date-time
    Until:
      name: until
      in: query
      description: Опубликованные не позже этого момента
      schema:
        type: string
        format: date-time
    Order:
      name: order
      in: query
      description: Порядок по дате публикации
      schema:
        type: string
        enum: [new, old]
        default: new
    WithBody:
      name: with_body
      in: query
      description: |
        Добавить в каждый элемент списка `body_md` и `body_html`. Ответ
        становится заметно тяжелее — включайте, когда действительно рисуете
        тела записей, например ленту изменений.
      schema:
        type: boolean
        default: false
    Limit:
      name: limit
      in: query
      description: Сколько записей вернуть
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      description: |
        Курсор из поля `next_cursor` предыдущего ответа. Непрозрачная строка,
        разбирать её не надо — формат может измениться.
      schema:
        type: string
  headers:
    RateLimitLimit:
      description: Разрешено запросов в минуту
      schema: {type: integer, example: 60}
    RateLimitRemaining:
      description: Осталось в текущем окне
      schema: {type: integer, example: 58}
    RateLimitReset:
      description: Unix-время обнуления окна
      schema: {type: integer, format: int64, example: 1785312000}
  schemas:
    EntryKind:
      type: string
      description: |
        Тип записи:

        | Значение  | Что это                                        |
        | --------- | ---------------------------------------------- |
        | `feed`    | запись ленты изменений                         |
        | `news`    | короткая новость                               |
        | `link`    | ссылка на чужой материал с комментарием        |
        | `meme`    | картинка с подписью                            |
        | `idea`    | идея со статусом проработки                    |
        | `article` | длинный материал                               |
      enum: [feed, news, link, meme, idea, article]
      example: news
    FeedType:
      type: string
      description: Характер изменения — только у записей ленты
      enum: [added, changed, fixed, removed, wiki]
      example: added
    IdeaStatus:
      type: string
      description: Стадия проработки идеи
      enum: [raw, in-progress, done, dropped]
      example: raw
    Author:
      type: object
      description: Кто добавил запись. Может отсутствовать у импортированного.
      required: [name]
      properties:
        name:
          type: string
          example: Акрилл
        username:
          type: string
          nullable: true
          description: Логин в телеграме, без собаки
          example: akryll
    Media:
      type: object
      description: |
        Картинка или видео. Что именно — видно по `mime`; у видео заполнены
        `duration` и `poster_url`.
      required: [id, url, mime, size]
      properties:
        id: {type: integer, format: int64, example: 12}
        url:
          type: string
          format: uri
          description: Прямая ссылка на файл
          example: https://api.slophub.ru/api/v1/media/12
        mime: {type: string, example: image/jpeg}
        size:
          type: integer
          format: int64
          description: Размер в байтах
          example: 184320
        width: {type: integer, nullable: true, example: 1280}
        height: {type: integer, nullable: true, example: 960}
        duration:
          type: integer
          nullable: true
          description: Длительность видео в секундах; у картинок пусто
          example: 42
        poster_url:
          type: string
          format: uri
          nullable: true
          description: |
            Кадр-заставка видео. Показывайте его до нажатия «играть»: иначе
            браузер потянет само видео ради первого кадра.
          example: https://api.slophub.ru/api/v1/media/13
    Tag:
      type: object
      required: [slug, name, count]
      properties:
        slug: {type: string, example: agenty}
        name: {type: string, example: агенты}
        count:
          type: integer
          description: Сколько опубликованных записей с этим тегом
          example: 7
    IdeaSuggestion:
      type: object
      required: [title, body]
      properties:
        title:
          type: string
          minLength: 5
          maxLength: 120
          description: Суть одной строкой — она станет заголовком записи
          example: Показывать время чтения у статей
        body:
          type: string
          minLength: 20
          maxLength: 5000
          description: |
            Развёрнутое описание. Понимает markdown: заголовки, списки,
            ссылки, код. Разметка разбирается на сервере, теги вроде
            `<script>` до страницы не доходят.
          example: |
            Сейчас непонятно, на сколько минут статья. Считать по числу слов.
        contact:
          type: string
          maxLength: 120
          description: |
            Как с вами связаться, если идея понравится: логин в телеграме,
            почта — что угодно. В саму запись не попадает и на сайте не
            появится: уходит модератору в уведомление и в журнал действий.
          example: '@akryll'
        website:
          type: string
          description: |
            Ловушка для роботов. Поле должно остаться пустым: живой человек
            его не видит, автозаполнялка заполняет. Непустое — идею молча
            выбрасываем, отвечая как обычно.
    WikiPage:
      type: object
      required: [title, path, url]
      properties:
        title: {type: string, example: 'Агенты: как они устроены'}
        description:
          type: string
          description: Краткое описание страницы, если автор его заполнил
          example: Цикл «модель → инструмент → результат»
        path:
          type: string
          description: Путь внутри вики, без домена и локали
          example: agents/intro
        url:
          type: string
          format: uri
          description: Готовая ссылка на страницу
          example: https://wiki.slophub.ru/agents/intro
        locale: {type: string, example: ru}
    Entry:
      type: object
      description: |
        Запись любого типа. Поля, специфичные для типа, приходят только у
        своего типа: `feed_type` — у ленты, `idea_status` — у идей, `url` и
        `og_*` — у ссылок, `media` — у мемов.
      required: [id, kind, slug, title, summary, tags, published_at, url_path]
      properties:
        id: {type: integer, format: int64, example: 42}
        kind: {$ref: '#/components/schemas/EntryKind'}
        slug: {type: string, example: 2026-07-28-changelog}
        title: {type: string, example: Появилась лента изменений}
        summary:
          type: string
          description: Короткий анонс для карточек и OG-разметки
          example: Теперь видно, что и когда менялось на сайте
        body_md:
          type: string
          nullable: true
          description: Исходный markdown. В списках только при `with_body=true`.
        body_html:
          type: string
          nullable: true
          description: Готовая разметка. В списках только при `with_body=true`.
        author:
          allOf:
            - $ref: '#/components/schemas/Author'
          nullable: true
        tags:
          type: array
          items: {type: string}
          example: [сайт, дизайн]
        url_path:
          type: string
          description: Путь к собственной странице записи на сайте slophub.ru
          example: /feed/2026-07-28-changelog/
        reading_minutes:
          type: integer
          nullable: true
          description: Оценка времени чтения, только у статей
          example: 6
        feed_type:
          allOf: [{$ref: '#/components/schemas/FeedType'}]
          nullable: true
        idea_status:
          allOf: [{$ref: '#/components/schemas/IdeaStatus'}]
          nullable: true
        url:
          type: string
          format: uri
          nullable: true
          description: Адрес материала, на который ссылаемся — только у `link`
        og_title:
          type: string
          nullable: true
          description: Заголовок, вытянутый со страницы по ссылке
        og_description:
          type: string
          nullable: true
        og_image:
          allOf: [{$ref: '#/components/schemas/Media'}]
          nullable: true
        media:
          allOf: [{$ref: '#/components/schemas/Media'}]
          nullable: true
          description: Картинка — обязательна у `meme`
        source_name:
          type: string
          nullable: true
          description: |
            Откуда взято: название издания у новости со ссылкой на
            первоисточник или канал, из которого переслали в бота
          example: Пресс-релиз Anthropic
        source_url:
          type: string
          nullable: true
          description: Ссылка на первоисточник или на исходное сообщение
        published_at:
          type: string
          format: date-time
          example: '2026-07-28T12:00:00Z'
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: Приходит, только если запись правили после публикации
    EntryPage:
      type: object
      required: [items, total]
      properties:
        items:
          type: array
          items: {$ref: '#/components/schemas/Entry'}
        next_cursor:
          type: string
          nullable: true
          description: Курсор следующей страницы. `null` — записи кончились.
        total:
          type: integer
          description: Сколько всего записей подходит под фильтр
          example: 17
    Stats:
      type: object
      required: [total, by_kind]
      properties:
        total: {type: integer, example: 17}
        by_kind:
          type: object
          description: Число опубликованных записей по типам
          additionalProperties: {type: integer}
          example: {feed: 3, news: 1, idea: 3, article: 1}
        last_published_at:
          type: string
          format: date-time
          nullable: true
          description: Когда опубликовали последнее
    LoginStart:
      type: object
      required: [nonce, deeplink, expires_in]
      properties:
        nonce:
          type: string
          description: Код, с которым потом опрашивать `/auth/telegram/poll`
          example: 8f14e45fceea167a5a36dedd4bea2543
        deeplink:
          type: string
          format: uri
          description: Ссылка на бота — её и показывать кнопкой и QR-кодом
          example: https://t.me/apchoo_bot?start=login_8f14e45fceea167a5a36dedd4bea2543
        expires_in:
          type: integer
          description: Сколько секунд код действителен
          example: 300
    LoginPending:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [pending]
          description: Подтверждения в боте ещё не было
    Session:
      type: object
      required: [access_token, token_type, expires_in, user]
      properties:
        access_token:
          type: string
          description: 'JWT для заголовка `Authorization: Bearer`'
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Сколько секунд живёт токен доступа
          example: 900
        user: {$ref: '#/components/schemas/Me'}
    Me:
      type: object
      required: [id, name, role, can_publish, can_moderate]
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          example: Аня
        username:
          type: string
          nullable: true
          description: Логин в телеграме, без собаки
          example: akryll
        role:
          type: string
          enum: [admin, editor, author]
        can_publish:
          type: boolean
          description: Публикуются ли записи сразу или уходят на одобрение
        can_moderate:
          type: boolean
          description: Виден ли этому человеку чужой список на модерацию
    SlopKind:
      type: string
      description: |
        Что запускается внутри. `static` раздаёт залитые файлы нашим
        раздатчиком и никакого кода на сервере не исполняет; остальные — это
        код, который работает в контейнере с соответствующим рантаймом.

        Адрес, засыпание и пробуждение у всех одинаковые: вид решает только,
        какой образ взять и какую команду в нём выполнить.

        В форме заливки поле необязательное: не указан — `static`. Умолчания
        здесь нет намеренно, полем `default` — оно заставляет валидатор
        пересобирать тело запроса, а кодировщика multipart у него нет, и
        заливка отвечала бы четырёхсотой.
      enum: [static, node, python, bun]
    Slop:
      type: object
      description: Демка в витрине.
      required: [name, title, description, kind, url, published_at]
      properties:
        name:
          type: string
          description: Оно же начало адреса и имя контейнера
          example: pixel-sea
        title: {type: string, example: Пиксельное море}
        description: {type: string}
        kind: {$ref: '#/components/schemas/SlopKind'}
        url:
          type: string
          description: Куда вести гостя
          example: https://slop-pixel-sea.slophub.ru
        author: {$ref: '#/components/schemas/Author'}
        published_at: {type: string, format: date-time}
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Машиночитаемый код ошибки
          enum:
            - bad_request
            - unauthorized
            - forbidden
            - not_found
            - rate_limited
            - internal
          example: bad_request
        message:
          type: string
          description: Человекочитаемое описание, на русском
          example: 'параметр limit: ожидалось число от 1 до 100'
        details:
          type: object
          nullable: true
          additionalProperties: true
          description: Подробности, если они есть — например, какое поле не так
  responses:
    BadRequest:
      description: Кривые параметры запроса
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            code: bad_request
            message: 'параметр kind: неизвестный тип записи "post"'
    NotFound:
      description: Ничего не нашлось
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            code: not_found
            message: запись не найдена
    TooManyRequests:
      description: Лимит исчерпан
      headers:
        Retry-After:
          description: Через сколько секунд можно повторить
          schema: {type: integer, example: 23}
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            code: rate_limited
            message: слишком много запросов, попробуйте через 23 секунды
    InternalError:
      description: Сломалось у нас
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            code: internal
            message: внутренняя ошибка
    Unauthorized:
      description: Нужен действующий токен
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
          example:
            code: unauthorized
            message: нужен вход
security:
  - {}
  - ApiKey: []
