# PageMark API - the single source of truth for the REST contract.
#
# Contract-first: this file is written by hand and BOTH sides are generated from it.
#   Backend  -> Kotlin/Spring interfaces + DTOs (./gradlew openApiGenerate), which the
#               controllers implement. A drift test fails the build if the running
#               application serves a different set of operations than declared here.
#   Frontend -> Ktor/kotlinx.serialization client for the Kotlin Multiplatform app.
#
# Conventions:
#   - Paths are written WITHOUT the /api/v1 prefix; the prefix is part of the server URL
#     and is applied centrally in the backend (ApiConfig).
#   - Changes within v1 must stay backwards compatible: add fields, never rename or
#     remove them. A breaking change opens /api/v2.
#   - List shape: every list that can grow is a paged envelope `{metadata, content}` with
#     `page`/`size`. A bare array is only used for small server-defined sets (tags, the
#     top-N rankings with `limit`, role requests).
#   - OpenAPI 3.0.3 rather than 3.1 is used deliberately: it is the dialect with the
#     most reliable support across the code generators on both sides.
openapi: 3.0.3

info:
  title: PageMark API
  version: "1.0.0"
  description: >-
    REST API of the Online Novel Library: accounts, novels, chapters, personal library
    and reading progress.

servers:
  - url: http://localhost:8080/api/v1
    description: Local development backend

tags:
  - name: Auth
    description: Registration, login and token lifecycle.
  - name: Users
    description: The account of the signed-in user.
  - name: Novels
    description: Novels and their co-authors.
  - name: Chapters
    description: Chapters of a novel, including their reading-order position.
  - name: Library
    description: The signed-in user's personal library and cross-device reading progress.
  - name: Reports
    description: User reports and moderation.
  - name: Comments
    description: Comments on novels and chapters.
  - name: Ratings
    description: 1-5 star ratings on novels, with an aggregated summary.
  - name: Rankings
    description: Daily per-novel read counts and most-read rankings.
  - name: System
    description: Technical endpoints of the API skeleton.

# Every operation requires a bearer access token unless it opts out with `security: []`.
security:
  - bearerAuth: [ ]

