openapi: 3.1.0
info:
  title: Lucas Messenger Protocol - WebSocket API
  version: 1.1.1
  description: |
    WebSocket transport layer for the Lucas Messenger Protocol.

    ## Connection

    Connect to `ws://{domain}/ws` to establish a WebSocket connection.

    Clients may include query parameters for session management:

    | Parameter | Required | Description |
    |-----------|----------|-------------|
    | `client` | No | Client type (`web`, `mobile`, `desktop`, `cli`) |
    | `version` | No | Client version string |
    | `deviceId` | No | Unique device identifier for multi-device support |

    Upon connection, the client must send an `auth` message within **10 seconds** or the server will send an error and close the connection.

    ## Message Format

    All messages are JSON objects with a required `type` field that identifies the message type.

    ```json
    { "type": "message.send", "conversationId": "...", "content": "..." }
    ```

    ## Authentication

    Authentication is performed by sending an `auth` message containing a valid JWT access token (obtained from `/api/v1/auth/login` or `/api/v1/auth/register`).

    ```
    -> { "type": "auth", "token": "eyJhbG..." }
    <- { "type": "auth.ok", "userId": "user_abc123", "sessionId": "sess_xyz", "heartbeatInterval": 30000 }
    ```

    Once authenticated, the server tracks the client and routes messages accordingly.

    ## Sequence Numbers

    Broadcast messages (those relayed to other users) include a `seq` (sequence number) for ordering and gap detection. Clients track the last received `seq` to detect missed messages. Acknowledgment messages (`message.ack`, `pong`, etc.) do not include `seq`.

    If the server detects a gap (e.g., after reconnection), it sends a `gap.detected` message as part of the auth response. The client can then request missed messages with `gap.request`.

    ## Idempotency

    Client-originated messages (`message.send`) include an optional `clientId` field for deduplication. If a client sends the same `clientId` twice, the server deduplicates and returns the original ack.

    ## Heartbeat

    Clients should send periodic `ping` messages at the interval recommended by the server in `auth.ok.heartbeatInterval` (default: 30s). The server responds with `pong` including a timestamp. Failure to send pings may result in disconnection.

    ```
    -> { "type": "ping" }
    <- { "type": "pong", "timestamp": 1719900000000 }
    ```

    ## Rate Limiting

    The server enforces per-message-type rate limits. When exceeded, the server sends a `rate.limited` message instead of processing the request.

    | Message Type | Limit | Window |
    |--------------|-------|--------|
    | `message.send` | 100 | 1 min |
    | `message.edit` | 30 | 15 min |
    | `message.delete` | 20 | 15 min |
    | `typing.start` / `typing.stop` | 60 | 1 min |
    | `receipt.read` | 300 | 15 min |
    | `presence.update` | 30 | 5 min |

    ## Connection Lifecycle

    1. Client connects to `ws://{domain}/ws`
    2. Client sends `auth` message with JWT token (within 10s)
    3. Server responds `auth.ok` with session info, or `auth.error` / close
    4. If reconnection, server may send `gap.detected` for missed messages
    5. Client sends/receives real-time messages
    6. Client sends periodic `ping` heartbeats
    7. On disconnect, server sets user presence to `offline`

  contact:
    name: Lucas Protocol
    url: https://github.com/lucas-protocol
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: ws://{domain}
    description: Lucas WebSocket Server
    variables:
      domain:
        default: localhost:3000
        description: Domain name of the Lucas server

tags:
  - name: Authentication
    description: Connection authentication and session management
  - name: Messages
    description: Real-time message send, edit, and delete
  - name: Presence
    description: Online status and typing indicators
  - name: Read Receipts
    description: Message read tracking
  - name: Heartbeat
    description: Connection keep-alive
  - name: Rate Limiting
    description: Flood protection and rate limit enforcement
  - name: Sequencing
    description: Message ordering, gap detection, and recovery

paths: {}

