openapi: 3.1.0
info:
  title: Blah Chatserver Proto
  version: 0.0.1

paths:
  /_blah/server:
    get:
      summary: Get Server metadata
      responses:
        200:
          content:
            application/json:
              schema:
                type: object
                properties:
                  server:
                    type: string
                    example: 'blah/0.0.0'
                  src_url:
                    type: string
                    example: 'https://github.com/Blah-IM/blahrs'
                  capabilities:
                    type: object
                    properties:
                      allow_public_register:
                        type: boolean

  # OAPI does not support WebSocket interface definitions.
  # See: https://github.com/OAI/OpenAPI-Specification/issues/55#issuecomment-929382279
  /_blah/ws:
    get:
      summary: WebSocket endpoint
      description: |
        This endpoint is for server-side-event dispatching.

        Once connected, client must send a JSON text message of type
        `Signed-Auth` for authentication.
        If server does not close it immediately, it means success.

        Since OAPI does not support WebSocket interface, we use request and
        response types documented here mean outgoing and incoming JSON text
        messages.

      parameters:
        - name: Connection
          in: header
          required: true
        - name: Upgrade
          in: header
          required: true

      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WSClientToServer'

      responses:
        101:
          headers:
            Connection:
              required: true
            Upgrade:
              required: true
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WSServerToClient'

  /_blah/user/me:
    get:
      summary: Check registration status of the current user
      parameters:
        - name: Authorization
          in: header
          description: Optional user authentication token.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

      responses:
        204:
          description: The user is already registered on the server.

        404:
          description: |
            The user is not registered, or no token is not provided.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiErrorWithRegisterChallenge'

    post:
      summary: Register or update user identity
      description: |
        Register or update a user identity description.

        To prevent misuse and DOS of this endpoint, the request must pass the
        server-specific Proof of Work (PoW) challenge as below:

        1.  The request payload must include `challenge_nonce` with the value
            of `x-blah-nonce` header from a recent enough GET response of
            `/user/me`. Server will rotate it and a nonce will expire after a
            server-specific time period.

        2.  The SHA256 of the canonical serialization (JCS) of `signee` must
            have at least `x-blah-difficulty` (from a recent response) number
            of leading zero bits.

        The `id_url` should be a HTTPS domain name without path. A fixed
        well-known path `/.well-known/blah.identity.json` will be fetched.
        It should return status 200, with a JSON response of type
        `UserIdentityDescription`.

      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-UserRegister'

      responses:
        204:
          description: User successfully registered.

        400:
          description: Invalid request format or any invalid fields in the request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        401:
          description: |
            Unable to verify user identity. May caused by connection failure
            when fetching id_url, malformed identity description, and etc.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        403:
          description: |
            Server disallows registration, either due to server restriction or
            unacceptable id_url.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        409:
          description: |
            User state changed during the operation. Could retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        422:
          description: |
            Fail to process identity description. Could be failure to fetch
            remote description, unacceptable result from id_url, or any fields
            (eg. signatures) in the returned description being invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room:
    get:
      summary: List rooms
      parameters:
        - name: filter
          in: query
          required: true
          schema:
            enum:
              - public
              - joined
              - unseen
          description: |
            Must be one of following values:
            - "public": list all public rooms on the server.
            - "joined": list rooms the user have joined.
              Requires `Authorization`.
            - "unseen": list rooms the user have joined and have unseen
              messages.
              Requires `Authorization`.

        - name: top
          in: query
          schema:
            type: string
          description:
            The maximum count of rooms returned in a single response. This is
            only an advice and server can clamp it to a smaller value.

        - name: skipToken
          in: query
          schema:
            type: string
          description:
            The page token returned from a previous list response to fetch the
            next page. NB. Other parameters (eg. `joined` and `page_len`)
            should be included (as the same value) for each page fetch.

        - name: Authorization
          in: header
          description: Optional proof of membership for private rooms.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

      responses:
        200:
          description: Filtered and paged rooms.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomList'

        401:
          description: Missing or invalid Authorization header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/create:
    post:
      summary: Create a room

      description:
        When `typ="create_room"`, create a multi-user room.

        When `typ="create_peer_chat"`, create a peer-to-peer room between two
        users. There can be at most one peer room for each given user pair.

      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-CreateRoom'

      responses:
        200:
          description: Room created.
          content:
            application/json:
              schema:
                type: string
                description: Newly created room `rid`.

        403:
          description: The user does not have permission to create room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        404:
          description: |
            The current user does not exists, the peer user does not exist or
            they disallows peer chat.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        409:
          description: There is already a peer chat room between the user pair.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}:
    get:
      summary: Get room metadata
      parameters:
        - name: Authorization
          in: header
          description: Optional proof of membership for private rooms.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

      responses:
        200:
          description: The metadata of the specified room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomMetadata'

        404:
          description: |
            Room does not exist or the user does not have permission to get metadata of it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

    delete:
      summary: Delete a room
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-DeleteRoom'

      responses:
        204:
          description: Operation completed.

        401:
          description: Missing or invalid Authorization header.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        404:
          description: |
            Room does not exist or the user does not have permission to access it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/admin:
    post:
      summary: Room management (legacy)
      deprecated: true
      description: |
        Use POST `/_blah/room/{rid}/member` or
        DELETE `/_blah/room/{rid}/member/{member_id_key}` instead.

      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-RoomAdmin'

      responses:
        204:
          description: Operation completed.

        404:
          description: |
            Room does not exist or the user does not have permission for the
            operation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        409:
          description:
            Operation is already done, eg. joining an already joined room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/feed.json:
    get:
      summary: Get JSON feed of room
      description: |
        Get room {rid}'s content in JSON feed v1.1 format. The room must be
        public. For human and feed reader consumption only.
      responses:
        200:
          description: The JSON feed.
          content:
            text/feed+json:
              schema:
                $ref: 'https://www.jsonfeed.org/version/1.1/'

        404:
          description: Room does not exist or is private.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/feed.atom:
    get:
      summary: Get Atom feed of room
      description: |
        Get room {rid}'s content in The Atom Syndication Format format. The
        room must be public. For human and feed reader consumption only.

        More details: <https://validator.w3.org/feed/docs/atom.html>

      responses:
        200:
          description: The Atom feed.
          content:
            application/atom+xml:
              description: Feed XML.

        404:
          description: Room does not exist or is private.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'


  /_blah/room/{rid}/msg:
    get:
      summary: List messages in a room
      description: |
        Return a list of messages in reversed server time order, up to length `top`
        in a single response, from room {rid}.
        The last (oldest) message's `cid` will be returned as `skipToken` in
        response, which can be used as query parameter for the next GET, to
        repeatedly fetch more history.

      parameters:
        - name: Authorization
          in: header
          description: Optional proof of membership for private rooms.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

        - name: top
          in: query
          schema:
            type: integer
          description: |
            The number of items returned in a single response. This is
            an advice and may be further clamped by the server. It must not be
            zero.

        - name: skipToken
          in: query
          schema:
            type: string
          description: |
            Return items after (older than) an existing `cid`. Useful for
            pagination.

      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomMsgs'

        404:
          description: |
            Room does not exist or the user does not have permission to read it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

    post:
      summary: Post a `Msg` into a room
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-Chat'

      responses:
        200:
          content:
            application/json:
              schema:
                type: string
                description: Newly created message id `cid`.

        403:
          description: The user does not have permission to post in this room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        404:
          description: The room does not exist or the user is not a room member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/msg/{cid}/seen:
    post:
      summary: Mark a message seen
      description: |
        Mark message {cid} and everything before it in room {rid} seen by the
        current user.

        Server may enforce that last seen message does not go backward. Marking
        an older message seen or sending the same request multiple times can be
        a no-op.

      parameters:
        - name: Authorization
          in: header
          required: true
          description: Proof of membership for private rooms.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

      responses:
        204:
          description: Operation completed.

        404:
          description: |
            Room does not exist or the user is not in the room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/member:
    get:
      summary: List room members
      parameters:
        - name: Authorization
          in: header
          required: true
          description: Proof of membership.
          schema:
            $ref: '#/components/schemas/Signed-Auth'

        - name: top
          in: query
          schema:
            type: string
          description:
            The maximum count of rooms returned in a single response. This is
            only an advice and server can clamp it to a smaller value.

        - name: skipToken
          in: query
          schema:
            type: string
          description:
            The page token returned from a previous list response to fetch the
            next page.

      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomMemberList'

        403:
          description: |
            The user does not have permission to get room members
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        404:
          description: |
            Room does not exist or the user is not in the room.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

    post:
      summary: Join a room
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-AddMember'

      responses:
        204:
          description: Operation completed.

        404:
          description: |
            Room does not exist or the user does not have permission for the
            operation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        409:
          description:
            The user is already a room member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /_blah/room/{rid}/member/{target_id_key}:
    delete:
      summary: Remove a room member.

      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signed-RemoveMember'

      responses:
        204:
          description: Operation completed.

        403:
          description: |
            The user does not have permission to remove the operand member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

        404:
          description: |
            Room does not exist, the user does not have permission for the
            operation, or the operand user is not a room member.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'


