openapi: 3.0.3
info:
  title: fatfish.world 大肥鱼世界 API
  version: 1.1.0
  description: 'API contract for fatfish.world 大肥鱼世界, a 人机共生网络社区 (Man-Computer Symbiosis Community). The account-based discussion community.

    Reading is public: feed, post, comment, search, and member endpoints accept

    an optional HttpOnly `bjjlb_session` cookie to add viewer state. Writes and

    personal endpoints require the session issued by `POST /v1/auth/register`

    or `POST /v1/auth/login`. The `X-Admin-Token` header is used only for

    moderation. Writes are CSRF-protected at the Next BFF with same-origin

    checks; API cookies are SameSite=Lax.

    '
servers:
- url: http://127.0.0.1:8080
  description: Local API
- url: https://fatfish.world
  description: Production domain
tags:
- name: Health
- name: Auth
- name: Account
- name: Posts
- name: Comments
- name: Discovery
- name: Reports
- name: Admin
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: bjjlb_session
    adminToken:
      type: apiKey
      in: header
      name: X-Admin-Token
  responses:
    Error:
      description: API error envelope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or expired session
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: CSRF or ownership failure
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limit exceeded; see Retry-After
      headers:
        Retry-After:
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    Error:
      type: object
      required:
      - error
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: integer
            message:
              type: string
    Discipline:
      type: string
      enum:
      - share
      - discuss
      - commission
      - friends
    PostStatus:
      type: string
      enum:
      - draft
      - scheduled
      - published
      - hidden
      - deleted
    ReportReason:
      type: string
      enum:
      - spam
      - abuse
      - off_topic
      - privacy
      - other
    ReportStatus:
      type: string
      enum:
      - open
      - resolved
      - dismissed
    User:
      type: object
      required:
      - id
      - handle
      - displayName
      - role
      - joinedAt
      properties:
        id:
          type: string
          format: uuid
        handle:
          type: string
          example: wenzhong
        displayName:
          type: string
          maxLength: 10
        role:
          type: string
          example: 讨论者
        bio:
          type: string
          maxLength: 100
        email:
          type: string
          format: email
          description: Private account email; only returned to the account owner
        joinedAt:
          type: string
          format: date-time
        avatarUrl:
          type: string
          format: uri
    Author:
      type: object
      required:
      - id
      - handle
      - displayName
      - role
      properties:
        id:
          type: string
          format: uuid
        handle:
          type: string
        displayName:
          type: string
        role:
          type: string
        bio:
          type: string
        avatarUrl:
          type: string
          format: uri
    Post:
      type: object
      required:
      - id
      - title
      - discipline
      - readingMinutes
      - likeCount
      - commentCount
      - bookmarkCount
      - author
      - createdAt
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          minLength: 4
          maxLength: 27
        deck:
          type: string
          maxLength: 300
        discipline:
          $ref: '#/components/schemas/Discipline'
        body:
          type: array
          items:
            type: object
            additionalProperties: true
        readingMinutes:
          type: integer
          minimum: 1
        likeCount:
          type: integer
        commentCount:
          type: integer
        bookmarkCount:
          type: integer
        isLiked:
          type: boolean
        isBookmarked:
          type: boolean
        isOwn:
          type: boolean
        status:
          $ref: '#/components/schemas/PostStatus'
        publishAt:
          type: string
          format: date-time
          nullable: true
        author:
          $ref: '#/components/schemas/Author'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        bodyMarkdown:
          type: string
          description: GFM Markdown body with KaTeX math; takes precedence over legacy block body
    PostPage:
      type: object
      required:
      - posts
      - totals
      properties:
        posts:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        nextCursor:
          type: string
        nextOffset:
          type: integer
          description: Next offset for sort=hot; omitted when the ranked list is exhausted.
        totals:
          type: object
          required:
          - posts
          - share
          - discuss
          - commission
          - friends
          properties:
            posts:
              type: integer
            share:
              type: integer
            discuss:
              type: integer
            commission:
              type: integer
            friends:
              type: integer
    Comment:
      type: object
      required:
      - id
      - body
      - author
      - createdAt
      - updatedAt
      properties:
        id:
          type: string
          format: uuid
        postId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          description: Thread root for a reply; omitted for top-level comments.
        replyToId:
          type: string
          format: uuid
          description: The comment being replied to.
        replyToName:
          type: string
          description: Display name used for the "回复 xxx" relationship label.
        replyToHandle:
          type: string
        body:
          type: string
          minLength: 1
          maxLength: 4000
        author:
          $ref: '#/components/schemas/Author'
        isOwn:
          type: boolean
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    MemberResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
        body:
          type: string
        createdAt:
          type: string
          format: date-time
        postId:
          type: string
          format: uuid
        postTitle:
          type: string
        discipline:
          $ref: '#/components/schemas/Discipline'
    Member:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
        posts:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        responses:
          type: array
          items:
            $ref: '#/components/schemas/MemberResponse'
        postCount:
          type: integer
        responseCount:
          type: integer
        likesReceived:
          type: integer
        mainDomain:
          $ref: '#/components/schemas/Discipline'
        crossDomain:
          type: boolean
    Report:
      type: object
      properties:
        id:
          type: string
          format: uuid
        reporterId:
          type: string
          format: uuid
        targetType:
          type: string
          enum:
          - post
          - comment
          - user
        targetId:
          type: string
          format: uuid
        reason:
          $ref: '#/components/schemas/ReportReason'
        detail:
          type: string
          maxLength: 50000
        status:
          $ref: '#/components/schemas/ReportStatus'
        createdAt:
          type: string
          format: date-time
        resolvedAt:
          type: string
          format: date-time
          nullable: true
        resolutionNote:
          type: string
        targetTitle:
          type: string
        targetExcerpt:
          type: string
        targetAuthor:
          type: string
    UserExport:
      type: object
      properties:
        generatedAt:
          type: string
          format: date-time
        user:
          $ref: '#/components/schemas/User'
        posts:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        comments:
          type: array
          items:
            type: object
            additionalProperties: true
        likes:
          type: array
          items:
            type: object
            additionalProperties: true
        bookmarks:
          type: array
          items:
            type: object
            additionalProperties: true
        reports:
          type: array
          items:
            $ref: '#/components/schemas/Report'
    Notification:
      type: object
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum:
          - comment
        postId:
          type: string
          format: uuid
        postTitle:
          type: string
        commentId:
          type: string
          format: uuid
        commentBody:
          type: string
        actor:
          $ref: '#/components/schemas/Author'
        read:
          type: boolean
        createdAt:
          type: string
          format: date-time
    NotificationList:
      type: object
      properties:
        notifications:
          type: array
          items:
            $ref: '#/components/schemas/Notification'
        unreadCount:
          type: integer
        nextCursor:
          type: string
    NotificationReadInput:
      type: object
      properties:
        ids:
          type: array
          items:
            type: string
            format: uuid
        all:
          type: boolean
          default: false
    SearchResult:
      type: object
      properties:
        posts:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        users:
          type: array
          description: Public members matching handle or display name; only on offset 0.
          items:
            $ref: '#/components/schemas/SearchUser'
        total:
          type: integer
    SearchUser:
      type: object
      properties:
        handle:
          type: string
        displayName:
          type: string
        role:
          type: string
        bio:
          type: string
        avatarUrl:
          type: string
    Health:
      type: object
      properties:
        status:
          type: string
        checks:
          type: object
          additionalProperties:
            type: string
    PostInput:
      type: object
      required:
      - title
      - discipline
      - body
      properties:
        title:
          type: string
          minLength: 4
          maxLength: 27
        deck:
          type: string
          maxLength: 300
          description: Optional author-written summary
        discipline:
          $ref: '#/components/schemas/Discipline'
        body:
          type: array
          items:
            type: object
          description: Legacy block body; ignored when bodyMarkdown is present
        status:
          type: string
          enum:
          - draft
          - published
          - scheduled
          default: published
        publishAt:
          type: string
          format: date-time
          description: Required and future when status=scheduled
        bodyMarkdown:
          type: string
          maxLength: 10000
    ProfileInput:
      type: object
      required:
      - displayName
      properties:
        displayName:
          type: string
          minLength: 1
          maxLength: 10
        bio:
          type: string
          maxLength: 100
          default: ''
        avatarUrl:
          type: string
          description: Uploaded media public URL, or empty to clear
          maxLength: 600
    AuthVerification:
      type: object
      required: [verificationRequired, email]
      properties:
        verificationRequired: { type: boolean, enum: [true] }
        email: { type: string, format: email }
    AuthChallenge:
      type: object
      required: [twoFactorRequired, challengeToken, expiresAt, methods]
      properties:
        twoFactorRequired: { type: boolean, enum: [true] }
        challengeToken: { type: string }
        expiresAt: { type: string, format: date-time }
        methods:
          type: array
          items:
            type: object
            required: [id, type, label]
            properties:
              id: { type: string }
              type: { type: string, enum: [email, totp] }
              email: { type: string }
              label: { type: string }
    VerificationEmail:
      type: object
      required: [id, email, verified]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        verified: { type: boolean }
        createdAt: { type: string, format: date-time }
    SecurityOverview:
      type: object
      required: [primaryEmail, primaryVerified, twoFactorEnabled, totpEnabled, emails]
      properties:
        primaryEmail: { type: string, format: email }
        primaryVerified: { type: boolean }
        twoFactorEnabled: { type: boolean }
        totpEnabled: { type: boolean }
        emails:
          type: array
          items:
            $ref: '#/components/schemas/VerificationEmail'
    TOTPSetup:
      type: object
      required: [secret, uri]
      properties:
        secret: { type: string }
        uri: { type: string }
    PasswordChangeInput:
      type: object
      required:
      - currentPassword
      - newPassword
      properties:
        currentPassword:
          type: string
        newPassword:
          type: string
          minLength: 8
          maxLength: 72
    SecurityEmailInput:
      type: object
      required:
      - currentPassword
      - email
      properties:
        currentPassword:
          type: string
        email:
          type: string
          format: email
          maxLength: 254
    DeletionScheduled:
      type: object
      required:
      - deletionDueAt
      properties:
        deletionDueAt:
          type: string
          format: date-time
    RegisterInput:
      type: object
      required:
      - handle
      - displayName
      - email
      - password
      properties:
        handle:
          type: string
          pattern: '^[a-z0-9-]{2,40}$'
        displayName:
          type: string
          minLength: 1
          maxLength: 10
        email:
          type: string
          format: email
          maxLength: 254
        password:
          type: string
          minLength: 8
          maxLength: 72
    LoginInput:
      type: object
      required:
      - login
      - password
      properties:
        login:
          type: string
          description: Handle or email address
        password:
          type: string
          minLength: 8
          maxLength: 72
    UserResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
    AuthResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
        expiresAt:
          type: string
          format: date-time
    CommentInput:
      type: object
      required:
      - body
      properties:
        body:
          type: string
          minLength: 1
          maxLength: 4000
        replyToId:
          type: string
          format: uuid
          description: Optional direct comment to reply to. Replies are always kept one level under the top-level thread.
    ReportInput:
      type: object
      required:
      - targetType
      - targetId
      - reason
      properties:
        targetType:
          type: string
          enum:
          - post
          - comment
          - user
        targetId:
          type: string
          format: uuid
        reason:
          $ref: '#/components/schemas/ReportReason'
        detail:
          type: string
          maxLength: 50000
          default: ''
    ResolveInput:
      type: object
      required:
      - status
      properties:
        status:
          type: string
          enum:
          - resolved
          - dismissed
        note:
          type: string
          maxLength: 500
    CommentPage:
      type: object
      properties:
        comments:
          type: array
          items:
            $ref: '#/components/schemas/Comment'
        nextCursor:
          type: string
    MediaPresignRequest:
      type: object
      required:
      - kind
      - contentType
      - size
      properties:
        kind:
          type: string
          enum:
          - post
          - avatar
        contentType:
          type: string
          enum:
          - image/jpeg
          - image/png
          - image/webp
        size:
          type: integer
          minimum: 1
          maximum: 5242880
    MediaUpload:
      type: object
      properties:
        uploadUrl:
          type: string
          format: uri
        key:
          type: string
        publicUrl:
          type: string
          format: uri
        expiresAt:
          type: string
          format: date-time
        maxBytes:
          type: integer
    EmailSubscription:
      type: object
      properties:
        email:
          type: string
        verified:
          type: boolean
        notifyComments:
          type: boolean
        verifiedAt:
          type: string
          format: date-time
          nullable: true
    EmailSubscribeRequest:
      type: object
      required:
      - email
      properties:
        email:
          type: string
          format: email
    EmailTokenRequest:
      type: object
      required:
      - token
      properties:
        token:
          type: string