components:
  schemas:
    # ──────────────────────────────────────────────
    #  Client → Server Messages
    # ──────────────────────────────────────────────

    AuthMessage:
      type: object
      required: [type, token]
      properties:
        type:
          type: string
          enum: [auth]
        token:
          type: string
          description: JWT access token from /api/v1/auth/login or /api/v1/auth/register
        lastSeq:
          type: integer
          description: Last received sequence number for session resumption
        deviceId:
          type: string
          description: Device identifier (should match connection query param)
      description: |
        Must be sent within 10 seconds of connection. First message after connect.
        If `lastSeq` is provided on reconnection, the server will check for missed
        messages and send `gap.detected` if any are found.

    MessageSend:
      type: object
      required: [type, conversationId, content]
      properties:
        type:
          type: string
          enum: [message.send]
        clientId:
          type: string
          description: Client-generated unique ID for deduplication
        conversationId:
          type: string
        content:
          type: string
          minLength: 1
          maxLength: 3072
          description: CommonMark formatted content
        replyTo:
          type: string
          description: ID of message being replied to
        metadata:
          type: object
          additionalProperties: true

    MessageEdit:
      type: object
      required: [type, messageId, content]
      properties:
        type:
          type: string
          enum: [message.edit]
        messageId:
          type: string
        content:
          type: string
          minLength: 1
          maxLength: 3072
          description: New CommonMark formatted content
      description: |
        Edit an existing message. Constraints:
        - Only the original sender can edit
        - Must be within 24 hours of original send
        - Maximum 10 edits per message

    MessageDelete:
      type: object
      required: [type, messageId]
      properties:
        type:
          type: string
          enum: [message.delete]
        messageId:
          type: string
      description: |
        Soft-delete a message. The message content is replaced with
        "[Message deleted]" and the tombstone flag is set.
        Only the sender or a conversation admin/owner can delete.

    PresenceUpdateClient:
      type: object
      required: [type, status]
      properties:
        type:
          type: string
          enum: [presence.update]
        status:
          type: string
          enum: [online, away, busy]
        customMessage:
          type: string
          maxLength: 128
          nullable: true
      description: |
        Update own presence status. Broadcast to all conversation members.
        Server auto-sets `away` after 30s idle, `offline` after 5min idle.

    TypingStart:
      type: object
      required: [type, conversationId]
      properties:
        type:
          type: string
          enum: [typing.start]
        conversationId:
          type: string
      description: |
        Indicate that the user has started typing in a conversation.
        Server auto-sends `typing.stop` after 3s of inactivity.

    TypingStop:
      type: object
      required: [type, conversationId]
      properties:
        type:
          type: string
          enum: [typing.stop]
        conversationId:
          type: string
      description: Indicate that the user has stopped typing.

    ReadReceipt:
      type: object
      required: [type, conversationId, lastReadId]
      properties:
        type:
          type: string
          enum: [receipt.read]
        conversationId:
          type: string
        lastReadId:
          type: string
          description: ID of the last message read by the user
      description: Mark messages as read up to the given message ID.

    PingMessage:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [ping]
      description: |
        Heartbeat keep-alive. Server responds with pong.
        Clients should send at the interval from auth.ok.heartbeatInterval.

    GapRequest:
      type: object
      required: [type, fromSeq]
      properties:
        type:
          type: string
          enum: [gap.request]
        fromSeq:
          type: integer
          description: Start sequence number (inclusive)
        toSeq:
          type: integer
          description: End sequence number (inclusive). If omitted, fetches up to latest.
      description: |
        Request missed messages from the server after a gap is detected.
        Server responds with `gap.response` containing the missed messages.

    # ──────────────────────────────────────────────
    #  Server → Client Messages
    # ──────────────────────────────────────────────

    AuthOk:
      type: object
      required: [type, userId, sessionId, heartbeatInterval]
      properties:
        type:
          type: string
          enum: [auth.ok]
        userId:
          type: string
          description: The authenticated user's ID
        sessionId:
          type: string
          description: Unique session identifier for this connection
        heartbeatInterval:
          type: integer
          description: Recommended ping interval in milliseconds
          example: 30000
      description: Sent after successful authentication.

    AuthError:
      type: object
      required: [type, message, code]
      properties:
        type:
          type: string
          enum: [auth.error]
        message:
          type: string
          description: Human-readable error description
        code:
          type: string
          enum: [token_required, invalid_token]
          description: Machine-readable error code
        retryable:
          type: boolean
          description: Whether the client should attempt re-authentication
      description: |
        Sent when authentication fails. The server closes the connection after
        sending this message.

    MessageAck:
      type: object
      required: [type, id, server_ts]
      properties:
        type:
          type: string
          enum: [message.ack]
        clientId:
          type: string
          nullable: true
          description: Echoed back from the client's MessageSend for deduplication
        id:
          type: string
          description: Server-assigned message ID
        server_ts:
          type: integer
          description: Server timestamp (ms since epoch)
      description: |
        Acknowledgment for a sent message. If the client provided a `clientId`,
        it is echoed back here. If the server already processed a message with
        the same `clientId`, the original `id` and `server_ts` are returned.

    MessageIncoming:
      type: object
      required: [type, id, seq, conversationId, senderId, content, createdAt]
      properties:
        type:
          type: string
          enum: [message.incoming]
        id:
          type: string
        seq:
          type: integer
          description: Server sequence number for ordering and gap detection
        conversationId:
          type: string
        senderId:
          type: string
        content:
          type: string
          maxLength: 3072
          description: CommonMark formatted content
        replyTo:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
      description: Broadcast to all other conversation members when a new message is sent.

    MessageEditedAck:
      type: object
      required: [type, id]
      properties:
        type:
          type: string
          enum: [message.edited.ack]
        id:
          type: string
          description: The edited message's ID

    MessageEdited:
      type: object
      required: [type, messageId, seq, conversationId, content, editedAt]
      properties:
        type:
          type: string
          enum: [message.edited]
        messageId:
          type: string
        seq:
          type: integer
          description: Server sequence number
        conversationId:
          type: string
        content:
          type: string
          maxLength: 3072
          description: Updated CommonMark formatted content
        editedAt:
          type: string
          format: date-time
      description: Broadcast to all other conversation members when a message is edited.

    MessageDeletedAck:
      type: object
      required: [type, id]
      properties:
        type:
          type: string
          enum: [message.deleted.ack]
        id:
          type: string
          description: The deleted message's ID

    MessageDeleted:
      type: object
      required: [type, messageId, seq, conversationId, deletedBy, deletedAt]
      properties:
        type:
          type: string
          enum: [message.deleted]
        messageId:
          type: string
        seq:
          type: integer
          description: Server sequence number
        conversationId:
          type: string
        deletedBy:
          type: string
          description: User ID of whoever deleted the message
        deletedAt:
          type: string
          format: date-time
      description: Broadcast to all other conversation members when a message is deleted.

    PresenceUpdateServer:
      type: object
      required: [type, seq, userId, status]
      properties:
        type:
          type: string
          enum: [presence.update]
        seq:
          type: integer
          description: Server sequence number
        userId:
          type: string
        status:
          type: string
          enum: [online, away, busy, offline]
      description: Broadcast to conversation members when a user's presence changes.

    TypingIndicator:
      type: object
      required: [type, conversationId, userId]
      properties:
        type:
          type: string
          enum: [typing.start, typing.stop]
        conversationId:
          type: string
        userId:
          type: string
      description: Broadcast to other conversation members when someone starts/stops typing.

    ReadReceiptBroadcast:
      type: object
      required: [type, conversationId, userId, lastReadId]
      properties:
        type:
          type: string
          enum: [receipt.read]
        conversationId:
          type: string
        userId:
          type: string
          description: User who read the messages
        lastReadId:
          type: string
          description: ID of the last read message
      description: Broadcast to other conversation members when a user reads messages.

    PongMessage:
      type: object
      required: [type, timestamp]
      properties:
        type:
          type: string
          enum: [pong]
        timestamp:
          type: integer
          description: Server timestamp (ms since epoch)

    RateLimited:
      type: object
      required: [type, message, limit, remaining, reset, retry_after]
      properties:
        type:
          type: string
          enum: [rate.limited]
        message:
          type: string
          description: Human-readable rate limit message
        limit:
          type: integer
          description: Maximum requests allowed in the window
        remaining:
          type: integer
          description: Requests remaining in the current window
        reset:
          type: integer
          description: Unix timestamp (seconds) when the window resets
        retry_after:
          type: integer
          description: Seconds to wait before retrying
      description: Sent when a message exceeds the per-type rate limit.

    GapDetected:
      type: object
      required: [type, lastReceived, expected, missedCount]
      properties:
        type:
          type: string
          enum: [gap.detected]
        lastReceived:
          type: integer
          description: Last sequence number the client confirmed receiving
        expected:
          type: integer
          description: Expected next sequence number
        missedCount:
          type: integer
          description: Number of missed messages
      description: |
        Sent by the server after reconnection when it detects missing messages
        based on the client's lastSeq. Client should respond with gap.request.

    GapResponse:
      type: object
      required: [type, messages]
      properties:
        type:
          type: string
          enum: [gap.response]
        messages:
          type: array
          items:
            type: object
            description: Server-pushed messages (message.incoming, message.edited, etc.) with seq numbers
      description: Response to gap.request containing the missed messages in order.

    ErrorMessage:
      type: object
      required: [type, message]
      properties:
        type:
          type: string
          enum: [error]
        message:
          type: string
          description: Human-readable error description
      description: |
        Sent when a message fails validation or an operation is unauthorized.
        Also sent before connection close on auth timeout.
        Common error messages:
        - "Token required" — missing token in auth message
        - "Invalid or expired token" — JWT verification failed
        - "Not authenticated" — action attempted before auth
        - "Authentication timeout" — no auth message within 10 seconds
        - "conversationId and content required" — missing fields in message.send
        - "messageId and content required" — missing fields in message.edit
        - "messageId required" — missing messageId in message.delete
        - "Not a member of this conversation" — user not in conversation
        - "Cannot delete this message" — not sender or admin
        - "Message not found" — invalid messageId
        - "Invalid message format" — malformed JSON
        - "Unknown message type: {type}" — unrecognized type field

    # ──────────────────────────────────────────────
    #  Enums
    # ──────────────────────────────────────────────

    CloseCode:
      type: integer
      description: |
        WebSocket close codes. These are the planned codes the server will use.
        Currently the server sends an `error` message before closing and may not
        include a numeric close code.

        | Code | Category | Description |
        |------|----------|-------------|
        | 1000 | Normal | Normal closure |
        | 1001 | Normal | Client navigating away or tab closed |
        | 4000 | Auth | Authentication timeout (no auth within 10s) |
        | 4001 | Auth | Invalid or expired token |
        | 4002 | Rate | Rate limit exceeded (persistent abuse) |
        | 4003 | Session | Duplicate session (another device connected) |
        | 4004 | Session | Session expired |
        | 4005 | Protocol | Invalid message format (malformed JSON) |
        | 4006 | Protocol | Unknown message type |
        | 4008 | Resource | Server resource constraints |
        | 4009 | Maintenance | Server undergoing maintenance |
      enum: [1000, 1001, 4000, 4001, 4002, 4003, 4004, 4005, 4006, 4008, 4009]

    PresenceStatus:
      type: string
      enum: [online, away, busy, offline]
      description: |
        Presence status values and their timeout behavior:
        - `online` — active connection, no idle timeout
        - `away` — auto-set after 30 seconds of inactivity
        - `busy` — manually set by user
        - `offline` — auto-set after 5 minutes of inactivity, or on disconnect
