openapi: 3.1.0
info:
  title: moyro MM Compatibility API
  version: 0.2.52
  description: |
    moyro v0.2.52의 Mattermost v4 호환 API 계약. 필드명과 경로는 Mattermost REST
    API v4와 최대한 일치시켜 기존 클라이언트·봇·플러그인의 이식성을 높인다.
    이 문서는 구현된 핵심 workflow를 명세하며 전체 route-shape 목록은
    mattermost-api-compatibility.md에서 별도로 관리한다. Moyro-native 관리·AI·MCP
    surface는 `/api/moyro/v1` 경계에 있고 이 호환 명세에 포함하지 않는다.
servers:
  - url: /api/v4
    description: 현재 Moyro 배포 origin의 Mattermost v4 호환 API
security:
  - bearerAuth: []
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Error:
      type: object
      required: [id, message, status_code]
      properties:
        id: { type: string }
        message: { type: string }
        detailed_error: { type: string }
        request_id: { type: string }
        status_code: { type: integer }
    User:
      type: object
      additionalProperties: false
      required: [id, username, email, roles, picture, guest_file_download]
      properties:
        id: { type: string, format: uuid }
        username: { type: string }
        email: { type: string, format: email }
        roles: { type: string }
        picture: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        nickname: { type: string }
        position: { type: string }
        guest_expires_at:
          type: integer
          format: int64
          minimum: 1
          description: 제한 게스트 계정의 Unix 밀리초 만료 시각. 일반 사용자는 생략된다.
        guest_file_download:
          type: boolean
          description: 제한 게스트가 원본 파일 바이트를 다운로드할 수 있는지 여부.
        delete_at: { type: integer, format: int64, minimum: 1 }
    RegisterUserRequest:
      type: object
      additionalProperties: false
      required: [username, email, password]
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 64
          description: 공백을 제거하고 소문자로 정규화한 뒤 ASCII 사용자 이름 규칙을 적용한다.
        email: { type: string, format: email, maxLength: 254 }
        password:
          type: string
          format: password
          description: UTF-8 기준 12–72바이트여야 한다.
        invite_id:
          type: string
          maxLength: 128
          description: 선택한 member 또는 restricted guest 초대를 원자적으로 소비한다.
    InviteKind:
      type: string
      enum: [member, guest]
    CreateTeamInvite:
      type: object
      additionalProperties: false
      properties:
        max_uses:
          type: integer
          maximum: 10000
          default: 0
          description: 0 이하 값은 만료 전까지 무제한 사용으로 정규화된다.
        ttl_seconds:
          type: integer
          maximum: 31536000
          default: 604800
          description: 0 이하 값은 기본 7일로 정규화된다.
        kind: { $ref: "#/components/schemas/InviteKind" }
        channel_ids:
          type: array
          maxItems: 100
          uniqueItems: true
          items: { type: string, minLength: 1, maxLength: 128 }
        guest_expires_after_seconds:
          type: integer
          format: int64
          minimum: 0
          maximum: 31536000
        guest_file_download: { type: boolean, default: false }
      allOf:
        - if:
            properties:
              kind: { const: guest }
            required: [kind]
          then:
            required: [channel_ids, guest_expires_after_seconds]
            properties:
              channel_ids: { type: array, minItems: 1, maxItems: 100 }
              guest_expires_after_seconds:
                type: integer
                format: int64
                minimum: 3600
                maximum: 31536000
    TeamInvite:
      type: object
      additionalProperties: false
      required:
        - id
        - team_id
        - created_by
        - max_uses
        - use_count
        - expires_at
        - create_at
        - url
        - kind
        - channel_ids
        - guest_expires_after_seconds
        - guest_file_download
      properties:
        id: { type: string }
        team_id: { type: string }
        created_by: { type: string }
        max_uses: { type: integer, minimum: 0, maximum: 10000 }
        use_count: { type: integer, minimum: 0 }
        expires_at: { type: integer, format: int64, minimum: 1 }
        create_at: { type: integer, format: int64, minimum: 1 }
        url: { type: string }
        kind: { $ref: "#/components/schemas/InviteKind" }
        channel_ids:
          type: array
          maxItems: 100
          items: { type: string }
        guest_expires_after_seconds: { type: integer, format: int64, minimum: 0, maximum: 31536000 }
        guest_file_download: { type: boolean }
    PublicTeamInvite:
      type: object
      additionalProperties: false
      required:
        - id
        - team_id
        - team_display_name
        - team_name
        - expires_at
        - kind
        - channel_ids
        - guest_expires_after_seconds
        - guest_file_download
      properties:
        id: { type: string }
        team_id: { type: string }
        team_display_name: { type: string }
        team_name: { type: string }
        expires_at: { type: integer, format: int64, minimum: 1 }
        kind: { $ref: "#/components/schemas/InviteKind" }
        channel_ids:
          type: array
          maxItems: 100
          items: { type: string }
        guest_expires_after_seconds: { type: integer, format: int64, minimum: 0, maximum: 31536000 }
        guest_file_download: { type: boolean }
    LoginRequest:
      type: object
      required: [login_id, password]
      properties:
        login_id: { type: string }
        password: { type: string }
    LoginResponse:
      type: object
      properties:
        token: { type: string }
        user: { $ref: "#/components/schemas/User" }
    Team:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        display_name: { type: string }
        type: { type: string, enum: [O, I] }
        create_at: { type: integer, format: int64 }
        update_at: { type: integer, format: int64 }
        delete_at: { type: integer, format: int64 }
    Channel:
      type: object
      properties:
        id: { type: string }
        team_id: { type: string }
        type: { type: string, enum: [O, P, D, G] }
        display_name: { type: string }
        name: { type: string }
        header: { type: string }
        purpose: { type: string }
        create_at: { type: integer, format: int64 }
        update_at: { type: integer, format: int64 }
        delete_at: { type: integer, format: int64 }
    Post:
      type: object
      properties:
        id: { type: string }
        channel_id: { type: string }
        user_id: { type: string }
        root_id: { type: string }
        message: { type: string }
        props: { type: object, additionalProperties: true }
        file_ids:
          type: array
          items: { type: string }
        create_at: { type: integer, format: int64 }
        update_at: { type: integer, format: int64 }
        delete_at: { type: integer, format: int64 }
    PostList:
      type: object
      properties:
        order:
          type: array
          items: { type: string }
        posts:
          type: object
          additionalProperties: { $ref: "#/components/schemas/Post" }
    CreateTeam:
      type: object
      required: [name, display_name]
      properties:
        name: { type: string }
        display_name: { type: string }
        type: { type: string, default: O }
    CreateChannel:
      type: object
      required: [team_id, name, display_name]
      properties:
        team_id: { type: string }
        name: { type: string }
        display_name: { type: string }
        type: { type: string, default: O }
    CreatePost:
      type: object
      required: [channel_id, message]
      properties:
        channel_id: { type: string }
        message: { type: string }
        root_id: { type: string }
        props: { type: object, additionalProperties: true }
        file_ids:
          type: array
          items: { type: string }
    UpdatePost:
      type: object
      properties:
        id: { type: string }
        message: { type: string }
        props: { type: object, additionalProperties: true }
    Reaction:
      type: object
      required: [user_id, post_id, emoji_name]
      properties:
        user_id: { type: string }
        post_id: { type: string }
        emoji_name: { type: string }
        create_at: { type: integer, format: int64 }
    FileInfo:
      type: object
      properties:
        id: { type: string }
        user_id: { type: string }
        post_id: { type: string }
        channel_id: { type: string }
        name: { type: string }
        extension: { type: string }
        size: { type: integer, format: int64 }
        mime_type: { type: string }
        create_at: { type: integer, format: int64 }
        update_at: { type: integer, format: int64 }
        delete_at: { type: integer, format: int64 }
    ChannelMember:
      type: object
      properties:
        channel_id: { type: string }
        user_id: { type: string }
        roles: { type: string }
        last_viewed_at: { type: integer, format: int64 }
        create_at: { type: integer, format: int64 }
    SearchPostsRequest:
      type: object
      required: [terms]
      properties:
        terms: { type: string }
        is_or_search: { type: boolean }
        per_page: { type: integer, default: 60 }
        page: { type: integer, default: 0 }
    Session:
      type: object
      description: 안전한 세션 관리 표현. JWT 원문과 jti 해시는 응답하지 않는다.
      required: [id, user_id, device_id, expires_at, create_at, is_current]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        device_id: { type: string }
        expires_at: { type: integer, format: int64 }
        create_at: { type: integer, format: int64 }
        is_current: { type: boolean }
    CreateScheduledPost:
      type: object
      required: [channel_id, send_at]
      anyOf:
        - required: [message]
        - required: [file_ids]
      properties:
        channel_id: { type: string }
        root_id: { type: string }
        message: { type: string }
        file_ids:
          type: array
          items: { type: string }
        props: { type: object, additionalProperties: true }
        send_at:
          type: integer
          format: int64
          description: 예약 발송 시각(ms Unix epoch)
    ScheduledPost:
      type: object
      required: [id, user_id, channel_id, message, file_ids, props, send_at, create_at, status, attempt_count]
      properties:
        id: { type: string, format: uuid }
        user_id: { type: string, format: uuid }
        channel_id: { type: string, format: uuid }
        root_id: { type: string }
        message: { type: string }
        file_ids:
          type: array
          items: { type: string }
        props: { type: object, additionalProperties: true }
        send_at: { type: integer, format: int64 }
        create_at: { type: integer, format: int64 }
        sent_at: { type: integer, format: int64 }
        error_text: { type: string }
        status:
          type: string
          enum: [pending, processing, succeeded, retry, dead, cancelled]
        claimed_at: { type: integer, format: int64 }
        lease_until: { type: integer, format: int64 }
        attempt_count: { type: integer, minimum: 0 }
        next_attempt_at: { type: integer, format: int64 }
        last_error_code: { type: string }
        last_error_text: { type: string }
        result_post_id: { type: string }
    Plugin:
      type: object
      required: [id, version, state, enabled, runtime, error, manifest]
      properties:
        id: { type: string }
        version: { type: string }
        state:
          type: string
          enum: [installed, running, failed]
        enabled: { type: boolean }
        runtime:
          type: string
          enum: [moyro_v1, mattermost_v1]
        error: { type: string }
        manifest:
          type: object
          additionalProperties: true
    PluginInstallResult:
      type: object
      required: [id, version, state, enabled, runtime, sha256, replaced]
      properties:
        id: { type: string }
        version: { type: string }
        state:
          type: string
          enum: [installed, running, failed]
        enabled: { type: boolean }
        runtime:
          type: string
          enum: [mattermost_v1]
        sha256:
          type: string
          pattern: "^[0-9a-f]{64}$"
        replaced: { type: boolean }
    PluginManagementCapabilities:
      type: object
      required: [management_enabled, uploads_enabled]
      properties:
        management_enabled: { type: boolean }
        uploads_enabled: { type: boolean }
    PluginStatus:
      type: object
      required: [plugin_id, state]
      properties:
        plugin_id: { type: string }
        state:
          type: string
          enum: [installed, running, failed]
    PluginConfiguration:
      type: object
      required: [configuration, schema]
      properties:
        configuration:
          type: object
          additionalProperties: true
        schema:
          type: object
          additionalProperties: true
    PluginConfigurationUpdate:
      type: object
      required: [configuration]
      properties:
        configuration:
          type: object
          additionalProperties: true
    PluginWebappBundle:
      type: object
      required: [id, version, url]
      properties:
        id: { type: string }
        version: { type: string }
        url:
          type: string
          pattern: "^/plugins/[A-Za-z0-9_.-]+/webapp\\.js$"
    StatusOK:
      type: object
      required: [status]
      properties:
        status: { type: string, const: OK }