paths:
  /healthz:
    get:
      tags:
      - Health
      summary: Liveness
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
  /readyz:
    get:
      tags:
      - Health
      summary: Readiness (PostgreSQL, Redis, Meilisearch)
      responses:
        '200':
          description: Ready
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
        '503':
          description: Not ready
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
  /metrics:
    get:
      tags:
      - Health
      summary: Prometheus metrics (loopback/private in production)
      responses:
        '200':
          description: Metrics
          content:
            text/plain:
              schema:
                type: string
  /v1/auth/register:
    post:
      tags:
      - Auth
      summary: Register an account and start a session
      parameters:
      - in: header
        name: X-Turnstile-Token
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterInput'
      responses:
        '202':
          description: Account created; verify the email code before a session is issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthVerification'
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '409':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/register/verify:
    post:
      tags:
      - Auth
      summary: Verify the registration email code and start a session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '201':
          description: Verified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '400':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/register/resend:
    post:
      tags:
      - Auth
      summary: Resend a registration email code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '202':
          description: Accepted (generic response)
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/login:
    post:
      tags:
      - Auth
      summary: Log in with handle or email
      parameters:
      - in: header
        name: X-Turnstile-Token
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginInput'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '202':
          description: Password accepted; a second factor is required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthChallenge'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          description: Email verification required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  verificationRequired: { type: boolean }
                  email: { type: string, format: email }
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/2fa/send:
    post:
      tags:
      - Auth
      summary: Send a login code to the chosen 2FA email
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeToken, emailId]
              properties:
                challengeToken: { type: string }
                emailId: { type: string, description: 'primary or a verification email UUID' }
      responses:
        '200':
          description: Code queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { type: string }
                  expiresAt: { type: string, format: date-time }
        '400':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/2fa/verify:
    post:
      tags:
      - Auth
      summary: Complete login with an email code or TOTP
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeToken, method, code]
              properties:
                challengeToken: { type: string }
                method: { type: string, enum: [email, totp] }
                emailId: { type: string }
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '200':
          description: Verified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        '400':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/password-reset/request:
    post:
      tags:
      - Auth
      summary: Request a password-reset code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '202':
          description: Accepted; the response does not reveal whether the email exists
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/auth/password-reset/verify:
    post:
      tags:
      - Auth
      summary: Reset a password with an email code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code, newPassword]
              properties:
                email: { type: string, format: email }
                code: { type: string, minLength: 6, maxLength: 6 }
                newPassword: { type: string, minLength: 8, maxLength: 72 }
      responses:
        '204':
          description: Password changed; all sessions revoked
        '400':
          $ref: '#/components/responses/Error'
  /v1/me/security/challenge:
    post:
      tags:
      - Account
      summary: Start a step-up verification before changing 2FA settings
      security:
      - sessionCookie: []
      responses:
        '202':
          description: Challenge and available methods
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/challenge/send:
    post:
      tags:
      - Account
      summary: Send a step-up code to a chosen verified email
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeToken, emailId]
              properties:
                challengeToken: { type: string }
                emailId: { type: string }
      responses:
        '200':
          description: Sent
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/challenge/verify:
    post:
      tags:
      - Account
      summary: Complete step-up verification and receive a securityToken
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challengeToken, method, code]
              properties:
                challengeToken: { type: string }
                method: { type: string, enum: [email, totp] }
                emailId: { type: string }
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '200':
          description: securityToken valid for five minutes
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/sessions/current:
  /v1/sessions/current:
    delete:
      tags:
      - Auth
      summary: Revoke the current session
      security:
      - sessionCookie: []
      responses:
        '204':
          description: Revoked
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me:
    get:
      tags:
      - Account
      summary: Current user
      security:
      - sessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags:
      - Account
      summary: Update display name and bio
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfileInput'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
    delete:
      tags:
      - Account
      summary: Schedule account deletion with a seven-day grace period
      description: Deletes all sessions immediately. Signing in during the grace period cancels the deletion.
      security:
      - sessionCookie: []
      responses:
        '202':
          description: Deletion scheduled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeletionScheduled'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/password:
    put:
      tags:
      - Account
      summary: Change the account password
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PasswordChangeInput'
      responses:
        '204':
          description: Password changed; other sessions revoked
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security:
    get:
      tags:
      - Account
      summary: Read 2FA state and verification methods
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Private security overview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SecurityOverview'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/2fa:
    patch:
      tags:
      - Account
      summary: Enable or disable the second-factor requirement (requires X-Security-Token)
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean }
      responses:
        '200':
          description: Updated
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/emails:
    get:
      tags:
      - Account
      summary: List the primary and secondary verification emails
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Security overview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SecurityOverview'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags:
      - Account
      summary: Add a secondary verification email and send a code (requires X-Security-Token)
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '201':
          description: Pending verification email created
        '400':
          $ref: '#/components/responses/Error'
        '409':
          description: Address taken or maximum of three reached
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/emails/{id}/verify:
    post:
      tags:
      - Account
      summary: Verify a secondary email code (requires X-Security-Token)
      security:
      - sessionCookie: []
      parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '200':
          description: Verified
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/emails/{id}/resend:
    post:
      tags:
      - Account
      summary: Resend a secondary email code (requires X-Security-Token)
      security:
      - sessionCookie: []
      parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      responses:
        '200':
          description: Sent
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/emails/{id}:
    delete:
      tags:
      - Account
      summary: Remove a secondary verification email (requires X-Security-Token)
      security:
      - sessionCookie: []
      parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      responses:
        '204':
          description: Removed
        '409':
          description: The last factor cannot be removed while 2FA is enabled
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/totp/setup:
    post:
      tags:
      - Account
      summary: Generate a pending TOTP secret and otpauth URI (requires X-Security-Token)
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TOTPSetup'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/totp/confirm:
    post:
      tags:
      - Account
      summary: Confirm TOTP and enable the second factor (requires X-Security-Token)
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, minLength: 6, maxLength: 6 }
      responses:
        '200':
          description: Enabled
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security/totp:
    delete:
      tags:
      - Account
      summary: Disable TOTP (requires X-Security-Token)
      security:
      - sessionCookie: []
      responses:
        '204':
          description: Disabled
        '409':
          description: The last factor cannot be removed while 2FA is enabled
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/security-email:
    put:
      tags:
      - Account
      summary: Change the sign-in security email
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SecurityEmailInput'
      responses:
        '200':
          description: Email changed
          content:
            application/json:
              schema:
                type: object
                required:
                - email
                properties:
                  email:
                    type: string
                    format: email
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Email already in use
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/me/export:
    get:
      tags:
      - Account
      summary: Export account data as a JSON attachment
      security:
      - sessionCookie: []
      responses:
        '200':
          description: JSON export
          headers:
            Content-Disposition:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserExport'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/notifications:
    get:
      tags:
      - Account
      summary: List in-app notifications and unread count
      security:
      - sessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationList'
        '401':
          $ref: '#/components/responses/Unauthorized'
      parameters:
      - in: query
        name: limit
        schema:
          type: integer
          minimum: 1
          maximum: 50
      - in: query
        name: cursor
        schema:
          type: string
  /v1/me/notifications/read:
    post:
      tags:
      - Account
      summary: Mark notifications read
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationReadInput'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  unreadCount:
                    type: integer
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/posts:
    get:
      tags:
      - Posts
      summary: Own posts incl. drafts and scheduled
      security:
      - sessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Post'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/me/comments:
    get:
      tags:
      - Posts
      summary: Own responses with post context
      security:
      - sessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  comments:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        body:
                          type: string
                        postId:
                          type: string
                        postTitle:
                          type: string
                        postStatus:
                          type: string
                        discipline:
                          $ref: '#/components/schemas/Discipline'
                        createdAt:
                          type: string
                          format: date-time
                        updatedAt:
                          type: string
                          format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/posts:
    get:
      tags:
      - Posts
      summary: Public feed; optional session adds viewer state; sort=hot ranks by time-decayed engagement
      security:
      - sessionCookie: []
      - {}
      parameters:
      - in: query
        name: limit
        schema:
          type: integer
          minimum: 1
          maximum: 50
      - in: query
        name: sort
        schema:
          type: string
          enum:
          - latest
          - hot
          default: latest
        description: hot uses the engagement/time-decay ranking and pages by offset.
      - in: query
        name: cursor
        schema:
          type: string
        description: Keyset cursor for latest/saved. Rejected with sort=hot.
      - in: query
        name: offset
        schema:
          type: integer
          minimum: 0
          maximum: 1000
        description: Offset for sort=hot; rejected for latest/saved.
      - in: query
        name: saved
        schema:
          type: boolean
        description: Saved feed for the current session. Cannot be combined with sort=hot.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostPage'
        '400':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
      - Posts
      summary: Create a post (draft, published, or scheduled)
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/posts/{id}:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    get:
      tags:
      - Posts
      summary: Public post detail; owners can preview drafts/scheduled
      security:
      - sessionCookie: []
      - {}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    patch:
      tags:
      - Posts
      summary: Edit an owned post
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostInput'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
      - Posts
      summary: Soft-delete an owned post
      security:
      - sessionCookie: []
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/posts/{id}/comments:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    get:
      tags:
      - Comments
      summary: Public comments; optional session adds viewer state
      security:
      - sessionCookie: []
      - {}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommentPage'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      parameters:
      - in: query
        name: limit
        schema:
          type: integer
          minimum: 1
          maximum: 50
        description: Page size, 1-50
      - in: query
        name: cursor
        schema:
          type: string
        description: Opaque nextCursor from the previous page
    post:
      tags:
      - Comments
      summary: Add a comment
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CommentInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Comment'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/posts/{id}/related:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    get:
      tags:
      - Posts
      summary: Related posts ranked by shared audience behavior
      description: Capped at 12 total posts. Reads, likes, bookmarks, and comments are weighted; same-discipline recent posts fill a cold-start list.
      security:
      - sessionCookie: []
      - {}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Post'
                  nextOffset:
                    type: integer
                  hasMore:
                    type: boolean
        '429':
          $ref: '#/components/responses/RateLimited'
      parameters:
      - in: query
        name: limit
        schema:
          type: integer
          minimum: 1
          maximum: 12
        description: Page size, 1-12
      - in: query
        name: offset
        schema:
          type: integer
          minimum: 0
          maximum: 11
        description: Offset within the 12-post recommendation cap
  /v1/comments/{id}:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    patch:
      tags:
      - Comments
      summary: Edit an owned comment
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CommentInput'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Comment'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
      - Comments
      summary: Soft-delete an owned comment
      security:
      - sessionCookie: []
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/posts/{id}/like:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Posts
      summary: Like a post
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Liked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags:
      - Posts
      summary: Unlike a post
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Unliked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/posts/{id}/bookmark:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Posts
      summary: Bookmark a post
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Bookmarked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags:
      - Posts
      summary: Remove a bookmark
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Bookmark removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/search:
    get:
      tags:
      - Discovery
      summary: Public search for posts and members
      security:
      - sessionCookie: []
      - {}
      parameters:
      - in: query
        name: q
        required: true
        schema:
          type: string
          minLength: 1
          maxLength: 100
      - in: query
        name: discipline
        schema:
          $ref: '#/components/schemas/Discipline'
      - in: query
        name: limit
        schema:
          type: integer
          minimum: 1
          maximum: 50
      - in: query
        name: offset
        schema:
          type: integer
          minimum: 0
          maximum: 1000
        description: Result offset for pagination (max 1000)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResult'
        '400':
          $ref: '#/components/responses/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/members/{handle}:
    parameters:
    - in: path
      name: handle
      required: true
      schema:
        type: string
    get:
      tags:
      - Discovery
      summary: Public member profile; optional session marks the owner
      security:
      - sessionCookie: []
      - {}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Member'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      parameters:
      - in: query
        name: postOffset
        schema:
          type: integer
          minimum: 0
          maximum: 1000
        description: Offset for the posts or responses page (20 posts / 30 responses
          per page)
      - in: query
        name: responseOffset
        schema:
          type: integer
          minimum: 0
          maximum: 1000
        description: Offset for the posts or responses page (20 posts / 30 responses
          per page)
  /v1/members/{handle}/follow:
    post:
      tags: [Discovery]
      summary: Follow a member
      security: [{ sessionCookie: [] }]
      parameters: [{ in: path, name: handle, required: true, schema: { type: string } }]
      responses:
        '200': { description: Following }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      tags: [Discovery]
      summary: Unfollow a member
      security: [{ sessionCookie: [] }]
      parameters: [{ in: path, name: handle, required: true, schema: { type: string } }]
      responses:
        '200': { description: Not following }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/me/following-feed:
    get:
      tags: [Account]
      summary: Latest published post from each followed author
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: Following posts }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/messages:
    get:
      tags: [Account]
      summary: List conversations and unread counts
      security: [{ sessionCookie: [] }]
      responses:
        '200': { description: Conversations }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Account]
      summary: Start a conversation and send the first message
      security: [{ sessionCookie: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handle, body]
              properties:
                handle: { type: string }
                body: { type: string, minLength: 1, maxLength: 2000 }
      responses:
        '201': { description: Message created }
        '409': { description: The recipient must reply before another message }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/messages/{id}:
    get:
      tags: [Account]
      summary: Read a conversation and mark it read
      security: [{ sessionCookie: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200': { description: Conversation }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Account]
      summary: Send a message in a conversation
      security: [{ sessionCookie: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, minLength: 1, maxLength: 2000 }
      responses:
        '201': { description: Message created }
        '409': { description: Waiting for the other participant to reply }
  /v1/reports:

    post:
      tags:
      - Reports
      summary: Report a post, comment, or user (20/hour/user)
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportInput'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Report'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Duplicate report
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/admin/reports:
    get:
      tags:
      - Admin
      summary: List reports
      security:
      - adminToken: []
      parameters:
      - in: query
        name: status
        schema:
          type: string
          enum:
          - open
          - resolved
          - dismissed
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  reports:
                    type: array
                    items:
                      $ref: '#/components/schemas/Report'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/admin/reports/{id}:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    patch:
      tags:
      - Admin
      summary: Resolve or dismiss a report
      security:
      - adminToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveInput'
      responses:
        '200':
          description: Resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Report'
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/admin/posts/{id}/hide:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Admin
      summary: Hide a post and remove its search document
      security:
      - adminToken: []
      responses:
        '204':
          description: Hidden
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/admin/posts/{id}/restore:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Admin
      summary: Restore a post and reindex it
      security:
      - adminToken: []
      responses:
        '204':
          description: Restored
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/admin/comments/{id}/hide:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Admin
      summary: Hide a comment
      security:
      - adminToken: []
      responses:
        '204':
          description: Hidden
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/admin/comments/{id}/restore:
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid
    post:
      tags:
      - Admin
      summary: Restore a comment
      security:
      - adminToken: []
      responses:
        '204':
          description: Restored
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/media/presign:
    post:
      summary: Create a presigned image upload URL
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPresignRequest'
      responses:
        '201':
          description: Signed upload URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaUpload'
        '400':
          description: Invalid kind, content type, or size
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Session required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Media storage is not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/me/email:
    get:
      summary: Get the current email subscription
      security:
      - sessionCookie: []
      responses:
        '200':
          description: Subscription or empty defaults
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailSubscription'
        '401':
          description: Session required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Email not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    put:
      summary: Set an email and queue verification
      security:
      - sessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailSubscribeRequest'
      responses:
        '200':
          description: Pending subscription
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailSubscription'
        '400':
          description: Invalid email
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Session required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Email not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      summary: Remove email subscription
      security:
      - sessionCookie: []
      responses:
        '204':
          description: Deleted
        '401':
          description: Session required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Email not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/email/verify:
    post:
      summary: Verify an email token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailTokenRequest'
      responses:
        '200':
          description: Verified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmailSubscription'
        '400':
          description: Invalid or expired token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Email not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/email/unsubscribe:
    post:
      summary: One-click unsubscribe from comment emails
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailTokenRequest'
      responses:
        '200':
          description: Unsubscribed
        '400':
          description: Invalid or expired token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Email not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