paths:

  /auth/register:
    post:
      tags: [ Auth ]
      operationId: register
      summary: Create an account and sign in
      description: >-
        Creates the account with the READER role and returns a token pair immediately,
        so a client reaches the signed-in state in one round trip. No e-mail
        verification step in this version.
      security: [ ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RegisterRequest"
      responses:
        "201":
          description: The account was created and is signed in.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthenticationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /auth/login:
    post:
      tags: [ Auth ]
      operationId: login
      summary: Sign in with e-mail and password
      security: [ ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequest"
      responses:
        "200":
          description: The credentials were accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthenticationResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /auth/refresh:
    post:
      tags: [ Auth ]
      operationId: refresh
      summary: Exchange a refresh token for a new token pair
      description: >-
        Rotates the refresh token: the presented one is revoked and a new pair issued.
        Presenting an already-revoked token is treated as a leak and revokes every
        token of that sign-in, forcing a fresh login.
      security: [ ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshRequest"
      responses:
        "200":
          description: A new token pair was issued and the presented token revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /auth/logout:
    post:
      tags: [ Auth ]
      operationId: logout
      summary: Revoke a refresh token
      description: >-
        Revokes the presented refresh token and everything else issued from the same
        sign-in.
      security: [ ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LogoutRequest"
      responses:
        "204":
          description: The token is no longer usable.
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users:
    get:
      tags: [ Users ]
      operationId: queryUsers
      summary: Find users by username
      description: >-
        Returns users whose username contains the (mandatory) filter, at least 2
        characters. This endpoint is restricted to callers with the `AUTHOR` role or a
        higher privilege level, since it is used to resolve a co-author UUID before
        assigning authorship to a novel; it is deliberately not a way to list every
        account - administrators browse with `GET /admin/users`.
      parameters:
        - $ref: "#/components/parameters/UsernameFilterParam"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: Matching users for the supplied search.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserQueryPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/{userId}:
    parameters:
      - name: userId
        in: path
        required: true
        description: The user's `userId`.
        schema:
          $ref: "#/components/schemas/Uuid"
    get:
      tags: [ Users ]
      operationId: getPublicUserProfile
      summary: Read a user's public profile
      description: >-
        Open to anyone, signed in or not. Returns only public identity data - never the
        email address, roles or permissions. A disabled or unknown account is a `404`.
        The user's published novels come from `GET /novels?authorId=`.
      security: [ ]
      responses:
        "200":
          description: The user's public profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicUserProfile"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /admin/users:
    get:
      tags: [ Users ]
      operationId: listAdminUsers
      summary: List and filter user accounts for administration
      description: >-
        Requires `USER_MANAGE` or `ROLE_MANAGE` - either is enough to browse the list,
        since finding a user is a prerequisite for both the role- and status-management
        actions below, which each still check their own specific permission
        independently.
      parameters:
        - $ref: "#/components/parameters/UserSearchParam"
        - $ref: "#/components/parameters/RoleFilterParam"
        - $ref: "#/components/parameters/EnabledFilterParam"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: A page of users matching the given filters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUserPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /admin/users/{userId}/roles:
    parameters:
      - name: userId
        in: path
        required: true
        description: Identifier of the user.
        schema:
          $ref: "#/components/schemas/Uuid"
    patch:
      tags: [ Users ]
      operationId: updateUserRoles
      summary: Assign or revoke a user's roles
      description: >-
        Requires `ROLE_MANAGE`. Replaces the user's complete role set with the given
        names; every name must already exist as a role (no admin UI to define new
        roles/permission bundles exists - that remains a Flyway-seed task). An admin
        cannot change their own role set with this endpoint. A changed assignment is
        reflected in a new access token only after the user's next login or refresh.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserRolesRequest"
      responses:
        "200":
          description: The user's roles were replaced.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /admin/users/{userId}/status:
    parameters:
      - name: userId
        in: path
        required: true
        description: Identifier of the user.
        schema:
          $ref: "#/components/schemas/Uuid"
    patch:
      tags: [ Users ]
      operationId: updateUserStatus
      summary: Suspend or reactivate a user account
      description: >-
        Requires `USER_MANAGE`. Clears or restores the `enabled` flag. An admin cannot
        change their own status with this endpoint. A disabled account is rejected
        immediately at login; an already-signed-in session is rejected the next time it
        tries to refresh its access token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserStatusRequest"
      responses:
        "200":
          description: The user's status was updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels:
    get:
      tags: [ Novels ]
      operationId: listNovels
      summary: Browse the public novel catalog
      description: >-
        Open to anyone, signed in or not. Returns novels regardless of author,
        excluding `DRAFT`. For an author's own novels including drafts, use `GET /users/me/novels` instead.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
        - $ref: "#/components/parameters/GenreParam"
        - $ref: "#/components/parameters/NovelStatusParam"
        - $ref: "#/components/parameters/AgeRatingParam"
        - $ref: "#/components/parameters/AuthorIdParam"
        - $ref: "#/components/parameters/SearchParam"
        - $ref: "#/components/parameters/TagsParam"
      responses:
        "200":
          description: A page of the public catalog matching the given filters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelQueryPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    post:
      tags: [ Novels ]
      operationId: createNovel
      summary: Create a novel
      description: >-
        Requires `NOVEL_CREATE`. The caller is recorded as the novel's first author via
        `NovelAuthor` in the same operation - no separate step to claim authorship, and
        no novel is ever created without at least one author.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateNovelRequest"
      responses:
        "201":
          description: The novel was created with the caller as its sole author.
          headers:
            Location:
              $ref: "#/components/headers/Location"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /tags:
    get:
      tags: [ Novels ]
      operationId: listTags
      summary: List the tags used in the public catalog
      description: >-
        Open to anyone. Every tag assigned to at least one public (non-`DRAFT`, not
        deleted) novel, most-used first, tag ascending on a tie. Feeds the catalog's tag
        filter (`GET /novels?tags=`).
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/TagLimitParam"
      responses:
        "200":
          description: The catalog's tags with their usage counts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TagSummaryList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /rankings/novels/most-read:
    get:
      tags: [ Rankings ]
      operationId: getMostReadNovels
      summary: Most-read novels over a trailing window
      description: >-
        Ranks public novels by aggregated read count over `period`, novel id ascending
        on a tie. Novels with zero reads in the period are excluded rather than listed
        with a fabricated zero. `genre`/`ageRating`/`authorId`/`publishedWithin` filter
        the candidate novels before ranking, independent of `period`. A top-N list, so it
        takes `limit` rather than `page`/`size`: it is never paged through.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/PeriodParam"
        - $ref: "#/components/parameters/LimitParam"
        - $ref: "#/components/parameters/PublishedWithinParam"
        - $ref: "#/components/parameters/GenreParam"
        - $ref: "#/components/parameters/AgeRatingParam"
        - $ref: "#/components/parameters/AuthorIdParam"
      responses:
        "200":
          description: The ranking, highest read count first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/NovelRankingEntry"
        "400":
          $ref: "#/components/responses/BadRequest"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/slug/{novelSlug}:
    get:
      tags: [ Novels ]
      operationId: getNovelBySlug
      summary: Read a single novel by its slug
      description: >-
        The one deliberate alias route: deep links carry only the human-readable slug, so
        it resolves a novel without a prior id lookup. Every other route addresses by `id`.
        Same visibility as `GET /novels/{novelId}` - a `DRAFT` novel 404s for anyone
        but an assigned author or a caller holding `NOVEL_EDIT_ANY`.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/NovelSlugParam"
      responses:
        "200":
          description: The novel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelDetail"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    get:
      tags: [ Novels ]
      operationId: getNovel
      summary: Read a single novel
      description: >-
        Public for any novel that is not `DRAFT`. A `DRAFT` novel is visible only to
        an assigned author or a caller holding `NOVEL_EDIT_ANY`.
      security: [ ]
      responses:
        "200":
          description: The novel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelDetail"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    patch:
      tags: [ Novels ]
      operationId: updateNovel
      summary: Change a novel
      description: >-
        A partial update: every field is optional, so a request body is optional too -
        an absent field or an absent body both leave everything unchanged. Requires
        assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateNovelRequest"
      responses:
        "200":
          description: The updated novel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Novels ]
      operationId: deleteNovel
      summary: Delete a novel
      description: >-
        Soft delete: the novel and its chapters stop appearing in any response, but the
        rows remain so accidentally deleted authors' work can be restored. Requires
        assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
      responses:
        "204":
          description: The novel is deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/authors:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    post:
      tags: [ Novels ]
      operationId: addNovelAuthor
      summary: Add a co-author
      description: >-
        Identifies the new author by their UUID, which can be obtained from `GET /users?username={username}`.
        Requires assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AddNovelAuthorRequest"
      responses:
        "201":
          description: The co-author was added.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelAuthor"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The novel does not exist, or no user has this username.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "409":
          description: This user is already an author of the novel.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/authors/{userId}:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - name: userId
        in: path
        required: true
        description: The author's `userId`.
        schema:
          $ref: "#/components/schemas/Uuid"
    delete:
      tags: [ Novels ]
      operationId: removeNovelAuthor
      summary: Remove an author
      description: >-
        Requires assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
      responses:
        "204":
          description: The author was removed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The novel does not exist, or no author has this `userId`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "409":
          description: This is the novel's last remaining author.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/cover:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    put:
      tags: [ Novels ]
      operationId: uploadNovelCover
      summary: Upload or replace a novel's cover image
      description: >-
        Multipart upload; idempotently replaces any cover the novel already has (or
        creates one). Accepted types: image/png, image/jpeg, image/webp; maximum size
        5 MiB. Requires assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [ file ]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: The novel with its updated `coverImageUrl`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelDetail"
        "400":
          description: The file is missing or not one of the accepted image types.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >-
            Another update to this novel (e.g. a concurrent cover upload by a co-author)
            committed first; retry the upload.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "413":
          description: The uploaded file exceeds the maximum cover image size.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Novels ]
      operationId: deleteNovelCover
      summary: Remove a novel's cover image
      description: >-
        Requires assignment via `NovelAuthor` or the `NOVEL_EDIT_ANY` permission.
        Succeeds even if the novel has no cover image.
      responses:
        "204":
          description: The cover image was removed, or there was none.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    get:
      tags: [ Chapters ]
      operationId: listChapters
      summary: List a novel's chapters in reading order
      description: >-
        Ordered by `sortKey` and already carrying each chapter's computed
        `chapterNumber`, so a client never sorts or numbers them itself. Paged like every
        list that can grow; `size` is at most 100, so a table of contents of a longer
        novel takes several requests.

        `?number=N` returns the (zero or one element) page holding the chapter at that
        reading-order position, so a deep link that only knows the number resolves it here.
        The row is a `Chapter`; a client that wants the `ChapterDetail` (neighbours) takes
        its `id` and calls `GET /novels/{novelId}/chapters/{chapterId}`.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/ChapterNumberFilterParam"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: One page of the chapters visible to the caller, in reading order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterPage"
        "400":
          $ref: "#/components/responses/BadRequest"

        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    post:
      tags: [ Chapters ]
      operationId: createChapter
      summary: Add a chapter to a novel
      description: >-
        The new chapter is appended after the current last chapter; use
        `PUT /novels/{novelId}/chapters/{chapterId}/position` afterwards to move it
        elsewhere. Requires assignment via `NovelAuthor` or the `CHAPTER_EDIT_ANY`
        permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateChapterRequest"
      responses:
        "201":
          description: The chapter was created and appended to the novel.
          headers:
            Location:
              $ref: "#/components/headers/Location"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
    get:
      tags: [ Chapters ]
      operationId: getChapter
      summary: Read a single chapter
      description: >-
        Public once the chapter is `PUBLISHED` with `accessLevel=FREE` and its novel
        is not itself `DRAFT`. Otherwise visible only to an assigned author or a
        caller holding `CHAPTER_EDIT_ANY` - anyone else gets `404`, the same as an
        unknown novel or chapter, so a stranger cannot tell a draft or premium chapter
        from one that does not exist. `PREMIUM` chapters have no purchase flow yet, so
        they are not publicly readable at all in this version.

        Carries the chapter's metadata, its novel and the reading-order
        `previousChapter`/`nextChapter` - the nearest chapter in each direction the
        caller can themselves read, skipping over any hidden one - so a reading view
        can render and let the reader page on without a further request. For the
        chapter's text, see `GET /novels/{novelId}/chapters/{chapterId}/parts`.
      security: [ ]
      responses:
        "200":
          description: The chapter, with its novel and reading-order neighbours.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterDetail"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    patch:
      tags: [ Chapters ]
      operationId: updateChapter
      summary: Change a chapter
      description: >-
        A partial update: every field is optional, so a request body is optional too -
        an absent field or an absent body both leave everything unchanged. Does not
        change the chapter's position; use
        `PUT /novels/{novelId}/chapters/{chapterId}/position` for that.
        Addressed by the chapter's stable `id` rather than its reader-facing
        `chapterNumber` - see the note on `ChapterIdParam`.
        Requires assignment via `NovelAuthor` or the `CHAPTER_EDIT_ANY` permission.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateChapterRequest"
      responses:
        "200":
          description: The updated chapter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Chapters ]
      operationId: deleteChapter
      summary: Delete a chapter
      description: >-
        Soft delete: the chapter stops appearing in any response, but the row remains
        so it can be restored. Addressed by the chapter's stable `id` - see the note on
        `ChapterIdParam`. Requires assignment via `NovelAuthor` or the
        `CHAPTER_EDIT_ANY` permission.
      responses:
        "204":
          description: The chapter is deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}/position:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
    put:
      tags: [ Chapters ]
      operationId: moveChapter
      summary: Move a chapter to a new position within its novel
      description: >-
        The server computes a new `sortKey` as the midpoint between the chapter named
        by `afterChapterId` and its current next neighbour, so moving one chapter never
        touches any other chapter's row. `400` (`CHAPTER_POSITION_INVALID`) when
        `afterChapterId` does not name another chapter of this novel, or names the
        chapter being moved. Requires assignment via `NovelAuthor` or the
        `CHAPTER_EDIT_ANY` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChapterPositionRequest"
      responses:
        "200":
          description: >-
            The chapter at its new position, with a recomputed `chapterNumber`, in the same
            shape as `PATCH /novels/{novelId}/chapters/{chapterId}`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterDetail"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}/content:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
    get:
      tags: [ Chapters ]
      operationId: getChapterContent
      summary: Read a chapter's Markdown source for editing
      description: >-
        Returns `content` exactly as it was last written, for an editor. Requires
        assignment via `NovelAuthor` or the `CHAPTER_EDIT_ANY` permission, the same as
        `PATCH`, whatever the chapter's status - readers use
        `GET /novels/{novelId}/chapters/{chapterId}/parts` instead. Never counts as a read.
      responses:
        "200":
          description: The chapter's Markdown source.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterSource"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}/parts:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
    get:
      tags: [ Chapters ]
      operationId: listChapterParts
      summary: List the reader-facing pages of a chapter
      description: >-
        The split of `content` into pages - see
        `GET /novels/{novelId}/chapters/{chapterId}/parts/{partNumber}` for a single page.
        Same visibility rule as `GET /novels/{novelId}/chapters/{chapterId}` - anyone who
        cannot read the chapter gets `404`. Paged (`size` at most 100); only the first page
        (`page=0`) counts as a read of the chapter.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: One page of the chapter's reader-facing pages, in order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterPartPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}/parts/{partNumber}:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
      - $ref: "#/components/parameters/PartNumberParam"
    get:
      tags: [ Chapters ]
      operationId: getChapterPart
      summary: Read one reader-facing page of a chapter
      description: >-
        Long chapters are split server-side into pages under a configurable character
        limit, so an author never has to fake multi-part numbering in a chapter's own
        title; a page has no title or content of its own beyond this split, and is
        always displayed as "<chapter title> - Part <partNumber>". See
        `GET /novels/{novelId}/chapters/{chapterId}/parts` to list every page at once -
        `partCount` on `Chapter`/`ChapterDetail` already gives the total without
        fetching it.

        Same visibility rule as `GET /novels/{novelId}/chapters/{chapterId}` - anyone
        who cannot read the chapter gets `404`, the same as an unknown `partNumber`.
        Pages are recomputed wholesale on every `PATCH` that changes `content`, so a
        `partNumber` obtained before an unrelated edit may already point at slightly
        different text.
      security: [ ]
      responses:
        "200":
          description: The requested page, with the chapter's title and total page count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ChapterPart"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me:
    get:
      tags: [ Users ]
      operationId: getCurrentUser
      summary: Read the own profile
      responses:
        "200":
          description: The profile of the authenticated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    patch:
      tags: [ Users ]
      operationId: updateCurrentUser
      summary: Change the own profile
      description: >-
        A partial update: every field is optional, so a request body is optional too -
        an absent field or an absent body both leave everything unchanged. Changing
        `email` or `password` additionally requires `currentPassword`, since both are
        credentials.

        A successful password change revokes all refresh tokens of the account,
        including the caller's own, so the client has to sign in again afterwards.
        Changing the username or e-mail does not, because tokens identify the user by
        ID.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProfileRequest"
      responses:
        "200":
          description: The updated profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me/role-requests:
    post:
      tags: [ Users ]
      operationId: requestRole
      summary: Request the AUTHOR role
      description: >-
        Self-service replacement for the removed `become-author` endpoint (see
        `specs/iterationplan.md`, Iteration 4.5). Currently auto-approved: the AUTHOR
        role - and with it `NOVEL_CREATE` - is granted immediately, visible after the
        next login/refresh. `AUTHOR` is the only requestable role in this version, so
        the request carries no body.

        Every request is persisted as its own auditable `RoleRequest`, so a future
        admin-approval requirement only changes the decision logic, not this endpoint
        or its data model. Idempotent for a caller who already holds AUTHOR: their
        existing request is returned unchanged instead of creating a duplicate.
      responses:
        "200":
          description: The caller already holds AUTHOR; their existing request is returned unchanged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleRequest"
        "201":
          description: A new request was created and immediately approved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    get:
      tags: [ Users ]
      operationId: listOwnRoleRequests
      summary: List the caller's own role requests
      description: Every request the caller has made, most recent first.
      responses:
        "200":
          description: The caller's role requests.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoleRequestList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me/novels:
    get:
      tags: [ Novels ]
      operationId: listOwnNovels
      summary: List the caller's own novels, including drafts
      description: >-
        Scoped: returns the novels the caller is an assigned author of, plus every
        novel (any status) if the caller holds `NOVEL_EDIT_ANY`. Unaffected by the
        public catalog's filters - this is the author's own working list, not a
        catalog browse. See `GET /novels` for the public catalog.


        This is also the only place an author can see their own `DRAFT` novels - the
        personal library (`/users/me/library/...`) refuses to hold a `DRAFT` novel even
        for its own author, so a frontend draft-management view is built on this
        endpoint, not on the library.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: A page of the novels visible to the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoredNovelPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me/library/novels:
    get:
      tags: [ Library ]
      operationId: listLibrary
      summary: List the signed-in user's library
      description: >-
        Paginated, ordered by `sort` (default `LAST_READ`). `readingStatus` and `search`
        narrow the listing; `statusCounts` in the response always counts the caller's
        whole library, independent of those filters, so a client can label status tabs
        from any page. An entry whose novel is no longer visible to the caller
        (soft-deleted, or a draft the caller can no longer see) is left out, the same
        visibility rule as the public catalog.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
        - $ref: "#/components/parameters/ReadingStatusParam"
        - $ref: "#/components/parameters/LibrarySortParam"
        - $ref: "#/components/parameters/SearchParam"
      responses:
        "200":
          description: The caller's library entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntryPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me/library/novels/{novelId}:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    get:
      tags: [ Library ]
      operationId: getLibraryEntry
      summary: Read the library entry for one novel
      description: >-
        `404` both when the novel does not exist (or is not visible to the caller) and
        when the caller has never added it to their library - the two are
        indistinguishable to the caller, same as elsewhere in this API.
      responses:
        "200":
          description: The library entry for this novel.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntry"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    put:
      tags: [ Library ]
      operationId: addOrUpdateLibraryEntry
      summary: Add a novel to the library, or change its reading status
      description: >-
        Upsert: creates the entry if the novel is not yet in the caller's library,
        otherwise updates it. `readingStatus` is optional - an empty body just adds the
        novel with the default `READING` status; a client that only wants to change
        status on an existing entry sends `readingStatus` alone.


        A `DRAFT` novel can never be added, `404`, even by its own author - the library
        is a reading list, not an authoring tool. An author previews their own drafts
        via `GET /users/me/novels` instead.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpsertLibraryEntryRequest"
      responses:
        "200":
          description: The novel was already in the library; its entry was updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntry"
        "201":
          description: The novel was not yet in the library; a new entry was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntry"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Library ]
      operationId: removeLibraryEntry
      summary: Remove a novel from the library
      description: >-
        A hard delete - a library entry is the caller's own bookmark/marker, not
        authored content, so there is nothing to preserve for recovery the way a
        soft-deleted novel or chapter is.
      responses:
        "204":
          description: The entry is removed.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /users/me/library/novels/{novelId}/progress:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    put:
      tags: [ Library ]
      operationId: updateReadingProgress
      summary: Update the reading position within a novel
      description: >-
        Server-authoritative, cross-device sync. Also an upsert, so a client can save
        progress on a novel it has not separately added to the library yet - the entry
        is created with `readingStatus=READING` if needed. `clientUpdatedAt` is the
        client's own timestamp of this reading event; an incoming update is only
        applied if it is newer than the entry's current `lastReadAt` (or the entry has
        no progress yet), otherwise this
        call is a no-op and simply returns the entry as it already stands
        (last-write-wins, not an error) - two nearly simultaneous updates from
        different devices resolve deterministically without the client needing to
        handle a conflict response.

        `chapterId` must name a chapter of this novel and `partNumber` must not exceed
        that chapter's current `partCount`, both are re-validated on every call since a
        chapter can be edited (re-paginated) or removed between reads.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateReadingProgressRequest"
      responses:
        "200":
          description: >-
            The novel was already in the library; its entry now reflects the update (or
            was left unchanged, if the update was stale).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntry"
        "201":
          description: The novel was not yet in the library; a new entry was created with this progress.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LibraryEntry"
        "400":
          description: >-
            `chapterId` does not name a chapter of this novel, or `partNumber` exceeds
            that chapter's current page count.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /reports:
    post:
      tags: [ Reports ]
      operationId: createReport
      summary: Report a visible novel, chapter, comment, or user
      description: >-
        Creates an open report. Novel, chapter and comment targets must currently be
        visible and active; user targets must refer to an existing enabled account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateReportRequest"
      responses:
        "201":
          description: The report was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /admin/reports:
    get:
      tags: [ Reports ]
      operationId: listReports
      summary: List reports for moderation
      description: >-
        Returns open reports by default, ordered oldest first so moderators can
        process them fairly. A status filter may be used to inspect an alternate
        status.
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
        - $ref: "#/components/parameters/ReportStatusParam"
      responses:
        "200":
          description: A page of reports.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportPage"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /admin/reports/{reportId}:
    parameters:
      - name: reportId
        in: path
        required: true
        description: Identifier of the report.
        schema:
          $ref: "#/components/schemas/Uuid"
    patch:
      tags: [ Reports ]
      operationId: updateReport
      summary: Review a report
      description: >-
        An open report can be transitioned once to REVIEWED, DISMISSED, or
        ACTION_TAKEN. The server records the moderator and decision time.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateReportRequest"
      responses:
        "200":
          description: The report was reviewed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/comments:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    get:
      tags: [ Comments ]
      operationId: listNovelComments
      summary: List comments on a novel
      description: >-
        Returns comments in stable oldest-first order. The novel must be visible to
        the caller. Deleted comments are left out, and `totalElements` doesn't count them.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: A page of novel comments.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommentPage"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    post:
      tags: [ Comments ]
      operationId: createNovelComment
      summary: Comment on a novel
      description: Creates a comment on a novel visible to the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCommentRequest"
      responses:
        "201":
          description: The comment was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/chapters/{chapterId}/comments:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
      - $ref: "#/components/parameters/ChapterIdParam"
    get:
      tags: [ Comments ]
      operationId: listChapterComments
      summary: List comments on a chapter
      description: >-
        Returns comments in stable oldest-first order. The chapter must be visible
        to the caller. Deleted comments are left out, and `totalElements` doesn't count them.
      security: [ ]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/SizeParam"
      responses:
        "200":
          description: A page of chapter comments.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommentPage"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    post:
      tags: [ Comments ]
      operationId: createChapterComment
      summary: Comment on a chapter
      description: Creates a comment on a chapter visible to the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCommentRequest"
      responses:
        "201":
          description: The comment was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /comments/{commentId}:
    parameters:
      - name: commentId
        in: path
        required: true
        description: Identifier of the comment.
        schema:
          $ref: "#/components/schemas/Uuid"
    patch:
      tags: [ Comments ]
      operationId: updateComment
      summary: Edit a comment
      description: >-
        Updates a comment's text. Only its author may edit it; REPORT_REVIEW
        moderators can hide it with DELETE.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateCommentRequest"
      responses:
        "200":
          description: The comment was updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Comments ]
      operationId: deleteComment
      summary: Delete or hide a comment
      description: >-
        Soft-deletes a comment. Its author or a caller with REPORT_REVIEW may perform
        this operation. A deleted comment is no longer listed; the row is kept for
        reports and moderation.
      responses:
        "204":
          description: The comment was deleted or hidden.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /novels/{novelId}/rating:
    parameters:
      - $ref: "#/components/parameters/NovelIdParam"
    get:
      tags: [ Ratings ]
      operationId: getNovelRating
      summary: Get the caller's rating for a novel
      description: >-
        Returns the caller's own rating for the novel (null if they have not rated it)
        together with the novel's aggregate summary. The novel must be visible to the caller.
      responses:
        "200":
          description: The caller's rating and the novel's aggregate summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelRating"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    put:
      tags: [ Ratings ]
      operationId: putNovelRating
      summary: Rate a novel
      description: >-
        Creates or replaces the caller's 1-5 star rating for a novel visible to them. At
        most one rating per caller and novel exists; a repeated call replaces the previous
        value instead of adding a second rating.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PutNovelRatingRequest"
      responses:
        "200":
          description: The rating was saved; the response reflects the new aggregate summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelRating"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"
    delete:
      tags: [ Ratings ]
      operationId: deleteNovelRating
      summary: Remove the caller's rating
      description: >-
        Deletes the caller's rating for the novel and returns the novel's aggregate
        summary without it (`stars` is null), so a client needs no second request. 404
        if they have not rated it.
      responses:
        "200":
          description: The rating was deleted; the response reflects the new aggregate summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NovelRating"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

  /system/ping:
    get:
      tags: [ System ]
      operationId: ping
      summary: Liveness ping of the API layer
      description: >-
        Confirms that the API layer is reachable and serving JSON. Infrastructure health
        checks use the Actuator endpoints instead.
      security: [ ]
      responses:
        "200":
          description: The API layer is reachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PingResponse"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/InternalServerError"