paths:
  /system/ping:
    get:
      operationId: pingSystem
      security: []
      summary: Server liveness
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }

  /users:
    post:
      operationId: createUser
      security: []
      summary: Register a member or restricted guest account
      description: |
        Without `invite_id`, local signup must be enabled. With an invitation,
        invite consumption, user creation, account role, and invited team/channel
        memberships commit atomically; a race for the last use returns `400`
        without leaving a user row. Guest expiry and file-download policy come
        only from the validated invitation and are returned on the user object.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterUserRequest" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "400":
          description: Invalid registration input or invitation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Local registration is disabled and no invitation was supplied
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Username or email already exists
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Account could not be created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Registration service is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /invites/{inviteID}:
    parameters:
      - name: inviteID
        in: path
        required: true
        schema: { type: string, minLength: 1, maxLength: 128 }
    get:
      operationId: previewTeamInvite
      security: []
      summary: Preview a live member or restricted guest invitation
      description: Returns only signup-safe team and access metadata; creator and usage counters are not exposed.
      responses:
        "200":
          description: Live invitation preview
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicTeamInvite" }
        "404":
          description: Invitation is absent, revoked, expired, exhausted, or its team is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /teams/invite/{inviteID}:
    parameters:
      - name: inviteID
        in: path
        required: true
        schema: { type: string, minLength: 1, maxLength: 128 }
    get:
      operationId: previewTeamInviteAlias
      security: []
      summary: Preview a live invitation through the Mattermost-compatible alias
      responses:
        "200":
          description: Live invitation preview
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicTeamInvite" }
        "404":
          description: Invitation is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /users/login:
    post:
      operationId: loginUser
      security: []
      summary: Authenticate and receive a session token
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "201":
          description: Authenticated
          headers:
            Token: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }

  /users/me:
    get:
      operationId: getCurrentUser
      summary: Get the authenticated user
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }

  /users/me/sessions:
    get:
      operationId: listCurrentUserSessions
      summary: List the caller's active sessions without bearer token material
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Session" }
    delete:
      operationId: revokeOtherCurrentUserSessions
      summary: Revoke every session except the authenticated session
      responses:
        "200":
          description: Revocation count
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  revoked: { type: integer, format: int64 }

  /users/me/sessions/{sessionID}:
    parameters:
      - name: sessionID
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      operationId: revokeCurrentUserSession
      summary: Revoke one session owned by the caller
      responses:
        "200":
          description: Idempotent result
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  changed: { type: boolean }

  /teams:
    post:
      operationId: createTeam
      summary: Create a team
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTeam" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Team" }

  /teams/{teamID}/invites:
    parameters:
      - name: teamID
        in: path
        required: true
        schema: { type: string, minLength: 1, maxLength: 128 }
    get:
      operationId: listTeamInvites
      summary: List non-revoked member and restricted guest invitations
      description: Requires system administrator or administrator membership in the selected team.
      responses:
        "200":
          description: Invitations newest first; expired rows remain visible until revoked
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/TeamInvite" }
        "401":
          description: Authentication required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Team invitation administration is not allowed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Invitations could not be listed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    post:
      operationId: createTeamInvite
      summary: Create a member or channel-restricted guest invitation
      description: |
        Requires system administrator or administrator membership in the team.
        Guest invitations require one or more active public/private channels in
        this team and an access duration between one hour and one year. Member
        invitations ignore guest scope fields and always permit file downloads.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTeamInvite" }
      responses:
        "201":
          description: Invitation created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TeamInvite" }
        "400":
          description: Invalid body, lifetime, usage limit, or guest channel scope
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Authentication required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Team invitation administration is not allowed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Invitation could not be created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /users/me/teams:
    get:
      operationId: listCurrentUserTeams
      summary: Teams for the authenticated user
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Team" }

  /channels:
    post:
      operationId: createChannel
      summary: Create a channel
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateChannel" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channel" }

  /users/me/teams/{teamID}/channels:
    parameters:
      - name: teamID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listCurrentUserTeamChannels
      summary: List channels for the user in a team
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Channel" }

  /posts:
    post:
      operationId: createPost
      summary: Create a post
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatePost" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Post" }

  /scheduled_posts:
    post:
      operationId: createScheduledPost
      summary: Schedule a post for leased background delivery
      description: |
        The caller must still belong to the channel when creating the schedule.
        Workers claim rows with a lease and persist a unique scheduled-post result
        so retries cannot create a second post row. Post-commit notifications and
        integrations remain best-effort rather than a transactionally replayed lifecycle.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateScheduledPost" }
      responses:
        "201":
          description: Scheduled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScheduledPost" }
        "400":
          description: Invalid body or time
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: Not a channel member
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /users/me/scheduled_posts:
    get:
      operationId: listCurrentUserScheduledPosts
      summary: List mutable pending or retrying scheduled posts
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ScheduledPost" }

  /scheduled_posts/{scheduledID}:
    parameters:
      - name: scheduledID
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      operationId: deleteScheduledPost
      summary: Cancel a pending or retrying scheduled post owned by the caller
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string } }
        "404":
          description: Missing, already claimed, or already delivered
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /channels/{channelID}/posts:
    parameters:
      - name: channelID
        in: path
        required: true
        schema: { type: string }
      - name: page
        in: query
        schema: { type: integer, default: 0 }
      - name: per_page
        in: query
        schema: { type: integer, default: 60, maximum: 200 }
    get:
      operationId: listChannelPosts
      summary: List posts in a channel
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostList" }

  /posts/{postID}:
    parameters:
      - name: postID
        in: path
        required: true
        schema: { type: string }
    put:
      operationId: updatePost
      summary: Update (edit) a post
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdatePost" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Post" }
    delete:
      operationId: deletePost
      summary: Soft-delete a post
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string } }

  /teams/{teamID}/posts/search:
    parameters:
      - name: teamID
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: searchTeamPosts
      summary: Full-text search posts visible to the caller in a team
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchPostsRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PostList" }

  /reactions:
    post:
      operationId: createReaction
      summary: Add a reaction to a post
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [post_id, emoji_name]
              properties:
                post_id: { type: string }
                emoji_name: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reaction" }

  /posts/{postID}/reactions:
    parameters:
      - name: postID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listPostReactions
      summary: List reactions on a post
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Reaction" }

  /users/{userID}/posts/{postID}/reactions/{emoji}:
    parameters:
      - name: userID
        in: path
        required: true
        schema: { type: string }
      - name: postID
        in: path
        required: true
        schema: { type: string }
      - name: emoji
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: deleteReaction
      summary: Remove a reaction (caller's only)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string } }

  /files:
    post:
      operationId: uploadFiles
      summary: Upload one or more files for later post attachment
      parameters:
        - name: channel_id
          in: query
          schema: { type: string }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  items: { type: string, format: binary }
                client_ids:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  file_infos:
                    type: array
                    items: { $ref: "#/components/schemas/FileInfo" }
                  client_ids:
                    type: array
                    items: { type: string }

  /files/{fileID}:
    parameters:
      - name: fileID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: downloadFile
      summary: Download a file
      responses:
        "200":
          description: Binary payload
          content:
            application/octet-stream:
              schema: { type: string, format: binary }

  /files/{fileID}/info:
    parameters:
      - name: fileID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getFileInfo
      summary: File metadata
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileInfo" }

  /channels/{channelID}/members:
    parameters:
      - name: channelID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: listChannelMembers
      summary: List channel members
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ChannelMember" }
    post:
      operationId: addChannelMember
      summary: Invite (add) a user to a channel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id: { type: string }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelMember" }

  /channels/{channelID}/members/{userID}:
    parameters:
      - name: channelID
        in: path
        required: true
        schema: { type: string }
      - name: userID
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: deleteChannelMember
      summary: Remove a channel member (self-leave or admin kick)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string } }

  /users/logout:
    post:
      operationId: logoutUser
      summary: Revoke the caller's current session
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties: { status: { type: string } }

  /plugins:
    get:
      operationId: listPlugins
      summary: List installed plugins (`manage_plugins`)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Plugin" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    post:
      operationId: installPlugin
      summary: Upload, validate, install, and activate a Mattermost plugin archive
      description: |
        Requires `manage_plugins`. Accepts either multipart field `plugin` or a
        raw gzip-compressed tar body. A runtime upload is installed as
        `mattermost_v1`. Existing installations require `force=true`; the
        implementation also accepts `replace=true` as a compatibility alias.
        Install and replacement use a durable PostgreSQL rollback marker; a
        candidate is not exposed until activation, old-generation call drain,
        and metadata commit finish. Archive structure is validated, but plugin
        code signatures and publisher identity are not; installed bundles are
        fully trusted native code.
      parameters:
        - name: force
          in: query
          required: false
          schema: { type: boolean, default: false }
        - name: replace
          in: query
          required: false
          deprecated: true
          description: Compatibility alias for `force`.
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [plugin]
              properties:
                plugin:
                  type: string
                  format: binary
          application/gzip:
            schema:
              type: string
              format: binary
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: Plugin installed and activated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PluginInstallResult" }
        "400":
          description: Malformed, unsafe, oversized, or incompatible archive
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Plugin already exists without force, or the current generation could not drain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/capabilities:
    get:
      operationId: getPluginManagementCapabilities
      summary: Get delegated plugin-management runtime capabilities
      description: Requires `manage_plugins` and does not expose the system-wide configuration snapshot.
      responses:
        "200":
          description: Current plugin-management availability
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PluginManagementCapabilities" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/statuses:
    get:
      operationId: listPluginStatuses
      summary: List plugin runtime states for authenticated clients
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PluginStatus" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/webapp:
    get:
      operationId: discoverPluginWebapps
      summary: Discover enabled and running web plugin bundles
      description: |
        Returns same-origin bundle URLs for the authenticated webapp. Disabled,
        failed, stopped, and server-only plugins are omitted.
      responses:
        "200":
          description: Enabled web bundles
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/PluginWebappBundle" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/{pluginID}:
    delete:
      operationId: deletePlugin
      summary: Disable and remove an installed plugin (`manage_plugins`)
      parameters:
        - name: pluginID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is not installed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The current plugin generation could not drain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Plugin removal or persistent-state cleanup failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/{pluginID}/enable:
    post:
      operationId: enablePlugin
      summary: Enable and activate an installed plugin (`manage_plugins`)
      parameters:
        - name: pluginID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Enabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is not installed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The current plugin generation could not drain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Plugin activation failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/{pluginID}/disable:
    post:
      operationId: disablePlugin
      summary: Deactivate and disable an installed plugin (`manage_plugins`)
      parameters:
        - name: pluginID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Disabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is not installed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: The current plugin generation could not drain
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Plugin deactivation or persistence failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/{pluginID}/configuration:
    parameters:
      - name: pluginID
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getPluginConfiguration
      summary: Get effective plugin configuration and manifest schema (`manage_plugins`)
      responses:
        "200":
          description: Effective configuration and schema
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PluginConfiguration" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is not installed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Plugin install or replacement is in progress
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Configuration could not be decrypted or read
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    put:
      operationId: updatePluginConfiguration
      summary: Encrypt, persist, and apply plugin configuration (`manage_plugins`)
      description: |
        The previous encrypted configuration is restored when the live plugin
        hook rejects the new value. If rollback or live reapply is uncertain,
        the managed runtime fails closed until the Moyro process is restarted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PluginConfigurationUpdate" }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StatusOK" }
        "400":
          description: Invalid JSON configuration
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "403":
          description: "`manage_plugins` permission required"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is not installed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Plugin install or replacement is in progress
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Encryption, persistence, or live configuration hook failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Managed plugin runtime is unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /plugins/{pluginID}/webapp.js:
    servers:
      - url: /
        description: Deployment origin; this route is outside `/api/v4`.
    get:
      operationId: getPluginWebappBundle
      summary: Fetch one authenticated web plugin JavaScript bundle
      parameters:
        - name: pluginID
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: JavaScript bundle
          headers:
            X-Content-Type-Options:
              schema: { type: string, const: nosniff }
            Cache-Control:
              schema: { type: string, const: "private, max-age=300" }
          content:
            text/javascript:
              schema:
                type: string
        "401":
          description: Missing or invalid session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Bundle is absent or plugin is not enabled and running

  /plugins/{pluginID}/{pluginPath}:
    servers:
      - url: /
        description: Deployment origin; this route is outside `/api/v4`.
    description: |
      Catch-all plugin HTTP boundary. `pluginPath` represents the complete
      remainder after `/plugins/{pluginID}/` and may contain `/`. The runtime
      also dispatches the root `/plugins/{pluginID}/`. It accepts every HTTP
      method even though OpenAPI has no standard catch-all method operation.
      Authentication is optional so a plugin can implement
      its own token; a supplied Moyro bearer/PAT must validate. Moyro strips
      `Authorization` and client-supplied `Mattermost-User-ID`, then injects a
      verified `Mattermost-User-ID` when authenticated. The plugin owns
      endpoint-level authorization. Request bodies are limited to 8 MiB.
    x-moyro-catch-all-methods: [GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS]
    parameters:
      - name: pluginID
        in: path
        required: true
        schema: { type: string }
      - name: pluginPath
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: callPluginHTTP
      summary: Dispatch a request to a running plugin HTTP handler
      security:
        - bearerAuth: []
        - {}
      responses:
        "200":
          description: Plugin-defined response; status, headers, and body vary
        "401":
          description: A supplied Moyro credential was invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Plugin is absent, disabled, not running, or has no HTTP hook
        "502":
          description: Moyro native RPC plugin HTTP call failed

  /websocket:
    get:
      operationId: connectWebSocket
      summary: WebSocket upgrade endpoint
      security: []
      description: |
        Upgrades without an HTTP bearer token, then authenticates with the
        first `authentication_challenge` frame. Events follow the Mattermost envelope:
        `{ event, data, broadcast, seq }`. Core events:

        - `posted` — new post in channel. `data.post` is a stringified Post JSON.
          `data.mentions` is a stringified JSON array of user IDs referenced with @.
        - `post_edited` — message/props of an existing post changed.
        - `post_deleted` — soft-delete broadcast.
        - `typing` — another user is typing in a channel (fanned to channel members, omits the author).
        - `user_added` / `user_removed` — channel membership changes.
        - `reaction_added` / `reaction_removed` — reaction lifecycle on a post.
        - `mention` — direct fan-out to each mentioned user (notification hook).

        Client-to-server actions (`{seq, action, data}`):

        - `user_typing` — client announces it is typing. `data.channel_id` required, `data.parent_id` optional.
      responses:
        "101": { description: Switching Protocols }