# Ideally we should generate these from src, but we need to
# WAIT: https://github.com/juhaku/utoipa/pull/1034
components:
  schemas:
    WSClientToServer:
      anyOf:
        - $ref: '#/components/schemas/Signed-Auth'

    WSServerToClient:
      anyOf:
        - type: object
          properties:
            chat:
              $ref: '#/components/schemas/WithMsgId-Signed-Chat'

        - type: object
          properties:
            lagged:
              type: object
              const: {}

    ApiError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: A machine-readable error code string.
              example: invalid_signature
            message:
              type: string
              description: A human-readable error message.
              example: signature verification failed

    ApiErrorWithRegisterChallenge:
      allOf:
        - $ref: '#/components/schemas/ApiError'
        - type: object
          properties:
            register_challenge:
              type: object
              properties:
                pow:
                  type: object
                  properties:
                    nonce:
                      type: integer
                      format: uint32
                    difficulty:
                      type: integer
                      format: uint32

    RoomList:
      type: object
      required:
        - rooms
      properties:
        rooms:
          type: array
          items:
            $ref: '#/components/schemas/RoomMetadataForList'
        next_token:
          type: string
          description: An opaque token to fetch the next page.

    RoomMetadataForList:
      type: object
      required: ['rid', 'title', 'attrs']
      properties:
        rid:
          type: string
        title:
          type: string
        attrs:
          description: Room attributes bitset, see `RoomAttrs`.
          type: integer
          format: int32
        last_msg:
          $ref: '#/components/schemas/WithMsgId-Signed-Chat'
        last_seen_cid:
          description: The `cid` of the last chat being marked as seen.
          type: string
        unseen_cnt:
          description: |
            The number of unseen messages. Only available for
            GET `/room?filter=unseen`.
          type: integer
          format: uint32
        member_permission:
          type: integer
          format: int32
        peer_user:
          type: string
          description: |
            For peer chat room, this gives the identity of the peer user.

    RoomMetadata:
      type: object
      required: ['rid', 'title', 'attrs']
      properties:
        rid:
          type: string
        title:
          type: string
        attrs:
          type: integer
          format: int32

    RoomMsgs:
      type: object
      required:
        - msgs
      properties:
        msgs:
          description: Room messages in reversed server-received time order.
          type: array
          items:
            $ref: '#/components/schemas/WithMsgId-Signed-Chat'
        skip_token:
          description: The token for fetching the next page.
          type: string

    RoomMemberList:
      type: object
      required:
        - members
      properties:
        members:
          description: Room members in server-specified order.
          type: array
          items:
            $ref: '#/components/schemas/RoomMember'
        skip_token:
          description: The token for fetching the next page.
          type: string

    RoomMember:
      type: object
      required:
        - id_key
        - permission
      properties:
        id_key:
          type: string
        permission:
          type: integer
          format: int32
        last_seen_cid:
          type: string

    RichText:
      type: array
      items:
        anyOf:
          - type: string
            description: Unstyled text piece.
          - type: array
            items: false
            prefixItems:
              - type: string
                description: The text piece to apply styles on.
              - type: object
                properties:
                  b:
                    type: boolean
                    description: Bold.
                  m:
                    type: boolean
                    description: Monospace.
                  i:
                    type: boolean
                    description: Italic.
                  s:
                    type: boolean
                    description: Strikethrough.
                  u:
                    type: boolean
                    description: Underline.
                  hashtag:
                    type: boolean
                    description: Hashtag.
                  link:
                    type: string
                    description: Link target.


    Signed-Auth:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'auth'

    Signed-RoomAdmin:
      oneOf:
        - $ref: '#/components/schemas/Signed-AddMember'
        - $ref: '#/components/schemas/Signed-RemoveMember'

    Signed-AddMember:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'add_member'
                room:
                  type: string
                permission:
                  type: integer
                  format: int32
                user:
                  type: string

    Signed-RemoveMember:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'remove_member'
                room:
                  type: string
                user:
                  type: string

    Signed-Chat:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'chat'
                room:
                  type: string
                rich_text:
                  $ref: '$/components/schemas/RichText'

    WithMsgId-Signed-Chat:
      allOf:
        - $ref: '#/components/schemas/Signed-Chat'
        - type: object
          properties:
            cid:
              type: string
              description: An opaque server-specific identifier.

    Signed-CreateRoom:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              oneOf:
                - type: object
                  properties:
                    typ:
                      type: string
                      const: 'create_room'
                    title:
                      type: string
                - type: object
                  properties:
                    typ:
                      type: string
                      const: 'create_peer_chat'
                    peer:
                      type: string

    Signed-DeleteRoom:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'delete_room'
                room:
                  type: integer
                  format: in64

    Signed-UserRegister:
      type: object
      properties:
        sig:
          type: string
        signee:
          type: object
          properties:
            nonce:
              type: integer
              format: uint32
            timestamp:
              type: integer
              format: uint64
            id_key:
              type: string
            act_key:
              type: string
            payload:
              type: object
              properties:
                typ:
                  type: string
                  const: 'user_register'
                server_url:
                  type: string
                  description: |
                    The server URL to register on. Must matches chat server's base_url.
                    It's path segment must be normalized, eg. always contains a `/` path for top-level.
                id_url:
                  type: string
                  description: |
                    The identity server URL. Must be in form `https://<domain>/`.
                    It's path segment must be normalized, eg. always contains a `/` path for top-level.
                id_key:
                  type: string
                  description: Hex encoded user primary key `id_key`.
                challenge:
                  type: object
                  properties:
                    pow:
                      type: object
                      properties:
                        nonce:
                          type: integer
                          format: uint32
                          description: The challenge nonce retrieved from a recent GET response of `/user/me`.

    UserIdentityDescription:
      type: object
      properties:
        id_key:
          type: string

        act_keys:
          type: array
          items:
            type: object
            properties:
              sig:
                type: string
              signee:
                type: object
                properties:
                  nonce:
                    type: integer
                    format: uint32
                  timestamp:
                    type: integer
                    format: uint64
                  id_key:
                    type: string
                  act_key:
                    type: string
                  payload:
                    type: object
                    properties:
                      typ:
                        type: string
                        const: 'user_act_key'
                      act_key:
                        type: string
                      expire_time:
                        type: integer
                        format: uint64
                      comment:
                        type: string

        profile:
          type: object
          properties:
            sig:
              type: string
            signee:
              type: object
              properties:
                nonce:
                  type: integer
                  format: uint32
                timestamp:
                  type: integer
                  format: uint64
                id_key:
                  type: string
                act_key:
                  type: string
                payload:
                  type: object
                  properties:
                    typ:
                      type: string
                      const: 'user_profile'
                    preferred_chat_server_urls:
                      type: array
                      items:
                        type: string
                        format: url
                    id_urls:
                      type: array
                      items:
                        type: string
                        format: url