components:

  securitySchemes:

    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Short-lived JWT access token from `/auth/login` or `/auth/refresh`, sent as
        `Authorization: Bearer <token>`. It carries the effective permissions of the
        user as claims, so authorisation needs no database lookup per request.

  schemas:

    RegisterRequest:
      type: object
      description: >-
        A request to register a new user. The `username`, `email` and `password` are
        the credentials of the new user; `displayName` is optional.
      required: [ username, email, password ]
      properties:
        username:
          $ref: "#/components/schemas/Username"
        email:
          $ref: "#/components/schemas/Email"
        password:
          $ref: "#/components/schemas/Password"
        displayName:
          $ref: "#/components/schemas/DisplayName"

    LoginRequest:
      type: object
      description: >-
        A request to log in to the system. The `email` and `password` are the credentials of an existing user.
      required: [ email, password ]
      properties:
        email:
          $ref: "#/components/schemas/Email"
        password:
          $ref: "#/components/schemas/CredentialPassword"

    RefreshRequest:
      type: object
      description: >-
        Uses the refresh token to get a new access token and refresh token pair. The
        refresh token is revoked and replaced with a new one on every refresh, so it
        can only be used once.
      required: [ refreshToken ]
      properties:
        refreshToken:
          $ref: "#/components/schemas/RefreshToken"

    LogoutRequest:
      type: object
      description: >-
        Revokes the refresh token, so it can no longer be used to get a new access
        token.
      required: [ refreshToken ]
      properties:
        refreshToken:
          $ref: "#/components/schemas/RefreshToken"

    AuthenticationResponse:
      type: object
      description: The signed-in user together with a fresh token pair.
      required: [ user, tokens ]
      properties:
        user:
          $ref: "#/components/schemas/UserProfile"
        tokens:
          $ref: "#/components/schemas/TokenPair"

    TokenPair:
      type: object
      required: [ accessToken, tokenType, expiresIn, refreshToken, refreshExpiresIn ]
      properties:
        accessToken:
          $ref: "#/components/schemas/AccessToken"
        tokenType:
          $ref: "#/components/schemas/TokenType"
        expiresIn:
          $ref: "#/components/schemas/LifetimeSeconds"
        refreshToken:
          $ref: "#/components/schemas/RefreshToken"
        refreshExpiresIn:
          $ref: "#/components/schemas/LifetimeSeconds"

    UserProfile:
      type: object
      required: [ id, username, email, roles, permissions, enabled, createdAt, updatedAt ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        username:
          $ref: "#/components/schemas/Username"
        email:
          $ref: "#/components/schemas/Email"
        displayName:
          $ref: "#/components/schemas/DisplayName"
        roles:
          $ref: "#/components/schemas/RoleNameList"
        permissions:
          $ref: "#/components/schemas/PermissionList"
        enabled:
          $ref: "#/components/schemas/Enabled"
        createdAt:
          $ref: "#/components/schemas/Timestamp"
        updatedAt:
          $ref: "#/components/schemas/Timestamp"

    UpdateProfileRequest:
      type: object
      description: >-
        Partial update of the own profile. Omitted fields leave the current value
        unchanged; `null` is not accepted for this request.
      properties:
        username:
          $ref: "#/components/schemas/Username"
        email:
          $ref: "#/components/schemas/Email"
        password:
          allOf:
            - $ref: "#/components/schemas/Password"
          description: >-
            The new password. Requires `currentPassword`. Revokes every refresh token of the account, so
            the client has to sign in again.
        displayName:
          $ref: "#/components/schemas/DisplayName"
        currentPassword:
          allOf:
            - $ref: "#/components/schemas/CredentialPassword"
          description: >-
            The password currently in use. Mandatory when `email` or `password` is
            present, ignored otherwise.

    UpdateUserRolesRequest:
      type: object
      description: >-
        The complete role set to assign to a user, replacing whatever they currently
        have.
      required: [ roles ]
      properties:
        roles:
          allOf:
            - $ref: "#/components/schemas/RoleNameList"
          minItems: 1
          description: Every name must already exist as a role.

    UpdateUserStatusRequest:
      type: object
      description: Whether the account should be enabled or suspended.
      required: [ enabled ]
      properties:
        enabled:
          $ref: "#/components/schemas/Enabled"

    RoleRequest:
      type: object
      description: >-
        An auditable, self-service request for a role.
      required: [ id, requestedRole, status, createdAt ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        requestedRole:
          $ref: "#/components/schemas/RoleName"
        status:
          $ref: "#/components/schemas/RoleRequestStatus"
        decision:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/RoleRequestDecision"
          description: >-
            Absent while `status` is `PENDING`. Present for an automatic decision, or
            when an admin/user has decided it.
        createdAt:
          $ref: "#/components/schemas/Timestamp"

    RoleRequestList:
      type: array
      description: >-
        A list of role requests, most recent first.
      items:
        $ref: "#/components/schemas/RoleRequest"

    RoleRequestDecision:
      type: object
      description: >-
        The decision on a role request, either automatic or by an admin/user.
      required: [ decidedBy, decidedAt ]
      properties:
        decidedBy:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Uuid"
          description: >-
            The user who decided the request, or `null` if it was an automatic decision.
        decidedAt:
          $ref: "#/components/schemas/Timestamp"

    NovelReference:
      type: object
      description: >-
        Minimal novel identity.
      required: [ id, slug, title, chapterCount ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        slug:
          $ref: "#/components/schemas/Slug"
        title:
          $ref: "#/components/schemas/Title"
        chapterCount:
          $ref: "#/components/schemas/ChapterCount"

    NovelRatingSummary:
      type: object
      description: >-
        Aggregated rating for a novel. `ratingCount=0` means no ratings yet, in which case
        `ratingAverage` is absent rather than a fabricated `0.0`.
      required: [ ratingCount ]
      properties:
        ratingCount:
          $ref: "#/components/schemas/RatingCount"
        ratingAverage:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/RatingAverage"

    Novel:
      type: object
      description: >-
        Full novel metadata, without its synopsis or authorship records - see `NovelDetail` for those. The `coverImageUrl`
        is always present, but the image it points at may be a placeholder if the author has not uploaded one yet.
      allOf:
        - $ref: "#/components/schemas/NovelReference"
        - type: object
          required:
            [ coverImageUrl, genres, tags, status, ageRating, contentWarnings, ratingSummary, publishedAt, authors ]
          properties:
            coverImageUrl:
              $ref: "#/components/schemas/CoverImageUrl"
            genres:
              $ref: "#/components/schemas/GenreList"
            tags:
              $ref: "#/components/schemas/TagList"
            authors:
              $ref: "#/components/schemas/AuthorList"
            status:
              $ref: "#/components/schemas/NovelStatus"
            ageRating:
              $ref: "#/components/schemas/AgeRating"
            contentWarnings:
              $ref: "#/components/schemas/ContentWarningList"
            ratingSummary:
              $ref: "#/components/schemas/NovelRatingSummary"
            publishedAt:
              $ref: "#/components/schemas/PublishedAt"

    NovelDetail:
      description: >-
        Novel metadata plus its synopsis and the full authorship records.
      allOf:
        - $ref: "#/components/schemas/Novel"
        - type: object
          required: [ description, authorships ]
          properties:
            description:
              $ref: "#/components/schemas/Synopsis"
            authorships:
              $ref: "#/components/schemas/AuthorshipList"

    NovelRankingEntry:
      type: object
      description: >-
        One entry of a most-read ranking (`GET /rankings/novels/most-read`). Novels with
        zero reads in the ranked period are never included, so `rank` has no gaps.
      required: [ rank, novel, readCount ]
      properties:
        rank:
          type: integer
          format: int32
          minimum: 1
          description: 1-based position within the ranked period, novel id ascending on a tie.
        novel:
          $ref: "#/components/schemas/Novel"
        readCount:
          type: integer
          format: int64
          minimum: 1
          description: Aggregated read-call count over the ranked period.

    CreateNovelRequest:
      type: object
      description: >-
        Creates a new novel. The caller must hold `NOVEL_CREATE`. The cover image is not part of this
        request - use `PUT /novels/{novelId}/cover` after creation.
      required: [ title, description ]
      properties:
        title:
          $ref: "#/components/schemas/Title"
        description:
          $ref: "#/components/schemas/Synopsis"
        ageRating:
          $ref: "#/components/schemas/AgeRating"
        genres:
          $ref: "#/components/schemas/GenreList"
        tags:
          $ref: "#/components/schemas/TagList"
        contentWarnings:
          $ref: "#/components/schemas/ContentWarningList"

    UpdateNovelRequest:
      type: object
      description: >-
        Partial update. Every field is optional - an absent field or an absent body both leave everything unchanged. 
        The cover image is not part of this request - use `PUT`/`DELETE /novels/{novelId}/cover`.
      properties:
        title:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Title"
        description:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Synopsis"
        ageRating:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/AgeRating"
        genres:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/GenreList"
        tags:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/TagList"
        status:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/NovelStatus"
        contentWarnings:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ContentWarningList"

    NovelQueryPage:
      type: object
      description: A page of novels, sorted by creation time, most recent first.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          $ref: "#/components/schemas/NovelList"

    AuthoredNovel:
      description: >-
        A novel as its author sees it in `GET /users/me/novels`: the public metadata plus
        how many of its chapters are published or still drafts, and when anything about
        it last changed. Kept out of the public `Novel`, so the catalog never reveals an
        author's unpublished work.
      allOf:
        - $ref: "#/components/schemas/Novel"
        - type: object
          required: [ publishedChapterCount, draftChapterCount, updatedAt ]
          properties:
            publishedChapterCount:
              type: integer
              minimum: 0
              description: Active chapters with status `PUBLISHED`, whatever their access level.
            draftChapterCount:
              type: integer
              minimum: 0
              description: Active chapters with status `DRAFT`.
            updatedAt:
              allOf:
                - $ref: "#/components/schemas/Timestamp"
              description: >-
                The latest change to the novel's own details or to any of its active
                chapters (see `Chapter.updatedAt`).

    AuthoredNovelPage:
      type: object
      description: A page of the caller's own novels, sorted by creation time, most recent first.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          type: array
          items:
            $ref: "#/components/schemas/AuthoredNovel"

    PageInfo:
      type: object
      description: >-
        Pagination metadata.
      required: [ page, size, totalElements, totalPages ]
      properties:
        page:
          $ref: "#/components/schemas/PageIndex"
        size:
          $ref: "#/components/schemas/PageSize"
        totalElements:
          $ref: "#/components/schemas/TotalElements"
        totalPages:
          $ref: "#/components/schemas/PageCount"

    PublicUserProfile:
      type: object
      description: >-
        The publicly visible part of an account - safe to show to anyone. `author` is
        true while the account holds a role that may create novels.
      required: [ id, username, author, publishedNovelCount, memberSince ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        username:
          $ref: "#/components/schemas/Username"
        displayName:
          $ref: "#/components/schemas/DisplayName"
        author:
          type: boolean
          description: Whether the account may publish novels (holds `NOVEL_CREATE`).
        publishedNovelCount:
          type: integer
          minimum: 0
          description: Number of public (non-`DRAFT`, not deleted) novels this user is an author of.
        memberSince:
          $ref: "#/components/schemas/Timestamp"

    UserReference:
      type: object
      description: >-
        Minimal user identity.
      required: [ id, username ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        username:
          $ref: "#/components/schemas/Username"
        displayName:
          $ref: "#/components/schemas/DisplayName"

    UserQueryPage:
      type: object
      description: A page of user references, ordered by username ascending.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          $ref: "#/components/schemas/UserReferenceList"

    UserReferenceList:
      type: array
      description: >-
        Search results for users matching a username filter.
      items:
        $ref: "#/components/schemas/UserReference"

    AdminUserPage:
      type: object
      description: >-
        A page of full user profiles for administration, ordered by username
        ascending.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          $ref: "#/components/schemas/AdminUserList"

    AdminUserList:
      type: array
      description: Users matching the given admin-listing filters.
      items:
        $ref: "#/components/schemas/UserProfile"

    CreateReportRequest:
      type: object
      description: >-
        A report against a currently visible novel, chapter, or comment, or an enabled user.
      required: [ targetType, targetId, reason ]
      properties:
        targetType:
          $ref: "#/components/schemas/ReportTargetType"
        targetId:
          $ref: "#/components/schemas/Uuid"
        reason:
          $ref: "#/components/schemas/ReportReason"
        description:
          allOf:
            - $ref: "#/components/schemas/ReportDescription"
          nullable: true

    Report:
      type: object
      description: A user-submitted report and its moderation decision.
      required:
        [ id, reporterId, targetType, targetId, reason, status, createdAt, reviewedAt, reviewedBy ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        reporterId:
          $ref: "#/components/schemas/Uuid"
        targetType:
          $ref: "#/components/schemas/ReportTargetType"
        targetId:
          $ref: "#/components/schemas/Uuid"
        reason:
          $ref: "#/components/schemas/ReportReason"
        description:
          allOf:
            - $ref: "#/components/schemas/ReportDescription"
          nullable: true
        status:
          $ref: "#/components/schemas/ReportStatus"
        createdAt:
          $ref: "#/components/schemas/Timestamp"
        reviewedAt:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        reviewedBy:
          allOf:
            - $ref: "#/components/schemas/Uuid"
          nullable: true

    ReportPage:
      type: object
      description: A page of reports, ordered by creation time.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          type: array
          items:
            $ref: "#/components/schemas/Report"

    UpdateReportRequest:
      type: object
      description: The final moderation status for an open report.
      required: [ status ]
      properties:
        status:
          $ref: "#/components/schemas/ReportStatus"

    Comment:
      type: object
      description: >-
        A comment on a novel or chapter. A deleted comment is never returned, so the
        schema has no deleted flag; the author is identified by `author.id`.
      required: [ id, author, content, createdAt, updatedAt ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        author:
          $ref: "#/components/schemas/UserReference"
        content:
          $ref: "#/components/schemas/CommentContent"
        createdAt:
          $ref: "#/components/schemas/Timestamp"
        updatedAt:
          $ref: "#/components/schemas/Timestamp"

    CommentPage:
      type: object
      description: A page of comments in stable oldest-first order.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          type: array
          items:
            $ref: "#/components/schemas/Comment"

    CreateCommentRequest:
      type: object
      description: Creates a comment in a visible novel or chapter.
      required: [ content ]
      properties:
        content:
          $ref: "#/components/schemas/CommentContent"

    UpdateCommentRequest:
      type: object
      description: Replaces the text of an existing comment.
      required: [ content ]
      properties:
        content:
          $ref: "#/components/schemas/CommentContent"

    NovelRating:
      type: object
      description: >-
        The caller's own rating for a novel, if any, plus the novel's aggregate summary.
      required: [ summary ]
      properties:
        stars:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Stars"
          description: The caller's own rating, or null if they have not rated this novel.
        summary:
          $ref: "#/components/schemas/NovelRatingSummary"

    PutNovelRatingRequest:
      type: object
      description: Creates or replaces the caller's rating for a novel.
      required: [ stars ]
      properties:
        stars:
          $ref: "#/components/schemas/Stars"

    NovelAuthor:
      type: object
      description: One user's assignment as an author of a novel.
      required: [ novelId, authorRef, addedAt ]
      properties:
        novelId:
          $ref: "#/components/schemas/Uuid"
        authorRef:
          $ref: "#/components/schemas/UserReference"
        addedAt:
          $ref: "#/components/schemas/Timestamp"

    AuthorshipList:
      type: array
      description: >-
        List of authors assigned to a novel, in the order they were added. The first
        author is the one who created the novel.
      items:
        $ref: "#/components/schemas/NovelAuthor"

    AddNovelAuthorRequest:
      type: object
      description: >-
        Adds an existing user as a co-author of a novel. The user must already exist
        and have the `AUTHOR` role.
      required: [ userId ]
      properties:
        userId:
          $ref: "#/components/schemas/Uuid"

    Chapter:
      type: object
      description: >-
        A chapter's metadata and reading-order position, without its text - see
        `ChapterPart` for the reader-facing pages and
        `GET /novels/{novelId}/chapters/{chapterId}/content` for an author's Markdown source.
      required:
        [ id, chapterNumber, title, partCount, status, accessLevel, wordCount, updatedAt ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        chapterNumber:
          $ref: "#/components/schemas/ChapterNumber"
        title:
          $ref: "#/components/schemas/Title"
        partCount:
          $ref: "#/components/schemas/PartCount"
        status:
          $ref: "#/components/schemas/ChapterStatus"
        accessLevel:
          $ref: "#/components/schemas/ChapterAccessLevel"
        wordCount:
          $ref: "#/components/schemas/ChapterWordCount"
        updatedAt:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          description: >-
            When the chapter last changed: its title, text, status, access level or
            position.

    ChapterDetail:
      description: >-
        Full chapter metadata plus its novel reference.
      allOf:
        - $ref: "#/components/schemas/Chapter"
        - type: object
          required: [ novel, createdAt ]
          properties:
            novel:
              $ref: "#/components/schemas/NovelReference"
            previousChapter:
              nullable: true
              allOf:
                - $ref: "#/components/schemas/Chapter"
              description: >-
                The nearest earlier chapter the caller can read. Absent when this is 
                the first chapter the caller can reach.
            nextChapter:
              nullable: true
              allOf:
                - $ref: "#/components/schemas/Chapter"
              description: >-
                The nearest later chapter the caller can read. Absent when this is the
                last chapter the caller can reach.
            publishedAt:
              nullable: true
              allOf:
                - $ref: "#/components/schemas/Timestamp"
            createdAt:
              $ref: "#/components/schemas/Timestamp"

    ChapterSource:
      type: object
      description: >-
        A chapter's full Markdown source exactly as last written - unlike `ChapterPart`,
        which is the reader-facing split of it and normalises the whitespace at page
        boundaries. Meant for an editor that writes the text back via
        `PATCH /novels/{novelId}/chapters/{chapterId}`.
      required: [ id, content, updatedAt ]
      properties:
        id:
          $ref: "#/components/schemas/Uuid"
        content:
          $ref: "#/components/schemas/ChapterContent"
        updatedAt:
          $ref: "#/components/schemas/Timestamp"

    ChapterPart:
      type: object
      description: >-
        One reader-facing page of a chapter's content. Never independently titled or editable.
        To change the text, write the chapter's own `content` via 
        `PATCH /novels/{novelId}/chapters/{chapterId}`, which recomputes every page of that
        chapter in one step.
      required: [ chapterNumber, title, partNumber, partCount, content ]
      properties:
        chapterNumber:
          $ref: "#/components/schemas/ChapterNumber"
        title:
          $ref: "#/components/schemas/Title"
        partNumber:
          $ref: "#/components/schemas/PartNumber"
        partCount:
          $ref: "#/components/schemas/PartCount"
        content:
          $ref: "#/components/schemas/ChapterContent"

    CreateChapterRequest:
      type: object
      description: >-
        Creates a new chapter at the end of the novel. The new chapter's `partCount` is computed from its
        `content` and returned in the response.
      required: [ title, content ]
      properties:
        title:
          $ref: "#/components/schemas/Title"
        content:
          $ref: "#/components/schemas/ChapterContent"
        status:
          $ref: "#/components/schemas/ChapterStatus"
        accessLevel:
          $ref: "#/components/schemas/ChapterAccessLevel"

    UpdateChapterRequest:
      type: object
      description: >-
        Partial update. Every field is optional and an omitted field is left
        unchanged. Does not affect the chapter's position among its siblings.
      properties:
        title:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Title"
        content:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ChapterContent"
        status:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ChapterStatus"
        accessLevel:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ChapterAccessLevel"

    ChapterPositionRequest:
      type: object
      required: [ afterChapterId ]
      properties:
        afterChapterId:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Uuid"
          description: >-
            `id` of the chapter this one should immediately follow, within the same
            novel. Null moves the chapter to the very first position. Must not name the
            chapter being moved. Unlike a `chapterNumber`, an `id` never goes stale, so
            this reference is safe to reuse even after an unrelated reorder.

    LibraryEntry:
      type: object
      description: >-
        One novel in the caller's personal library, together with the caller's own
        cross-device reading position in it.
      required: [ novel, readingStatus, addedAt, updatedAt ]
      properties:
        novel:
          $ref: "#/components/schemas/Novel"
        readingStatus:
          $ref: "#/components/schemas/ReadingStatus"
        lastBookmark:
          allOf:
            - $ref: "#/components/schemas/Bookmark"
          nullable: true
          description: >-
            The last chapter/part the caller read, and their position within it. Absent
            if the caller has never read any part of this novel.
        addedAt:
          $ref: "#/components/schemas/Timestamp"
        updatedAt:
          $ref: "#/components/schemas/Timestamp"
        lastReadAt:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          description: >-
            When reading progress was last recorded for this entry. Absent if the caller
            has never read any part of this novel. Unlike `updatedAt`, a reading-status
            change does not move it.

    LibraryEntryPage:
      description: A page of library entries in the requested `LibrarySort` order.
      type: object
      required: [ metadata, content, statusCounts ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          $ref: "#/components/schemas/LibraryEntryList"
        statusCounts:
          $ref: "#/components/schemas/LibraryStatusCounts"

    LibraryStatusCounts:
      type: object
      description: >-
        Number of visible entries in the caller's whole library per reading status,
        independent of the listing's `readingStatus`/`search` filters.
      required: [ total, reading, completed, planToRead, dropped ]
      properties:
        total:
          type: integer
          format: int64
          minimum: 0
        reading:
          type: integer
          format: int64
          minimum: 0
        completed:
          type: integer
          format: int64
          minimum: 0
        planToRead:
          type: integer
          format: int64
          minimum: 0
        dropped:
          type: integer
          format: int64
          minimum: 0

    LibrarySort:
      type: string
      description: >-
        Order of a library listing. `LAST_READ`: most recent reading progress first,
        entries never read last (most recently updated first among those).
        `RECENTLY_ADDED`: most recently added first. `TITLE`: novel title A-Z,
        case-insensitive.
      enum:
        - LAST_READ
        - RECENTLY_ADDED
        - TITLE
      default: LAST_READ

    UpsertLibraryEntryRequest:
      type: object
      description: >-
        Every field is optional. An empty body just adds the novel with the default
        `READING` status (or leaves an existing entry's status unchanged).
      properties:
        readingStatus:
          $ref: "#/components/schemas/ReadingStatus"

    UpdateReadingProgressRequest:
      type: object
      required: [ chapterId, partNumber, position, clientUpdatedAt ]
      properties:
        chapterId:
          $ref: "#/components/schemas/Uuid"
        partNumber:
          $ref: "#/components/schemas/PartNumber"
        position:
          $ref: "#/components/schemas/ReadingPosition"
        clientUpdatedAt:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          description: >-
            The client's own timestamp of this reading event, compared against the
            entry's stored `lastReadAt` to resolve concurrent updates (last-write-wins).
            Not `updatedAt`: a reading-status change bumps that to server time and would
            otherwise make the next progress write look stale.

    PingResponse:
      type: object
      required: [ status, timestamp ]
      properties:
        status:
          $ref: "#/components/schemas/StatusOk"
        timestamp:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          description: Server time when the response was produced, in UTC.

    Email:
      type: string
      format: email
      maxLength: 255
      description: Unique login identifier.
      example: ada@example.com

    Uuid:
      type: string
      format: uuid
      description: UUID identifier.

    Timestamp:
      type: string
      format: date-time
      description: Date and time in ISO 8601 format.

    Username:
      type: string
      minLength: 3
      maxLength: 50
      description: Unique account handle.
      example: ada

    DisplayName:
      type: string
      maxLength: 100
      description: Optional name shown instead of the username.

    Password:
      type: string
      format: password
      minLength: 8
      maxLength: 72
      description: Password containing at least 8 and at most 72 characters.

    CredentialPassword:
      type: string
      format: password
      maxLength: 72
      description: >-
        A password presented to verify an existing account (login, or `currentPassword`
        on a profile update) - bounded only above, so a too-short or otherwise wrong
        value is rejected as an invalid credential rather than a validation error that
        would distinguish it from every other wrong password.

    RefreshToken:
      type: string
      maxLength: 128
      description: Opaque token used to obtain or revoke the next token pair.

    ReadingStatus:
      type: string
      description: Where the caller stands with a novel in their own library.
      enum:
        - READING
        - COMPLETED
        - PLAN_TO_READ
        - DROPPED

    Permission:
      type: string
      description: >-
        A single fine-grained capability. Permissions are checked directly instead of
        role names, so new roles can be introduced as pure configuration. New values may
        be added over time, so clients must tolerate unknown ones.
      enum:
        - NOVEL_CREATE
        - NOVEL_EDIT_ANY
        - CHAPTER_EDIT_ANY
        - USER_MANAGE
        - ROLE_MANAGE
        - REPORT_REVIEW
        - PLATFORM_STATS_VIEW

    NovelStatus:
      type: string
      description: Publication status of the whole work.
      enum:
        - DRAFT
        - ONGOING
        - COMPLETED
        - HIATUS

    PublicNovelStatus:
      type: string
      description: >-
        The subset of [NovelStatus](#/components/schemas/NovelStatus) a reader can
        filter the public catalog by. `DRAFT` is deliberately not a member: every
        `DRAFT` novel is already excluded from `GET /novels` regardless of this filter,
        so allowing it as a value would only ever produce an empty page instead of a
        clear `400`.
      enum:
        - ONGOING
        - COMPLETED
        - HIATUS

    AgeRating:
      type: string
      description: >-
        Coarse age classification set by an author on create/update. `GET /novels`
        can filter by it so a client hides e.g. `MATURE` per the reader's own
        settings - the server applies no such restriction itself.
      enum:
        - ALL_AGES
        - TEEN
        - MATURE

    RecentWindow:
      type: string
      description: >-
        A trailing window of UTC calendar days, ending today (today itself included).
        Used both as the ranked period and as the `publishedWithin` recency filter on
        `GET /rankings/novels/most-read`.
      enum:
        - LAST_1_DAY
        - LAST_7_DAYS
        - LAST_30_DAYS

    Synopsis:
      type: string
      maxLength: 3000
      minLength: 1
      description: Novel synopsis, up to 3,000 characters.

    ChapterNumber:
      type: integer
      description: 1-based position of a chapter within its novel.

    PartNumber:
      type: integer
      minimum: 1
      description: 1-based position of a reader-facing page within its chapter.

    PartCount:
      type: integer
      minimum: 1
      description: Number of reader-facing pages in a chapter.

    ReadingPosition:
      type: number
      format: double
      description: Position marker within a reader-facing page.

    Stars:
      type: integer
      format: int32
      minimum: 1
      maximum: 5
      description: A 1-5 star rating.

    RatingCount:
      type: integer
      format: int64
      minimum: 0
      description: Number of ratings a novel has received.

    RatingAverage:
      type: number
      format: double
      description: Arithmetic mean of all stars, rounded to one decimal place.

    AccessToken:
      type: string
      description: JWT Short-lived token for authenticated requests.

    TokenType:
      type: string
      description: Authentication scheme used in the authorization header.
      example: Bearer

    LifetimeSeconds:
      type: integer
      format: int64
      description: Remaining lifetime of a token in seconds.
      example: 900

    RoleName:
      type: string
      description: Name of an account role.
      example: AUTHOR

    RoleRequestStatus:
      type: string
      description: >-
        `PENDING` is unused in this version - every request is decided immediately - but
        already modeled so a later admin-approval requirement (see
        `anforderungen-backend.md`, section 15) only changes the decision logic, not this
        schema.
      enum:
        - PENDING
        - APPROVED
        - REJECTED
      example: APPROVED

    ReportTargetType:
      type: string
      description: Kind of resource to which a report refers.
      enum:
        - NOVEL
        - CHAPTER
        - USER
        - COMMENT

    ReportReason:
      type: string
      description: Fixed category explaining why a resource was reported.
      enum:
        - COPYRIGHT
        - INAPPROPRIATE_CONTENT
        - SPAM
        - OTHER

    ReportStatus:
      type: string
      description: Moderation lifecycle of a report.
      enum:
        - OPEN
        - REVIEWED
        - DISMISSED
        - ACTION_TAKEN

    ReportDescription:
      type: string
      description: Optional explanation supplied by the reporter, up to 1,000 characters.
      maxLength: 1000

    CommentContent:
      type: string
      minLength: 1
      maxLength: 1000
      description: Comment text, between 1 and 1,000 characters.

    Slug:
      type: string
      readOnly: true
      pattern: '^[a-z0-9]+(-[a-z0-9]+)*$'
      maxLength: 220
      description: URL-friendly identifier.
      example: the-silent-orbit

    Title:
      type: string
      minLength: 1
      maxLength: 80
      description: Non-empty title, up to 80 characters.

    CoverImageUrl:
      type: string
      nullable: true
      format: uri
      description: Server-resolved cover image URL, absent if the novel has none.

    PublishedAt:
      type: string
      nullable: true
      format: date-time
      description: >-
        When the novel first left `DRAFT`, set once and never changed by a later status
        change. Absent if the novel has never left `DRAFT`.

    Genre:
      type: string
      minLength: 1
      maxLength: 50
      description: Genre assigned to a novel.

    ContentWarning:
      type: string
      minLength: 1
      maxLength: 100
      description: Freely entered content warning.

    ChapterContent:
      type: string
      minLength: 1
      # A generous sanity ceiling, not a product constraint: any realistic single
      # chapter is well under this (requirements, section 11, "Längenbegrenzungen" -
      # every other free-text field in this contract is bounded, this one previously
      # was not).
      maxLength: 500000
      description: >-
        Markdown source of chapter text, stored as-is. Chapter text is prose, so Markdown may
        only style it: paragraphs, bold, italic, strikethrough, headings, block quotes, lists
        and scene breaks (thematic breaks). Links of any kind (inline, reference with a
        definition, `<...>` autolinks, bare `https://`/`www.` URLs and email addresses), link
        definitions, images, raw HTML, code (spans, fenced and indented blocks), tables and
        task-list checkboxes are refused with `400 VALIDATION_FAILED`: one `errors[]` entry per
        kind found, each with `field: content` and the line numbers in its message. The
        text is parsed as GitHub Flavored Markdown, as the reader renders it - `[sighs]` without
        a matching `[sighs]: url` definition is plain text, not a link. Only new text is checked,
        so a title- or status-only update never fails on older text.

    ChapterWordCount:
      type: integer
      description: Computed word count of a chapter.

    ChapterStatus:
      type: string
      description: >-
        Drafts are visible only to the novel's assigned authors; `PUBLISHED` is
        required for a chapter to be readable by anyone else.
      enum:
        - DRAFT
        - PUBLISHED

    ChapterAccessLevel:
      type: string
      description: >-
        Prepared for paid chapters. Every chapter is FREE in this version.
      enum:
        - FREE
        - PREMIUM

    StatusOk:
      type: string
      description: Successful liveness status.
      example: ok

    FieldName:
      type: string
      description: Name of a rejected field.

    ErrorMessage:
      type: string
      description: Explanation of why a value was rejected.

    ProblemType:
      type: string
      format: uri
      description: Identifier of the problem type.

    ProblemTitle:
      type: string
      description: Short human-readable problem summary.

    ProblemDetailText:
      type: string
      description: Human-readable explanation of a specific problem occurrence.

    ProblemInstance:
      type: string
      format: uri
      description: Request path that produced the problem.

    HttpStatus:
      type: integer
      format: int32
      description: HTTP status code.

    NovelList:
      type: array
      items:
        $ref: "#/components/schemas/Novel"

    ChapterPage:
      type: object
      description: >-
        A page of a novel's chapters, sorted by `chapterNumber` ascending. The first
        chapter is the one with the lowest `chapterNumber`.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          type: array
          items:
            $ref: "#/components/schemas/Chapter"

    ChapterPartPage:
      type: object
      description: >-
        A page of the reader-facing pages of a chapter, sorted by `partNumber` ascending.
      required: [ metadata, content ]
      properties:
        metadata:
          $ref: "#/components/schemas/PageInfo"
        content:
          type: array
          items:
            $ref: "#/components/schemas/ChapterPart"

    LibraryEntryList:
      type: array
      description: >-
        List of entries in a user's library.
      items:
        $ref: "#/components/schemas/LibraryEntry"

    PageIndex:
      type: integer
      minimum: 0
      description: Zero-based page index.

    PageSize:
      type: integer
      minimum: 1
      maximum: 100
      description: Requested page size.

    TotalElements:
      type: integer
      format: int64
      description: Total number of elements across all pages.

    PageCount:
      type: integer
      minimum: 0
      description: Total number of pages.

    ChapterCount:
      type: integer
      minimum: 0
      description: Number of chapters in a novel.

    GenreList:
      type: array
      items:
        type: string
        minLength: 1
        maxLength: 50
      maxItems: 20
      default: [ ]
      example: [ Science Fiction ]

    TagList:
      type: array
      description: >-
        Free-form tags of a novel (e.g. "Slow burn", "Found family"). Trimmed;
        duplicates differing only in case are collapsed to the first occurrence.
      items:
        $ref: "#/components/schemas/Tag"
      maxItems: 10
      default: [ ]
      example: [ Slow burn, Found family ]

    Tag:
      type: string
      minLength: 1
      maxLength: 30
      description: A single free-form novel tag.

    TagSummary:
      type: object
      description: A tag in use in the public catalog, with the number of public novels carrying it.
      required: [ tag, novelCount ]
      properties:
        tag:
          $ref: "#/components/schemas/Tag"
        novelCount:
          type: integer
          format: int64
          minimum: 1

    TagSummaryList:
      type: array
      items:
        $ref: "#/components/schemas/TagSummary"

    AuthorList:
      type: array
      description: >-
        The novel's authors as lightweight references, first-added (primary) author first.
        `NovelDetail.authorships` carries the full authorship records.
      items:
        $ref: "#/components/schemas/UserReference"

    ContentWarningList:
      type: array
      items:
        type: string
        minLength: 1
        maxLength: 100
      maxItems: 20
      default: [ ]
      example: [ "Violence" ]

    RoleNameList:
      type: array
      description: >-
        Names of the assigned roles. Plain strings rather than an enum: roles are
        configuration data and new ones may appear without a client release.
        Branch on `permissions` instead.
      items:
        $ref: "#/components/schemas/RoleName"
      default: [ ]
      example: [ READER ]

    PermissionList:
      type: array
      description: >-
        Effective permissions, the union over all assigned roles - what a UI should
        use to decide whether to offer an action.
      items:
        $ref: "#/components/schemas/Permission"
      default: [ ]
      example: [ NOVEL_CREATE, NOVEL_EDIT_ANY ]

    Enabled:
      type: boolean
      description: Whether the account is enabled.

    Bookmark:
      type: object
      description: A reading position recorded for a novel.
      required: [ chapterId, chapterNumber, chapterTitle, partNumber, position ]
      properties:
        chapterId:
          $ref: "#/components/schemas/Uuid"
        chapterNumber:
          $ref: "#/components/schemas/ChapterNumber"
        chapterTitle:
          $ref: "#/components/schemas/Title"
        partNumber:
          $ref: "#/components/schemas/PartNumber"
        position:
          $ref: "#/components/schemas/ReadingPosition"

    ErrorCode:
      type: string
      description: >-
        Stable, machine-readable error identifier. Clients branch on this instead of on
        the human-readable message. New values may be added over time; the backend itself
        never sends `UNKNOWN` - it is reserved for clients to substitute locally when they
        receive a code added after they were built, so older clients degrade to `detail`/
        `title` instead of failing to parse the response.
      enum:
        - UNKNOWN
        - VALIDATION_FAILED
        - MALFORMED_REQUEST
        - NOT_FOUND
        - METHOD_NOT_ALLOWED
        - UNSUPPORTED_MEDIA_TYPE
        - INTERNAL_ERROR
        - AUTHENTICATION_REQUIRED
        - INVALID_CREDENTIALS
        - INVALID_REFRESH_TOKEN
        - CURRENT_PASSWORD_INVALID
        - ACCOUNT_DISABLED
        - ACCESS_DENIED
        - EMAIL_ALREADY_IN_USE
        - USERNAME_ALREADY_IN_USE
        - AUTHOR_ALREADY_ASSIGNED
        - LAST_AUTHOR_CANNOT_BE_REMOVED
        - CHAPTER_POSITION_INVALID
        - UNSUPPORTED_IMAGE_TYPE
        - COVER_IMAGE_TOO_LARGE
        - COVER_UPLOAD_CONFLICT
        - NOVEL_CREATION_CONFLICT
        - PROGRESS_CHAPTER_INVALID
        - LIBRARY_ENTRY_CONFLICT
        - RATE_LIMIT_EXCEEDED
        - REPORT_TARGET_INVALID
        - REPORT_STATUS_INVALID
        - RATING_CONFLICT
        - SELF_ADMIN_ACTION_FORBIDDEN

    ValidationError:
      type: object
      description: A single field that failed server-side validation.
      required: [ field, message ]
      properties:
        field:
          allOf:
            - $ref: "#/components/schemas/FieldName"
          example: email
        message:
          allOf:
            - $ref: "#/components/schemas/ErrorMessage"
          example: must be a well-formed email address

    ProblemDetail:
      type: object
      description: >-
        Uniform error format following RFC 7807, served as `application/problem+json`.
        Beyond the members defined by the RFC it carries a stable `code`, a `timestamp`
        and, for failed validation, the offending fields.
      required: [ type, title, status, code, timestamp ]
      properties:
        type:
          allOf:
            - $ref: "#/components/schemas/ProblemType"
          example: https://api.pagemark.de/problems/404
        title:
          allOf:
            - $ref: "#/components/schemas/ProblemTitle"
          example: Not Found
        status:
          allOf:
            - $ref: "#/components/schemas/HttpStatus"
          example: 404
        detail:
          $ref: "#/components/schemas/ProblemDetailText"
        instance:
          allOf:
            - $ref: "#/components/schemas/ProblemInstance"
          example: /api/v1/novels/unknown
        code:
          $ref: "#/components/schemas/ErrorCode"
        timestamp:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          description: When the error was produced, in UTC.
        errors:
          type: array
          description: Field-level details; present when `code` is `VALIDATION_FAILED`.
          items:
            $ref: "#/components/schemas/ValidationError"

  parameters:

    NovelIdParam:
      name: novelId
      in: path
      required: true
      description: Stable identifier of the novel.
      schema:
        $ref: "#/components/schemas/Uuid"

    NovelSlugParam:
      name: novelSlug
      in: path
      required: true
      description: URL-friendly identifier of the novel, e.g. `super-gene`. See `Novel.slug`.
      schema:
        type: string
        pattern: '^[a-z0-9]+(-[a-z0-9]+)*$'
        maxLength: 220

    ChapterNumberFilterParam:
      name: number
      in: query
      required: false
      description: >-
        Filter by 1-based reading-order position within the novel. An unknown or hidden
        number yields an empty page, not a 404.
      schema:
        type: integer
        format: int32
        minimum: 1

    ChapterIdParam:
      name: chapterId
      in: path
      required: true
      description: >-
        Stable identifier of the chapter, the same value as `Chapter.id`. Used by every
        chapter route instead of `chapterNumber`, which is a display position, not an
        identifier.
      schema:
        $ref: "#/components/schemas/Uuid"

    PartNumberParam:
      name: partNumber
      in: path
      required: true
      description: >-
        1-based position of the page within its chapter, the same value as
        `ChapterPart.partNumber`. Pages are recomputed wholesale on every content edit
        of the chapter, so this number is a best-effort position, not a stable anchor.
      schema:
        type: integer
        format: int32
        minimum: 1

    PageParam:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 0
        default: 0

    SizeParam:
      name: size
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    GenreParam:
      name: genre
      in: query
      required: false
      description: Exact, case-insensitive match against one of the novel's genres.
      schema:
        type: string

    NovelStatusParam:
      name: status
      in: query
      required: false
      schema:
        $ref: "#/components/schemas/PublicNovelStatus"

    AgeRatingParam:
      name: ageRating
      in: query
      required: false
      schema:
        $ref: "#/components/schemas/AgeRating"

    AuthorIdParam:
      name: authorId
      in: query
      required: false
      description: Restrict the catalog to novels this user is an assigned author of.
      schema:
        $ref: "#/components/schemas/Uuid"

    PeriodParam:
      name: period
      in: query
      required: true
      description: The trailing window to rank novels by read count over.
      schema:
        $ref: "#/components/schemas/RecentWindow"

    PublishedWithinParam:
      name: publishedWithin
      in: query
      required: false
      description: >-
        Restrict the ranking to novels first published within this trailing window,
        independent of `period`.
      schema:
        $ref: "#/components/schemas/RecentWindow"

    LimitParam:
      name: limit
      in: query
      required: false
      description: Maximum number of ranking entries to return.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    SearchParam:
      name: search
      in: query
      required: false
      description: Case-insensitive substring match against the novel's title.
      schema:
        type: string
        minLength: 1
        maxLength: 200

    TagsParam:
      name: tags
      in: query
      required: false
      description: >-
        Restrict the catalog to novels carrying at least one of these tags
        (case-insensitive exact match). Repeat the parameter for several tags
        (`?tags=Slow%20burn&tags=Heist`).
      style: form
      explode: true
      schema:
        type: array
        maxItems: 20
        items:
          $ref: "#/components/schemas/Tag"

    TagLimitParam:
      name: limit
      in: query
      required: false
      description: Maximum number of tags to return.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50

    LibrarySortParam:
      name: sort
      in: query
      required: false
      description: Order of the library listing.
      schema:
        $ref: "#/components/schemas/LibrarySort"

    UserSearchParam:
      name: username
      in: query
      required: false
      description: Case-insensitive substring match against a user's username.
      schema:
        type: string
        minLength: 1
        maxLength: 50

    UsernameFilterParam:
      name: username
      in: query
      required: true
      description: >-
        Case-insensitive substring match against a user's username. At least 2
        non-blank characters.
      schema:
        type: string
        minLength: 2
        maxLength: 50

    RoleFilterParam:
      name: role
      in: query
      required: false
      description: Restrict the admin user listing to accounts holding this role.
      schema:
        $ref: "#/components/schemas/RoleName"

    EnabledFilterParam:
      name: enabled
      in: query
      required: false
      description: Restrict the admin user listing to enabled or disabled accounts.
      schema:
        type: boolean

    ReportStatusParam:
      name: status
      in: query
      required: false
      description: Restrict the listing to reports in this moderation status. Defaults to `OPEN`.
      schema:
        $ref: "#/components/schemas/ReportStatus"

    ReadingStatusParam:
      name: readingStatus
      in: query
      required: false
      description: Restrict the library listing to entries with this exact reading status.
      schema:
        $ref: "#/components/schemas/ReadingStatus"

  headers:

    Location:
      description: >-
        URI of the resource just created, as a path relative to the server root
        (`/api/v1/...`). Only sent where the created resource has its own `GET` route.
      schema:
        type: string
        format: uri-reference
    WwwAuthenticate:
      description: The authentication scheme the API expects, always `Bearer`.
      schema:
        type: string
        example: Bearer
    RetryAfter:
      description: Seconds to wait before the client sends another request.
      schema:
        type: integer
        format: int32
        minimum: 0

  responses:

    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    Unauthorized:
      description: >-
        The request carried no valid credentials, or they were rejected. Public
        (`security: []`) read routes never answer 401: they treat a missing or invalid
        bearer token as an anonymous caller.
      headers:
        WWW-Authenticate:
          $ref: "#/components/headers/WwwAuthenticate"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    Forbidden:
      description: >-
        The caller is authenticated but lacks the permission this operation requires.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    NotFound:
      description: The requested resource does not exist.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    Conflict:
      description: >-
        The request collides with existing data, e.g. an e-mail address or username that
        is already taken.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    TooManyRequests:
      description: >-
        The caller has sent too many requests in a given time; retry after the delay in
        `Retry-After`. Enforced per client address by the application: strictly on login,
        register and refresh, loosely on everything else. Declared on every operation
        because the loose limit can hit any of them.
      headers:
        Retry-After:
          $ref: "#/components/headers/RetryAfter"
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"

    InternalServerError:
      description: An unexpected internal error occurred.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetail"
