# Merged spec generated by docs-generator.
# This document is the effective YAML behind the rendered page —
# consolidated from every file in the source directory.
# project: chat
# yaml-language-server: $schema=/docs/../schemas/spec.schema.json

info:
    title: Chat Service
    version: 0.1.0
    description: |
        Realtime team-chat service untuk ekosistem ikavia. Backend Go (gin + GORM +
        gocql + gorilla/websocket), data berlapis: **PostgreSQL** untuk metadata
        (channels, memberships, audit), **ScyllaDB** untuk message body (ULID-
        ordered), **Redis pub/sub** untuk multi-instance fanout antar WS gateway.

        Auth delegated ke **Account Service** — JWT RS256 ditandatangani di
        `account.ikavia.com` + verified via `/api/v1/auth/public-key`. Setiap
        request authenticated lazy-create row di `users` table chat-side (1:1 ke
        JWT `sub`).
    base_urls:
        - label: Production
          url: https://chat.ikavia.com
          default: true
        - label: Staging
          url: https://chat-dev.ikavia.com
        - label: Local
          url: http://localhost:8144
    overview_cards:
        - icon: "\U0001F4AC"
          title: REST + WebSocket
          description: Identical message model via either surface
          content: |
            Same envelope shape (`{success, data, request_id}`) untuk REST. WS
            frame (`{type, id, data}`) untuk realtime events. Send via `POST
            /api/v1/channels/{channel-id}/messages` → persist Scylla → publish
            Redis topic `chat:ch:{channel_id}` → fan-out ke WS subscribers di
            instance manapun.
        - icon: "\U0001F194"
          title: Identity model
          description: Two-layer — account_id (JWT) ↔ chat user.id (local)
          content: |
            - **`account_id`** = JWT `sub`, dimiliki account-service (otoritas).
            - **`user_id`** = chat-local UUID di tabel `users`. Lazy-created saat
              principal pertama kali hit endpoint authenticated.
            - **`org_id`** dari JWT `org_id` claim — strict per-org isolation
              (channels, messages, memberships semua scoped by org).
            - **`principal_type`** = `human` atau `service` (bot/service account).
        - icon: "\U0001F199"
          title: Channel kinds
          description: channel | dm | group_dm
          content: |
            - **`channel`** — named, public-by-default, owner+admins+members.
              Bisa `is_private=true` untuk invite-only.
            - **`dm`** — 1:1 direct message. Idempotent canonical pair (sortir
              `user_id` lalu hash → `metadata.dm_pair`). `POST /api/v1/dm`
              dari A→B vs B→A return channel_id sama.
            - **`group_dm`** — 3-9 user, tidak bisa di-rename/archive.
        - icon: "\U0001F9FE"
          title: Standard envelope
          description: Match ikavia pattern (snake_case JSON, kebab-case URL)
          content: |
            Success:
            ```json
            { "success": true, "data": { ... }, "request_id": "uuid" }
            ```
            Error:
            ```json
            { "success": false, "message": "...", "request_id": "uuid" }
            ```
            Pagination response shape:
            ```json
            {
              "items": [...],
              "pagination": {
                "page": 1, "per_page": 50,
                "total": 123, "total_pages": 3
              }
            }
            ```
        - icon: "\U0001F510"
          title: Permission model
          description: Org-level RBAC inherited dari account-service grants
          content: |
            Chat endpoints gate-keep dengan permission claim di JWT. Required:

            | Endpoint family | Permission required |
            |---|---|
            | `POST /channels`               | `channel:create` |
            | `PATCH /channels/{id}`         | `channel:update` |
            | `DELETE /channels/{id}`        | `channel:archive` |
            | `POST /channels/{id}/members`  | `channel:invite` |
            | `DELETE …/members/{user-id}`   | `channel:remove_member` |
            | `POST /channels/{id}/messages` | `chat:write` |
            | `GET …/messages`               | (channel member auto-OK) |

            Wildcards `*` dan `chat:*` di-honor. Service principals (bot) tetap
            kena permission gate — tidak ada bypass.
        - icon: "\U0001F4E1"
          title: Multi-instance scale-out
          description: Redis pub/sub fan-out, no sticky session needed
          content: |
            Setiap chat-service instance bergabung ke Redis pub/sub (db=5 staging,
            db=6 prod). Topics:

            - `chat:ch:{channel_id}` — message events
            - `chat:user:{user_id}` — per-user events (membership added, badges)
            - `chat:org:{org_id}` — org-wide announcements (future)

            WS hub di-tiap-instance subscribe Redis untuk topics yang ada conn
            aktif. Saat user pindah load-balancer node, langganan WS re-build
            otomatis pakai topic state in-process.
        - icon: "\U0001F4DA"
          title: Storage layout
          description: Per-stack isolation untuk prod vs staging
          content: |
            | Layer | Staging | Production |
            |---|---|---|
            | PostgreSQL | DB `chat`, role `chat_app` | DB `chat_prod`, role `chat_app_prod` |
            | ScyllaDB | keyspace `chat`, RF=3 | keyspace `chat_prod`, RF=3 |
            | Redis | db=5, prefix `chat:` | db=6, prefix `chat_prod:` |
            | JWT issuer | `account-service-dev` | `account-service` |

            Tidak ada cross-stack data bleed — di-verify via test `GET prod-channel
            via dev-token` → `404 channel not found`.
        - icon: "\U0001F504"
          title: Org switching
          description: Multi-org users → pilih org aktif via account-service
          content: |
            JWT bawa **satu** `org_id` aktif. User member di banyak org perlu
            switch via account-service untuk pindah konteks:

            ```
            POST https://account.ikavia.com/api/v1/organizations/{org_id}/switch
            Authorization: Bearer <current_jwt>
            Body: {}
            → 200 { access_token, refresh_token, org_id (new) }
            ```

            Chat-service tidak pegang switcher sendiri — cukup pakai JWT baru di
            request berikutnya. Lazy-create di `users` table emit row terpisah
            per (account_id, org_id) → tiap context punya `user.id` sendiri.

            **Visibility rule saat switch:**

            | Channel kind | Visible setelah switch ke org B? |
            |---|---|
            | Org A internal (`is_public=false`) | ❌ Hilang (membership scoped per-org) |
            | Org A private | ❌ Hilang |
            | Public (cross-org, `is_public=true`) | ✅ Tetap visible |
            | DM peer di org B | ✅ Visible (DM canonical pair tetap aktif) |
            | DM peer di org A | ❌ Hilang sementara |

            Frontend `chat-web` punya dropdown switcher di top-bar yang call
            endpoint ini, simpan token baru di localStorage, tear-down WS, lalu
            re-bootstrap chat data untuk org target.
authentication:
    methods:
        - type: JWT Bearer
          header: Authorization
          format: Bearer <access_token>
          source: account-service
          description: |
            RS256 JWT diterbitkan oleh **account-service** (prod: `account.ikavia.com`,
            staging: `account-dev.ikavia.com`). Chat-service verify signature via JWKS
            endpoint dengan cache + auto-refresh saat kid berubah.
          token_contains:
            - sub (account_id UUID)
            - sid (session_id, kosong untuk service principal)
            - org_id (current organization)
            - 'permissions[] (chat-relevant: chat:write, chat:read, channel:*)'
            - principal_type (human|service)
            - kid (JWKS key reference)
            - exp, iat, iss, jti
        - type: API Key
          header: X-API-Key
          format: ak_<random>
          source: account-service (issuing) + chat-service (transparent exchange)
          description: |
            Service-to-service auth for bots / integrations. Sent in `X-API-Key`
            header directly to any chat-service endpoint — server transparently
            exchanges with account-service and caches the resulting JWT in-memory
            (per-replica, 60s pre-exp refresh). Bot integration code does NOT
            need to call `/auth/token-exchange` manually.

            Header precedence: `X-API-Key` > `Authorization: Bearer` (kalau
            both ada). Audit `actor_kind="service"` otomatis (dari resulting
            JWT's `principal_type`).
          note: |
            For WebSocket (no header support after the upgrade handshake), bots
            exchange once via `/auth/token-exchange` and pass JWT in `?token=`
            query param.
flow_overview:
    methods:
        - type: JWT Bearer
          steps:
            - title: Login (atau token-exchange)
              detail: |
                Human: `POST https://account.ikavia.com/api/v1/auth/login` dengan
                email+password → terima access_token + refresh_token.
                Service principal: `POST /auth/token-exchange` dengan X-API-Key →
                terima 1-hour JWT.
            - title: Switch org saat user multi-tenant
              detail: |
                User member di banyak org → satu org aktif per JWT. Untuk pindah:

                ```
                POST https://account.ikavia.com/api/v1/organizations/{org_id}/switch
                Authorization: Bearer <current_jwt>
                ```

                Response berisi access_token + refresh_token baru yang scoped ke
                org target. Replace token lokal, panggil ulang `GET /api/v1/me`
                di chat-service untuk lazy-create chat user di org baru, lalu
                list channels akan berubah ke konteks org tersebut.

                **List my orgs**: `GET https://account.ikavia.com/api/v1/organizations`
                → `{items: [{org_id, name, my_role, ...}]}` — sumber data untuk
                dropdown switcher di UI.

                **Per-org boundary tetap kuat** untuk channel non-public — kalau
                org A punya channel `secret-stuff`, switch ke org B akan hilang
                dari list. Channel `is_public=true` tetap visible cross-org.
            - title: Hit endpoint chat
              detail: |
                Setiap request: `Authorization: Bearer <access_token>`. First call
                auto lazy-create chat user row (1:1 ke JWT.sub). Subsequent call
                lookup existing.
            - title: Refresh saat 401
              detail: |
                Access token TTL ~15 menit. Saat 401, panggil
                `POST /auth/refresh` di account-service untuk dapat token baru
                (atau pakai refresh_token cookie SSO `.ikavia.com`).
            - title: Open WebSocket (realtime)
              detail: |
                `wss://chat.ikavia.com/ws?token=<jwt>` atau pakai Authorization
                header. Subprotocol `ikavia-chat-v1`. Subscribe ke channel
                via `{"type":"subscribe","data":{"channel_ids":[...]}}`.
sections:
    - id: attachments
      title: Attachments (Signed URL + Cleanup)
      description: |
        Chat-service tidak menyimpan file — upload dilakukan FE ke
        `media.ikavia.com` (separate service). Yang chat-service simpan adalah
        pointer di `metadata.attachments[]` pada message row.

        **Note share_url format:** media-service mengeluarkan share-link
        di `https://media.ikavia.com/share/<token>` (tanpa prefix
        `/api/v1/`). Lihat docs media-service "Shared Links" untuk
        detail kenapa path-nya terpisah dari `/api/v1`.

        **Backward-compat normalize:** untuk attachment historis yang
        sempat ter-publish dengan URL lama `/api/v1/share/<token>`
        (sebelum media-service di-fix), chat-service `Access` handler
        strip prefix saat redirect — `s|/api/v1/share/|/share/|` di
        runtime. Storage value tidak ikut di-rewrite. Aman pakai
        kembali walaupun ada attachment lama nyangkut di backup
        restore atau service yang lupa update.

        ```json
        {
          "attachments": [
            {
              "media_id":  "abc-uuid",
              "share_url": "https://media.ikavia.com/share/xyz",
              "mime":      "image/jpeg",
              "kind":      "image",
              "filename":  "ktp.jpg",
              "size":      123456
            }
          ]
        }
        ```

        **Access control problem.** `share_url` dari media-service bersifat
        permanen + publik — siapa pun yang dapat URL bisa akses tanpa cek
        membership channel. Risk: link bocor → outsider lihat file internal.

        **Solusi (per ADR Step 6 close-out 2026-05-19).** Chat-service issue
        signed URL HMAC-SHA256 dengan TTL 1 jam. Browser pakai signed URL
        untuk render attachment (`<img src=...>`). Saat signed URL di-hit,
        chat-service verify HMAC + expire, audit `attachment.viewed`/
        `attachment.downloaded`, dan 302 ke share_url permanent. Setelah 1
        jam URL basi, FE auto-refresh transparan.

        **Lifecycle file.** File ikut dihapus dari media-service saat:
        - Message dihapus (oleh siapa pun: uploader atau admin), atau
        - Message di-edit dan attachment dilepas dari metadata.

        Aksi delete dilakukan asynchronous (goroutine) — request HTTP return
        duluan. Best-effort. Setiap percobaan delete di-audit sebagai
        `attachment.released` dengan reason `message_deleted` atau
        `message_edited`.
      endpoints:
        - name: Sign batch
          method: POST
          path: /api/v1/attachments/sign
          auth: JWT Bearer
          description: |
            Mint signed URL TTL 1 jam untuk akses satu atau lebih attachment.
            Membership channel di-verify per item; item invalid masuk hasil
            dengan `ok=false` + `error` (batch tidak fail keseluruhan).

            FE biasanya panggil ini saat normalize message list (debounced
            100ms), kemudian replace `share_url` dengan `signed_url` di
            render template.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "media_id":  "abc-uuid",
                    "signed_url": "https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?c=27e3...&m=01K...&u=874f...&e=1779189144&a=view&t=11d2f44b...",
                    "expires_at": "2026-05-19T11:12:24Z",
                    "ok": true
                  }
                ]
              }
            }
        - name: Raw access (signed URL)
          method: GET
          path: /api/v1/attachments/{media-id}/raw
          auth: Signed URL (HMAC-SHA256 di query param `t`)
          description: |
            **Tidak butuh Bearer.** Self-auth via signature di query string.

            Verify HMAC + expire → audit (`attachment.viewed` atau
            `attachment.downloaded`) → 302 redirect ke `share_url` permanent
            media-service.

            URL ini boleh embedded langsung di `<img src>`, `<video>`, dst —
            browser akan follow 302 ke media-service.
          query_params:
            - name: c
              type: uuid
              required: true
              description: Channel ID.
            - name: m
              type: ulid
              required: true
              description: Message ID.
            - name: u
              type: uuid
              required: true
              description: User ID yang minta tiket (audit, bukan validasi).
            - name: e
              type: int64
              required: true
              description: Unix timestamp expire.
            - name: a
              type: string
              description: 'Action: `view` (default) atau `download`. Mempengaruhi audit event name.'
            - name: t
              type: hex
              required: true
              description: HMAC-SHA256 hex dari `media_id|c|m|u|e|a` pakai server secret.
        - name: List per channel
          method: GET
          path: /api/v1/channels/{channel-id}/attachments
          auth: JWT Bearer
          description: |
            List attachment di sebuah channel, latest first. Membership cek
            + wildcard `*` bypass. Tiap item sudah include `signed_url` siap
            pakai — FE tidak perlu re-sign.
          query_params:
            - name: kind
              type: string
              description: 'Filter: `image`, `video`, `audio`, `file`, atau kosong (semua).'
            - name: limit
              type: int
              description: Default 50, max 100.
            - name: before
              type: ulid
              description: Cursor pesan untuk paginate older.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "media_id":   "abc-uuid",
                    "signed_url": "https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?...",
                    "expires_at": "2026-05-19T11:12:24Z",
                    "kind":       "image",
                    "mime":       "image/jpeg",
                    "filename":   "ktp.jpg",
                    "size":       123456,
                    "channel_id": "27e3...",
                    "message_id": "01KR...",
                    "sender_id":  "874f...",
                    "sent_at":    "2026-05-19T10:00:00Z"
                  }
                ],
                "next_cursor": "01KR..."
              }
            }
    - id: auth-helpers
      title: Auth helpers (account-service)
      description: |
        Endpoint yang chat-web call ke **account-service** (beda host dari
        chat-service). Wajib di-implement caller untuk dapatkan/refresh JWT
        dan switch org context.

        ⚠️ Endpoint ini di-host di `account.ikavia.com`, bukan `chat.ikavia.com`.
        Authoritative documentation ada di [account docs](/docs?p=account) —
        di sini cuma copy yang relevan untuk chat integration.
      base_urls:
        - label: Production
          url: https://account.ikavia.com
          default: true
        - label: Staging
          url: https://account-dev.ikavia.com
      endpoints:
        - name: Login (human principal)
          method: POST
          path: /api/v1/auth/login
          auth: none
          description: |
            Email + password → access_token + refresh_token. Refresh juga
            di-set sebagai HttpOnly cookie scoped `.ikavia.com` untuk SSO
            cross-subdomain.
          body:
            - name: email
              type: string
              required: true
            - name: password
              type: string
              required: true
          example_response: |
            {
              "success": true,
              "data": {
                "account_id":   "uuid",
                "email":        "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "<jwt>",
                "refresh_token": "<jwt>",
                "expires_in":    900,
                "organizations": [{ "id": "uuid", "role": "owner" }]
              }
            }
        - name: List my orgs
          method: GET
          path: /api/v1/organizations
          auth: JWT Bearer
          description: |
            Return semua org yang caller anggota. Dipakai oleh chat-web untuk
            populate org-switcher dropdown.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "org_id":       "073bebe9-…",
                    "name":         "Acme Inc",
                    "slug":         "acme-inc",
                    "status":       "active",
                    "owner_id":     "uuid",
                    "member_count": 42,
                    "my_role":      "owner"
                  }
                ],
                "total": 1, "page": 1, "per_page": 20, "total_pages": 1
              }
            }
        - name: Switch active org
          method: POST
          path: /api/v1/organizations/{org_id}/switch
          auth: JWT Bearer
          description: |
            Issue JWT baru yang `org_id` claim-nya = `{org_id}` di path.
            Session ID (`sid`) preserved — bukan re-login. Refresh token
            juga di-rotate (lama otomatis ke-revoke saat next refresh).

            Setelah dapat token baru, **caller harus tear-down WebSocket lama**
            dan reconnect dengan token baru — server gateway tidak punya
            mechanism in-flight untuk re-auth. Frontend juga harus
            call ulang `/api/v1/me` chat-service untuk lazy-create chat user
            di org target.
          body:
            - name: (empty body)
              type: —
              description: POST dengan body `{}` cukup. Org_id di path.
          example_response: |
            {
              "success": true,
              "data": {
                "account_id":   "uuid",
                "email":        "user@example.com",
                "display_name": "Jane Doe",
                "access_token": "<new jwt scoped to target org>",
                "refresh_token":"<rotated>",
                "expires_in":   900,
                "organizations":[ ... ]
              }
            }
        - name: Refresh token
          method: POST
          path: /api/v1/auth/refresh
          auth: none
          description: |
            Pakai refresh_token (body atau HttpOnly cookie) → terima access
            baru. Setiap refresh rotate refresh token; presentasi refresh
            lama setelah rotation akan revoke seluruh session (reuse
            detection).

            Chat-web kasih ini ke fetch interceptor: pada 401 dari chat,
            coba refresh sekali → retry request asli; gagal → redirect ke
            login.
          body:
            - name: refresh_token
              type: string
              description: Optional kalau cookie ada.
        - name: Logout
          method: POST
          path: /api/v1/auth/logout
          auth: JWT Bearer
          description: |
            Revoke session (JTI ke Redis revocation list). Stolen access
            token segera berhenti valid di seluruh service yang verify via
            JWKS + revocation cache. Cookie refresh juga di-clear.

            Chat-service akan tutup WebSocket aktif untuk sid tersebut via
            `error{code: session_revoked}` frame + close.
        - name: Exchange API Key for JWT
          method: POST
          path: /api/v1/auth/token-exchange
          auth: API Key (X-API-Key)
          description: |
            Service principals (bots) send their `ak_*` key in `X-API-Key`
            header and receive a short-lived JWT scoped to the service
            account's permissions. JWT carries `principal_type=service`.

            **Note for chat-service callers:** you do NOT need to call this
            endpoint directly. chat-service accepts `X-API-Key` natively on
            every authenticated route — server exchanges + caches the JWT
            internally (in-memory cache per replica, 60s pre-exp refresh).
            See the dedicated **Bot integration** section in the chat-service
            README for the one-step pattern.

            Use this endpoint when:
              - You need the raw JWT for WS auth (`wss://chat.ikavia.com/ws?token=<jwt>`)
              - You're calling a service that does NOT support X-API-Key dual-mode
          example_response: |
            {
              "success": true,
              "data": {
                "access_token": "eyJhbGciOiJSUzI1NiI…",
                "expires_in": 3600,
                "token_type": "Bearer",
                "principal_type": "service"
              }
            }
    - id: calls
      title: Voice & Video Calls
      description: |
        Mesh WebRTC calling (≤4 peers per group) via Cloudflare Realtime TURN.
        Signaling SDP/ICE messages tunnel through the existing WebSocket gateway
        (see WebSocket section, frame types `call.sdp_offer` / `sdp_answer` /
        `ice`). Call lifecycle (start/join/leave/end) goes through these REST
        endpoints which fan-out `call.invite` / `call.participant_joined` /
        `call.participant_left` / `call.end` events.

        A background sweeper promotes calls stuck in `dialing` past the 60-second
        timeout to `missed` and publishes `call.end` so the initiator's UI can
        auto-dismiss the outgoing modal.
      endpoints:
        - name: Mint TURN credentials
          method: POST
          path: /api/v1/calls/turn-creds
          auth: JWT Bearer
          description: |
            Exchange the caller's JWT for short-lived Cloudflare TURN ICE-server
            credentials. The browser uses these to negotiate WebRTC. TTL is
            requested in the body (default 3600s, server clamps to 60..86400).

            Response shape matches `RTCConfiguration.iceServers` so the FE can
            pass it through to `new RTCPeerConnection({ iceServers })` with
            minimal massaging.
          body:
            - name: ttl_seconds
              type: int
              description: Lifetime of credentials in seconds (60..86400, default 3600)
          example_body: |
            { "ttl_seconds": 3600 }
          example_response: |
            {
              "success": true,
              "data": {
                "ice_servers": [
                  { "urls": ["stun:stun.cloudflare.com:3478"] },
                  {
                    "urls": [
                      "turn:turn.cloudflare.com:3478?transport=udp",
                      "turns:turn.cloudflare.com:5349?transport=tcp"
                    ],
                    "username": "g070e2…",
                    "credential": "ca7a41…"
                  }
                ],
                "expires_at": "2026-05-20T13:00:00Z",
                "ttl_seconds": 3600
              }
            }
        - name: Start a call
          method: POST
          path: /api/v1/channels/{channel-id}/calls
          auth: JWT Bearer
          description: |
            Initiator opens a new call session in the channel. Server inserts a
            row with `status=dialing`, adds initiator as a participant, then
            fans out `call.invite` on the channel topic so other members'
            devices ring.
          body:
            - name: type
              type: enum("audio"|"video")
              required: true
              description: audio or video
          example_body: |
            { "type": "video" }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "9bf2bc06-c00c-4cc1-ab06-5ba9fcc98f87",
                "org_id": "...",
                "channel_id": "...",
                "initiator_id": "...",
                "type": "video",
                "status": "dialing",
                "started_at": "2026-05-20T08:13:33.458841Z"
              }
            }
        - name: Join a call
          method: POST
          path: /api/v1/calls/{call-id}/join
          auth: JWT Bearer
          description: |
            Invitee accepts. Promotes status from `dialing` to `active` on the
            first non-initiator join. Returns 422 if call has already ended or
            mesh capacity (4) is full.
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "status": "active", ... }
            }
        - name: Leave a call
          method: POST
          path: /api/v1/calls/{call-id}/leave
          auth: JWT Bearer
          description: |
            Participant graceful hangup. Fans out `call.participant_left`. If
            the last active participant leaves, the call transitions to
            `status=ended` automatically and `call.end` is published.
          body:
            - name: code
              type: enum("hangup"|"disconnect"|"rejected"|"permission_denied")
              description: Why we're leaving — default 'hangup'
          example_body: |
            { "code": "hangup" }
          example_response: |
            { "success": true, "data": { "ok": true } }
        - name: End a call
          method: POST
          path: /api/v1/calls/{call-id}/end
          auth: JWT Bearer
          permission: chat:moderate (or initiator)
          description: |
            Initiator (or moderator) terminates the call for everyone. Fans out
            `call.end`. Duration is recorded in milliseconds in the response.
          body:
            - name: reason
              type: string
              description: End reason, free-form (defaults to 'hangup')
          example_body: |
            { "reason": "hangup" }
          example_response: |
            { "success": true, "data": { "ended": true } }
        - name: List recent calls (channel history)
          method: GET
          path: /api/v1/channels/{channel-id}/calls
          auth: JWT Bearer
          description: |
            Recent call sessions for the channel. Shows duration, type, status
            (active / ended / missed / failed). Used by the chat-web "Call
            history" panel.
          query_params:
            - name: limit
              type: int
              default: "50"
              description: Max items, ≤100
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "...", "type": "video",
                    "status": "ended", "end_reason": "hangup",
                    "started_at": "...", "ended_at": "...",
                    "duration_ms": 359, "initiator_id": "..."
                  }
                ]
              }
            }
    - id: channels
      title: Channels
      description: "Container untuk percakapan. Tiga kind: `channel` (named, multi-user),\n`dm` (1:1), `group_dm` (3-9 user). DM endpoints di section terpisah —\ndi sini fokus ke channel CRUD.\n\nSetiap channel di-create auto-jadi owner-membership untuk creator —\natomic dengan compensating-delete kalau membership insert gagal (no\norphan channel).\n\n**Visibility matrix** (2 boolean → 3 mode efektif; combination\n`is_public=true && is_private=true` ditolak 422):\n\n| `is_private` | `is_public` | Mode efektif | Discoverable di | Siapa bisa join |\n|---|---|---|---|---|\n| false | false | **org-internal** (default) | org sendiri | semua di org via invite/list |\n| true  | false | **private**                | org sendiri | invite-only (admin/owner) |\n| false | true  | **public** \U0001F310              | semua org   | siapa saja (`POST /join`) |\n"
      endpoints:
        - name: List channels
          method: GET
          path: /api/v1/channels
          auth: JWT Bearer
          description: |
            List channels di org saat ini (resolved dari JWT `org_id`).
            Filter optional via query.

            Tiap item di-dekorasi per-caller: `is_member`, `last_read_id`, dan
            **`unread_count`** = jumlah pesan setelah `last_read_id` (di-cap 99 →
            klien tampilkan "99+" bila ≥99; 0 = sudah terbaca semua). Dihitung
            hanya untuk channel yang ada unread (range-scan Scylla ter-bound).
          query_params:
            - name: scope
              type: string
              description: '`my-org` (channels di org sendiri) \| `public` (semua public cross-org) \| `all` (default, union org + public).'
            - name: kind
              type: string
              description: '`channel` | `dm` | `group_dm`'
            - name: archived
              type: boolean
              description: true → only archived; default false.
            - name: q
              type: string
              description: Partial-match (LIKE %name%) untuk channel name.
            - name: page
              type: int
              description: Default 1.
            - name: per-page
              type: int
              description: Default 20, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id":             "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
                    "org_id":         "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                    "kind":           "channel",
                    "name":           "general",
                    "topic":          "All-hands",
                    "is_private":     false,
                    "is_archived":    false,
                    "e2e_enabled":    false,
                    "created_by":     "874f0605-19d7-4e18-8aef-c544d9620157",
                    "message_count":  4,
                    "last_message_id":"01KRYQ7K1JQX75GVZ5PT06Y4FY",
                    "last_message_at":"2026-05-18T23:37:39.634Z",
                    "is_member":      true,
                    "last_read_id":   "01KRYQ7B0000000000000000",
                    "unread_count":   3,
                    "created_at":     "2026-05-18T23:08:21Z",
                    "updated_at":     "2026-05-18T23:37:39Z"
                  }
                ],
                "pagination": { "page": 1, "per_page": 20, "total": 2, "total_pages": 1 }
              }
            }
        - name: Get channel
          method: GET
          path: /api/v1/channels/{channel-id}
          auth: JWT Bearer
          description: |
            Detail single channel. Tidak strict membership check di endpoint ini
            (public channels visible org-wide); private channel data hanya
            terlihat kalau caller member.
        - name: Create channel
          method: POST
          path: /api/v1/channels
          auth: JWT Bearer
          description: |
            Create channel baru. Creator auto-jadi owner-membership.
            `kind` selalu `channel` — DM / group_dm pakai endpoint dedicated.
          body:
            - name: name
              type: string
              required: true
              description: Channel name. Lowercase alphanum + hyphen, max 80 chars.
            - name: topic
              type: string
              description: Free-form one-liner.
            - name: description
              type: string
              description: Markdown description, max 4kb.
            - name: is_private
              type: boolean
              description: true → invite-only di org. Default false. Mutex ke `is_public`.
            - name: is_public
              type: boolean
              description: true → cross-org public, siapa saja bisa join via `POST /join`. Default false. Mutex ke `is_private`.
            - name: e2e_enabled
              type: boolean
              description: End-to-end encryption flag. Immutable setelah create (Step 14 future feature).
            - name: retention_days
              type: int
              description: Override message retention. null = inherit org default.
          example_response: |
            {
              "success": true,
              "data": {
                "id":          "fc695580-e54e-4f9f-9101-55a0b078fc58",
                "kind":        "channel",
                "name":        "general",
                "is_private":  false,
                "created_by":  "...",
                ...
              }
            }
        - name: Update channel
          method: PATCH
          path: /api/v1/channels/{channel-id}
          auth: JWT Bearer
          description: |
            Patch name / topic / description / is_private. `kind`, `e2e_enabled`,
            `created_by` immutable. DM/group_dm tidak boleh di-rename.
          body:
            - name: name
              type: string
            - name: topic
              type: string
            - name: description
              type: string
            - name: is_private
              type: boolean
        - name: Archive channel
          method: DELETE
          path: /api/v1/channels/{channel-id}
          auth: JWT Bearer
          description: |
            Soft-archive — channel tetap ada tapi `is_archived=true` dan
            `archived_at` di-set. Tidak bisa kirim message baru. DM / group_dm
            tidak boleh di-archive.
          example_response: |
            { "success": true, "data": { "id": "...", "is_archived": true, "archived_at": "..." } }
        - name: Join public channel
          method: POST
          path: /api/v1/channels/{channel-id}/join
          auth: JWT Bearer
          description: |
            Self-join — anyone dengan valid JWT bisa join public channel,
            tanpa invite. Endpoint ini **menolak** kalau channel `is_public=false`
            (private/org-internal pakai invite flow biasa).

            Idempotent: kalau caller sudah member, return 200 dengan channel
            existing (bukan 409). Caller jadi role `member` (owner cuma creator).
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "is_public": true, "name": "public-square" }
            }
    - id: compliance
      title: Compliance (Export / Anonymize)
      description: |
        GDPR-style data-subject endpoints. Self-service: a user can dump their
        own data or trigger an anonymization without admin involvement.
        Admin-driven flows for forgetting-on-behalf live in account-service
        with chat-service exposing webhook callbacks.
      endpoints:
        - name: Export my data
          method: GET
          path: /api/v1/me/export
          auth: JWT Bearer
          description: |
            Returns a JSON dump of everything chat-service knows about the
            caller: user row, memberships, messages authored, reactions,
            stars, pinned, attachments referenced. Streamed for large
            accounts; gzip-encoded.
          example_response: |
            {
              "success": true,
              "data": {
                "user": { "id": "...", "display_name": "...", ... },
                "memberships": [ ... ],
                "messages": [ ... ],
                "reactions": [ ... ],
                "starred": [ ... ],
                "exported_at": "2026-05-20T..."
              }
            }
        - name: Anonymize my account
          method: POST
          path: /api/v1/me/anonymize
          auth: JWT Bearer
          description: |
            Soft-delete user row + replace `display_name` with `(deleted user)`.
            Messages remain (for channel-conversation integrity) but author
            fields are scrubbed of identifying info. Channel memberships are
            left for audit but `left_at` set to now.

            Irreversible. account-service should be the orchestrator for
            full-cascade anonymization across services.
          example_response: |
            { "success": true, "data": { "anonymized": true } }
    - id: cs-customer
      title: Customer Support — Customer
      description: |
        Sesi **Customer Support (CS)**: sebuah channel jadi percakapan terdukung
        dengan lifecycle state machine `bot → waiting → assigned → resolved →
        closed` (+ `abandoned`). Customer-initiated — customer membuka sesi, masuk
        antrian FIFO, lalu ditangani agen manusia (auto-assign atau manual claim).

        **Aturan kunci:**

        - **1 sesi aktif per customer** (lintas channel) — server menolak sesi
          kedua selama masih `bot/waiting/assigned`. Setelah `resolved`/`closed`
          boleh mulai sesi baru.
        - **1 sesi aktif per channel**.
        - **Skill (opsional)** — customer memilih kategori dari katalog (lihat
          bagian *Admin*); routing memprioritaskan agen ber-skill cocok.
        - Realtime via WebSocket event `cs_session_updated` (per channel) di tiap
          transisi status.
      endpoints:
        - name: Create CS session
          method: POST
          path: /api/v1/cs/sessions
          auth: JWT Bearer
          description: |
            Buka sesi CS di sebuah channel (customer harus member). Status awal
            `waiting` (masuk antrian), atau `bot` bila `use_bot=true` & bot org
            aktif. `skill` opsional — bila diisi harus slug yang **ada & aktif** di
            katalog.

            Body:
            ```json
            {
              "channel_id": "uuid",
              "topic": "status pesanan",
              "priority": "medium",
              "use_bot": false,
              "skill": "billing"
            }
            ```
          example_response: |
            {
              "success": true,
              "data": {
                "session_id": "…", "channel_id": "…", "status": "waiting",
                "queue_position": 2, "estimated_wait_seconds": 240, "priority": "medium"
              }
            }
        - name: Get my active session
          method: GET
          path: /api/v1/cs/sessions/mine
          auth: JWT Bearer
          description: |
            Sesi support milik caller yang sedang berjalan (`bot/waiting/assigned`),
            lintas channel. Dipakai klien untuk pre-check sebelum membuat sesi baru
            (buka yang ada daripada bikin duplikat).
        - name: Get CS session by channel
          method: GET
          path: /api/v1/channels/{channel-id}/cs-session
          auth: JWT Bearer
          description: |
            Sesi CS non-terminal untuk channel tersebut (untuk render banner status
            di room). Caller harus member channel.
        - name: Request handoff (bot to human)
          method: POST
          path: /api/v1/channels/{channel-id}/cs-session/handoff
          auth: JWT Bearer
          description: |
            Eskalasi dari `bot` ke antrian manusia (`waiting`). Body opsional:
            `{ "reason": "butuh agen", "priority": "high" }`.
        - name: Submit CSAT feedback
          method: POST
          path: /api/v1/channels/{channel-id}/cs-session/feedback
          auth: JWT Bearer
          description: |
            Rating kepuasan 1–5 (+ komentar opsional) setelah sesi `resolved`.
            Body: `{ "rating": 5, "comment": "mantap" }`.
    - id: cs-agent
      title: Customer Support — Agent
      description: |
        Endpoint sisi agen: presence/skill, antrian, dan assignment. Akses butuh
        role agen (tabel `cs_agents`) atau permission `cs:respond` / `cs:manage` /
        `*`.

        **Auto-assign** (feature flag `CS_AUTO_ASSIGN`): saat sesi masuk `waiting`,
        sistem meng-klaim agen online *least-loaded* yang skill-nya cocok (fallback
        ke agen umum) secara **atomik** (`SELECT … FOR UPDATE` + increment dalam
        satu transaksi) sehingga `active_count` tak pernah melebihi `max_concurrent`
        walau multi-instance. Realtime: WS `cs.session.waiting` / `cs.session.assigned`
        (per-agen).
      endpoints:
        - name: Get my agent state
          method: GET
          path: /api/v1/cs/agent/state
          auth: JWT Bearer
          description: |
            Presence + workload + skills agen (caller), untuk pre-fill UI. Default
            `offline` bila agen belum pernah set presence.
          example_response: |
            {
              "success": true,
              "data": { "status": "online", "skills": ["billing"], "max_concurrent": 5, "active_count": 2 }
            }
        - name: Set my agent state
          method: PUT
          path: /api/v1/cs/agent/state
          auth: JWT Bearer
          description: |
            Set presence + kapasitas + skills. `skills` dinormalisasi (lowercase /
            trim / dedup) dan bersifat **lenient** — slug yang tak ada/non-aktif di
            katalog di-drop diam-diam (bukan ditolak) supaya agen tak terkunci dari
            update presence.

            Body: `{ "status": "online", "max_concurrent": 5, "skills": ["billing","refund"] }`
            (`status` ∈ `online|away|busy|offline`).
        - name: List waiting queue
          method: GET
          path: /api/v1/cs/sessions
          auth: JWT Bearer
          description: |
            Daftar sesi berstatus `waiting` di org, diurutkan priority lalu FIFO
            (`waiting_since`). Tiap item menyertakan `skill` yang diminta.
        - name: Assign / claim session
          method: POST
          path: /api/v1/cs/sessions/{session-id}/assign
          auth: JWT Bearer
          description: |
            Tugaskan sesi ke agen. Body `{ "agent_id": "uuid" }` — **kosong berarti
            self-assign** (claim oleh caller). Transisi menjadi `assigned`.
        - name: Reassign / transfer session
          method: POST
          path: /api/v1/cs/sessions/{session-id}/reassign
          auth: JWT Bearer
          description: |
            Transfer sesi `assigned` ke agen lain (workload ikut pindah). **Gated
            supervisor/admin** (`cs:manage` / `*`). Body `{ "agent_id": "uuid" }`.
        - name: Update session status
          method: PATCH
          path: /api/v1/cs/sessions/{session-id}
          auth: JWT Bearer
          description: |
            Ubah status sesi via state machine (mis. `assigned → resolved`,
            `resolved → closed`). Body `{ "status": "resolved" }`. Transisi ilegal
            ditolak.
    - id: cs-admin
      title: Customer Support — Admin (Agents & Skills)
      description: |
        Pendaftaran agen + **katalog skill per-org** (managed/dinamis). Operasi
        tulis butuh admin (`cs:manage` / `chat:moderate` / `*`). `GET /cs/skills`
        (skill aktif) boleh dibaca siapa pun untuk mengisi picker kategori customer.
      endpoints:
        - name: List agents
          method: GET
          path: /api/v1/cs/agents
          auth: JWT Bearer
          description: |
            Daftar agen org (decorated `account_id` + `display_name` + `role`).
            Gated admin — endpoint ini juga dipakai klien untuk mendeteksi apakah
            caller seorang admin CS (200) atau bukan (403).
          example_response: |
            {
              "success": true,
              "data": [ { "user_id": "…", "account_id": "…", "display_name": "Budi", "role": "agent" } ]
            }
        - name: Register agent
          method: POST
          path: /api/v1/cs/agents
          auth: JWT Bearer
          description: |
            Jadikan user agen. Pilih salah satu identifier:
            `{ "account_id": "uuid", "display_name": "Budi", "role": "agent" }`
            (dari daftar member org account-service — direkomendasikan) **atau**
            `{ "user_id": "uuid", ... }` (chat user_id). `role` ∈ `agent | supervisor`.
            Gated admin.

            Bila pakai `account_id` dan user belum punya akun chat, server
            **auto-provision** user chat (pakai `display_name`; nama final
            auto-heal saat user pertama login) → owner bisa jadikan agen member
            mana pun walau belum pernah buka chat.
        - name: Remove agent
          method: DELETE
          path: /api/v1/cs/agents/{user-id}
          auth: JWT Bearer
          description: Copot status agen seseorang (by chat user_id). Gated admin.
        - name: List skills (catalog)
          method: GET
          path: /api/v1/cs/skills
          auth: JWT Bearer
          description: |
            Katalog skill **aktif** (default). Tambahkan `?include_inactive=true`
            untuk menyertakan yang non-aktif (admin only).
          example_response: |
            {
              "success": true,
              "data": [ { "id": "…", "slug": "billing", "label": "Tagihan", "active": true } ]
            }
        - name: Create skill
          method: POST
          path: /api/v1/cs/skills
          auth: JWT Bearer
          description: |
            Tambah skill ke katalog. Body `{ "slug": "billing", "label": "Tagihan" }`.
            Slug dinormalisasi lowercase dan divalidasi pola `^[a-z0-9][a-z0-9_-]{0,63}$`.
        - name: Update skill
          method: PATCH
          path: /api/v1/cs/skills/{skill-id}
          auth: JWT Bearer
          description: |
            Ubah `label` dan/atau status `active`. Body `{ "label": "Tagihan", "active": false }`.
            Menonaktifkan skill membuatnya tak bisa dipakai sesi baru (routing tetap
            punya fallback ke agen umum).
        - name: Delete skill
          method: DELETE
          path: /api/v1/cs/skills/{skill-id}
          auth: JWT Bearer
          description: |
            Hapus skill dari katalog. Sesi/agen yang terlanjur memakai slug-nya
            tetap aman (routing fallback). Gated admin.
        - name: Get business hours
          method: GET
          path: /api/v1/cs/business-hours
          auth: JWT Bearer
          description: |
            Jam operasional CS org + status terkini. Boleh dibaca semua member
            (untuk tahu CS buka/tutup). `open_now` & `next_open_at` dihitung di
            server (timezone-aware). `enabled=false` → CS dianggap selalu buka.
          example_response: |
            {
              "success": true,
              "data": {
                "enabled": true,
                "timezone": "Asia/Jakarta",
                "schedule": "{\"mon\":{\"open\":\"09:00\",\"close\":\"17:00\"}}",
                "closed_message": "Kami buka Sen–Jum 09.00–17.00 WIB.",
                "open_now": false,
                "next_open_at": "2026-06-01T02:00:00Z"
              }
            }
        - name: Set business hours
          method: PUT
          path: /api/v1/cs/business-hours
          auth: JWT Bearer
          permission: admin/supervisor
          description: |
            Set jam operasional (gated admin). `schedule` = objek JSON per-hari
            (`mon|tue|wed|thu|fri|sat|sun`; hari tanpa entry = tutup), jam `HH:MM`
            24-jam di `timezone`. Validasi: timezone valid + `close > open`.

            Di luar jam: sesi `waiting` **tidak** di-auto-abandon (tetap antri
            sampai buka); response sesi customer (`POST /cs/sessions`, `GET
            /cs/sessions/mine`) membawa `business_open` + `next_open_at` untuk
            menampilkan `closed_message` + ETA.
          body:
            - name: enabled
              type: boolean
              required: true
            - name: timezone
              type: string
              description: IANA tz, default Asia/Jakarta.
            - name: schedule
              type: object
              description: '{day:{open,close}} HH:MM.'
            - name: closed_message
              type: string
              description: Pesan ke customer di luar jam (≤500).
          example_body: |
            {
              "enabled": true,
              "timezone": "Asia/Jakarta",
              "schedule": { "mon": {"open":"09:00","close":"17:00"}, "fri": {"open":"09:00","close":"15:00"} },
              "closed_message": "Kami buka Sen–Jum."
            }
    - id: dlp
      title: DLP Audit
      description: |
        Server-side compliance trail for the **frontend** Data Loss Prevention
        scanner. chat-web runs regex checks on the composer body before every
        send (10 rule classes — JWT / AWS keys / PEM private key / GitHub PAT /
        Slack token / Stripe key / Google API key / Ikavia `ak_*` key / credit
        card with Luhn validation). When hits are found, a warning modal asks
        the user to confirm or cancel.

        Default policy is **warn-only** — user can override and send anyway.
        This endpoint records every detection regardless of override so the
        compliance team has a record of near-misses + actual leaks.

        The scanner is entirely client-side; raw secret bodies never leave the
        browser. Only rule IDs and a `sent_anyway` flag are POSTed.
      endpoints:
        - name: Record DLP detection
          method: POST
          path: /api/v1/audit/dlp
          auth: JWT Bearer
          description: |
            chat-web calls this fire-and-forget right after the scanner finds
            a match. Server sanitizes rule IDs (alphanumeric + underscore, ≤32
            chars), caps the list at 20 rules per call (rate-limit on audit
            table), writes `dlp.flagged` to `audit_logs`.

            Surface field disambiguates where the scan ran (composer / edit /
            reply / annotation caption) so reports can slice by entry point.
          body:
            - name: rule_ids
              type: '[]string'
              required: true
              description: Detected rule IDs from src/dlp.js (e.g. jwt, aws_access_key, credit_card)
            - name: channel_id
              type: string
              description: Optional — channel the scanner ran against
            - name: sent_anyway
              type: bool
              required: true
              description: true if user clicked 'Kirim tetap', false if cancelled
            - name: surface
              type: enum("composer"|"edit"|"reply"|"annotation")
              description: Where the scan was triggered
          example_body: |
            {
              "rule_ids": ["jwt", "aws_access_key"],
              "channel_id": "61c625f0-7a2e-44af-aa4c-8fefb0ce0ab4",
              "sent_anyway": false,
              "surface": "composer"
            }
          example_response: |
            { "success": true, "data": { "recorded": true } }
    - id: identity
      title: Identity (`/me`)
      description: |
        Endpoint untuk lookup atau update chat-side user record. Semua endpoint
        auth via `Authorization: Bearer <JWT>`. Lazy-create kalau row belum
        ada — caller tidak perlu register dulu.
      endpoints:
        - name: Get my chat user
          method: GET
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Return chat-side user record. Membuat record baru kalau ini call
            pertama (lazy-create dari JWT claim).
          example_response: |
            {
              "success": true,
              "data": {
                "id":             "874f0605-19d7-4e18-8aef-c544d9620157",
                "account_id":     "d130939c-f3ce-4cfb-bb6f-aee94e06419a",
                "org_id":         "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                "kind":           "human",
                "display_name":   "Chat Tester",
                "avatar_url":     null,
                "status":         "active",
                "mute_keywords":  ["lunch", "standup"],
                "created_at":     "2026-05-18T23:03:07.367883Z",
                "updated_at":     "2026-05-18T23:03:07.367883Z"
              },
              "request_id": "uuid"
            }
        - name: Update my profile
          method: PATCH
          path: /api/v1/me
          auth: JWT Bearer
          description: |
            Patch display_name / avatar_url / mute_keywords. Omitted
            field = unchanged. `kind`, `account_id`, `org_id` immutable.

            **display_name forwarding (TIER 2 fix):** chat-service relay
            PATCH ke account-service `PUT /api/v1/me` dengan caller's
            bearer. Account-service jadi source of truth permanent.
            Kalau forward gagal, local PG TIDAK di-update (return 502).
            Reverse direction (update di account.ikavia.com → propagate
            ke chat) ditangani via RabbitMQ event `account.account.updated`
            (auto-sync sub-1 detik di prod).

            **mute_keywords (TIER 2):** per-user notif suppression.
            Server normalize: trim whitespace + lowercase + dedupe. FE
            filter audio ping + desktop notif kalau body WS message_new
            match substring. Message tetap di-render (user lihat di
            chat list, hanya audio/popup yang silent).
          body:
            - name: display_name
              type: string
              description: Visible name, max 80 chars.
            - name: avatar_url
              type: string
              description: HTTPS URL ke gambar (validated, max 512 chars).
            - name: mute_keywords
              type: string[]
              description: Replace-all. Max 50 entries, max 50 chars each. `[]` = clear, key absent = no change.
          example_body: |
            {
              "mute_keywords": ["lunch", "standup", "deploy"]
            }
          example_response: |
            {
              "success": true,
              "data": { "id": "...", "display_name": "Updated Name", "mute_keywords": ["lunch", "standup", "deploy"], "..." : "..." }
            }
    - id: memberships
      title: Memberships & DM
      description: |
        Membership row = user ↔ channel join (composite PK `user_id`,
        `channel_id`). Carries role (`owner`|`admin`|`member`|`guest`),
        notification pref, mute timestamp, last-read marker.

        DM endpoint idempotent canonical — `POST /dm` dari A→B vs B→A return
        same channel_id (`metadata.dm_pair = sortedHash(a,b)`).
      endpoints:
        - name: List channel members
          method: GET
          path: /api/v1/channels/{channel-id}/members
          auth: JWT Bearer
          description: |
            List anggota aktif. Caller harus member juga. Member yang sudah
            leave (`left_at` set) tidak ke-include.
          query_params:
            - name: page
              type: int
            - name: per-page
              type: int
              description: Default 50, max 100.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "user_id":           "874f0605-19d7-4e18-8aef-c544d9620157",
                    "channel_id":        "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
                    "role":              "owner",
                    "notification_pref": "all",
                    "joined_at":         "2026-05-18T23:08:21Z"
                  }
                ],
                "pagination": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 }
              }
            }
        - name: Add members
          method: POST
          path: /api/v1/channels/{channel-id}/members
          auth: JWT Bearer
          description: |
            Batch add (max 100/request). Idempotent — duplicate di-skip silently
            (return 200, bukan 409). Untuk private channel, caller harus
            admin/owner; public channel any member boleh invite.

            Dua cara identifikasi (boleh dipakai bersama):
            - `user_ids` — chat-side user IDs (target sudah pernah buka chat).
            - `members` — `[{account_id, display_name}]` dari daftar member org
              (account-service). Server **auto-provision** akun chat bila target
              belum pernah buka chat (pakai `display_name`; nama auto-heal saat
              user login). Owner bisa undang member mana pun walau belum buka chat.
          body:
            - name: user_ids
              type: uuid[]
              description: Chat-side user IDs (bukan account_id).
            - name: members
              type: object[]
              description: '[{account_id, display_name}] — provision otomatis.'
          example_response: |
            { "success": true, "data": { "added": 3 } }
        - name: Remove member
          method: DELETE
          path: /api/v1/channels/{channel-id}/members/{user-id}
          auth: JWT Bearer
          description: |
            Soft-leave target user. `left_at` di-set tapi row tidak dihapus
            (for audit + last_read preservation). Self-remove auto-route ke
            leave endpoint.
        - name: Leave channel
          method: POST
          path: /api/v1/channels/{channel-id}/leave
          auth: JWT Bearer
          description: |
            Caller leave channel sendiri. Owner channel tidak boleh leave —
            harus transfer ownership dulu (out of scope Step 3).
        - name: Update my membership preferences
          method: PATCH
          path: /api/v1/channels/{channel-id}/me
          auth: JWT Bearer
          description: |
            Update notification pref / mute. Per-membership setting.
          body:
            - name: notification_pref
              type: string
              description: '`all` | `mentions` | `none`.'
            - name: muted_until
              type: timestamp
              description: RFC3339; null = no mute.
            - name: clear_mute
              type: boolean
              description: true → clear `muted_until` (alternative to passing null).
        - name: Open DM
          method: POST
          path: /api/v1/dm
          auth: JWT Bearer
          description: |
            Idempotent — kalau DM dengan peer ini sudah ada, return existing
            channel_id. Kalau belum, create new + bootstrap 2 membership row
            (caller + peer, both role=`member`).

            Canonical pair key: `metadata.dm_pair = "${min(a,b)}:${max(a,b)}"`,
            jadi A→B dan B→A return record sama. Self-DM ditolak (422).
          body:
            - name: user_id
              type: uuid
              required: true
              description: Peer chat-side user_id (bukan account_id).
          example_response: |
            {
              "success": true,
              "data": {
                "channel_id": "6805901e-5912-4a5c-92de-146c1dd35d4f",
                "created":    true
              }
            }
    - id: messages
      title: Messages (REST)
      description: |
        Message persistence layer: ScyllaDB `messages` table, partition by
        `channel_id`, clustered DESC by `message_id` (ULID).

        **Why ULID instead of UUID?** Lexicographic time-ordered → cluster-key
        DESC = newest first scan, no separate timestamp index needed. 80-bit
        random entropy → effectively no collision di tetangga millisecond.
        ULID juga sortable as string — gampang di-paginate via `before` /
        `after`.

        **Why no LWT?** Scylla tablets (default modern) belum dukung
        Lightweight Transactions. ULID monotonic per-process + random entropy
        → CAS tidak perlu. Edit/Delete: read-before-write di use_case lapisan,
        bukan CAS pada Scylla.

        Soft-delete: body di-kosongkan + `deleted_at` di-set. Row tetap ada
        untuk preserve ordering + audit. Client render placeholder.
      endpoints:
        - name: List messages
          method: GET
          path: /api/v1/channels/{channel-id}/messages
          auth: JWT Bearer
          description: |
            Return messages DESC (newest first). Pagination via `before` /
            `after` cursor (message_id), bukan offset (offset di Scylla
            mahal — pakai cursor).
          query_params:
            - name: limit
              type: int
              description: Default 50, max 100.
            - name: before
              type: ulid
              description: Pesan dengan id < before (lebih lama).
            - name: after
              type: ulid
              description: Pesan dengan id > after (lebih baru).
          example_response: |
            {
              "success": true,
              "data": {
                "count": 4,
                "items": [
                  {
                    "id":           "01KRYQ7K1JQX75GVZ5PT06Y4FY",
                    "channel_id":   "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
                    "org_id":       "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
                    "author_id":    "874f0605-19d7-4e18-8aef-c544d9620157",
                    "author_kind":  "human",
                    "body":         "Fourth message",
                    "content_type": "text/plain",
                    "edited_at":    null,
                    "deleted_at":   null,
                    "created_at":   "2026-05-18T23:37:39.634Z"
                  }
                ]
              }
            }
        - name: Send message
          method: POST
          path: /api/v1/channels/{channel-id}/messages
          auth: JWT Bearer
          description: |
            Persist + publish. Pipeline:

            1. ACL: caller harus active member (atau hold wildcard).
            2. ULID di-generate server-side (monotonic + random entropy).
            3. INSERT ke Scylla `messages` (no LWT — see above).
            4. Update `channels.last_message_*` denormalized (async goroutine).
            5. Publish envelope ke Redis topic `chat:ch:{channel_id}` →
               WS subscribers di all instances dapat `message_new` frame.

            Response REST datang sebelum atau bareng WS frame — client
            **harus** dedup by message id kalau optimistic-append.
          body:
            - name: body
              type: string
              required: true
              description: Max 8000 chars. Optional when attachments OR interactive present.
            - name: content_type
              type: string
              description: 'Default `text/plain`. Future: `text/markdown`.'
            - name: reply_to_id
              type: ulid
              description: Inline reply. Reply count denorm on parent bumped.
            - name: thread_root_id
              type: ulid
              description: Thread root (Step 7 feature).
            - name: mentions
              type: uuid[]
              description: User IDs explicitly mentioned.
            - name: client_nonce
              type: string
              description: Reserved untuk Step 6 idempotency key.
            - name: expires_in_seconds
              type: int
              description: Ephemeral TTL (5..86400). When set, sweeper auto-deletes after this many seconds + publishes `message_expired`.
            - name: metadata
              type: json-string
              description: JSON with `attachments[]`, `interactive.buttons` (inline keyboard), or `forward_from`. Max 16 KB.
          example_body: |
            {
              "body": "Deploy ke prod?",
              "metadata": "{\"interactive\":{\"buttons\":[[{\"id\":\"approve\",\"label\":\"✅ Approve\",\"style\":\"success\"},{\"id\":\"reject\",\"label\":\"❌ Reject\",\"style\":\"danger\"}]]}}"
            }
        - name: Edit message
          method: PATCH
          path: /api/v1/channels/{channel-id}/messages/{message-id}
          auth: JWT Bearer
          description: |
            Edit body. Hanya author (atau holder `chat:moderate`) boleh edit.
            Deleted message tidak bisa di-edit (422). `edited_at` di-set ke now.
            Broadcasts `message_edit` event via WS.
          body:
            - name: body
              type: string
              required: true
              description: New body, max 8000 chars.
        - name: Delete message
          method: DELETE
          path: /api/v1/channels/{channel-id}/messages/{message-id}
          auth: JWT Bearer
          description: |
            Soft-delete: body di-kosongkan + `deleted_at` di-set. Row tetap ada
            di Scylla untuk ordering + audit. Broadcasts `message_delete`
            via WS. Hanya author atau holder `chat:moderate`.

            Cascade cleanup: attachment_refs cleared, pinned/starred rows
            dropped, ES delete-marked, parent reply_count decremented when
            the deleted message had a `reply_to_id`.
          example_response: |
            { "success": true, "data": { "deleted": true } }
        - name: Interact (inline keyboard click)
          method: POST
          path: /api/v1/channels/{channel-id}/messages/{message-id}/interactions
          auth: JWT Bearer
          description: "Record a user click on a callback button in the message's\n`metadata.interactive.buttons` grid (Telegram-style inline\nkeyboard). Server validates the button exists + is NOT a link\nbutton (`url` set → those open the browser directly, no callback).\nPublishes `message.interaction` on the channel topic so the bot\nthat owns the message can react (typically by editing the\noriginal message via PATCH to acknowledge).\n\nInline keyboard schema (max 5×5 grid):\n\n```json\n{\n  \"interactive\": {\n    \"buttons\": [\n      [\n        {\"id\":\"approve\",\"label\":\"✅ Approve\",\"style\":\"success\"},\n        {\"id\":\"reject\",\"label\":\"❌ Reject\",\"style\":\"danger\"}\n      ],\n      [{\"label\":\"\U0001F4CB Logs\",\"url\":\"https://logs.example.com\"}]\n    ]\n  }\n}\n```\n\nStyles: `default | primary | danger | success`. Button id must be\nunique within a message and ≤64 chars; label ≤80 chars. Audit\n`message.interaction` recorded with actor_kind + action_id.\n"
          body:
            - name: action_id
              type: string
              required: true
              description: Matches a button.id in the message metadata.
          example_body: |
            { "action_id": "approve" }
          example_response: |
            { "success": true, "data": { "recorded": true } }
    - id: pinned
      title: Pinned Messages
      description: |
        Pin important messages to a channel for quick reference. Cascade-cleaned
        when source message is deleted (the `pinned_messages` row is dropped via
        a cascade hook in `messages.Delete`). Member-gated; only the user who
        pinned (or a `chat:moderate` holder) can unpin.

        WS events: `message_pinned`, `message_unpinned` fan-out on channel topic.
      endpoints:
        - name: Pin a message
          method: POST
          path: /api/v1/channels/{channel-id}/messages/{message-id}/pin
          auth: JWT Bearer
          description: |
            Member-only. Validates the message exists and is not deleted. Max
            50 pins per channel (idempotent — re-pin returns 200 not 201).
          example_response: |
            { "success": true, "data": { "pinned": true } }
        - name: Unpin a message
          method: DELETE
          path: /api/v1/channels/{channel-id}/messages/{message-id}/pin
          auth: JWT Bearer
          description: |
            Pinner or moderator only. Idempotent — unpin of a non-pinned
            message returns 200.
          example_response: |
            { "success": true, "data": { "unpinned": true } }
        - name: List pinned in channel
          method: GET
          path: /api/v1/channels/{channel-id}/pinned
          auth: JWT Bearer
          description: |
            Member-only. Returns up to 50 pins, sorted by pinned_at DESC.
            Enriched with message body so FE doesn't need to re-fetch.
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "channel_id": "...", "message_id": "01KS24…",
                    "pinned_by": "...", "pinned_at": "...",
                    "body": "Read the runbook before deploy",
                    "author_id": "...", "is_deleted": false
                  }
                ]
              }
            }
    - id: presence
      title: Presence & User Lookup
      description: |
        Two complementary endpoints for resolving "who is this user" and
        "are they online right now".

        **Presence** lives in Redis with a 90-second TTL. Connected WS clients
        refresh their own key every 30 seconds (3× safety margin against drift).
        On the WS gateway side, an explicit "last-conn-closed" path also fires
        `ClearPresence` so a graceful disconnect transitions the user to
        offline without waiting for TTL.

        **User lookup** returns `display_name`, `avatar_url`, `kind` for known
        account IDs. Used by chat-web to cache identities for messages whose
        author isn't currently in the member panel.
      endpoints:
        - name: Lookup users
          method: GET
          path: /api/v1/users/lookup
          auth: JWT Bearer
          description: |
            Batch resolve display info. Strict per-org by default — caller can
            only resolve users in the same org. Cross-org peers are name-
            resolved via the channel member-list endpoint (membership proves
            shared context).
          query_params:
            - name: ids
              type: csv-uuid
              required: true
              description: Comma-separated user IDs, max 100
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "...", "account_id": "...",
                    "display_name": "Arian Saputra",
                    "avatar_url": null,
                    "kind": "human"
                  }
                ]
              }
            }
        - name: Presence batch
          method: GET
          path: /api/v1/users/presence
          auth: JWT Bearer
          description: |
            Returns online/offline state for a set of user IDs by checking
            Redis presence keys (TTL 90s).
          query_params:
            - name: ids
              type: csv-uuid
              required: true
              description: Comma-separated user IDs
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  { "user_id": "...", "online": true },
                  { "user_id": "...", "online": false }
                ]
              }
            }
    - id: read-markers
      title: Read Markers
      description: |
        Per-user-per-channel last-read pointer. Stored on the membership row
        (`memberships.last_read_id`) and cached in Redis hot-path. The FE uses
        this to render unread counts (`COUNT(messages WHERE message_id >
        last_read_id)` via Scylla range query).

        No-clobber semantic: if client sends an older ULID than the one
        already stored, the update is ignored — out-of-order WS deliveries
        can't roll back a marker.
      endpoints:
        - name: Set last-read marker
          method: POST
          path: /api/v1/channels/{channel-id}/read-marker
          auth: JWT Bearer
          description: Member-only. Updates `last_read_id` if newer than stored.
          body:
            - name: message_id
              type: string
              required: true
              description: ULID of the most recently seen message
          example_body: |
            { "message_id": "01KS24XPTNY4SVB6Y7TVESYYYB" }
          example_response: |
            { "success": true, "data": { "ok": true } }
    - id: reactions
      title: Reactions
      description: |
        Emoji reactions on messages. Multi-user, single-emoji-per-user-per-msg
        semantics — toggling the same emoji twice from one user removes it.
        Reactions co-located in ScyllaDB partition with the parent message.
        WS event `reaction` fans out `{op: "add"|"remove", emoji, user_id}`.
      endpoints:
        - name: Add reaction
          method: POST
          path: /api/v1/channels/{channel-id}/messages/{message-id}/reactions
          auth: JWT Bearer
          description: |
            Member-only. Adds emoji to message. Server normalizes emoji (max
            16 chars). Returns 422 if emoji invalid or message deleted.
          body:
            - name: emoji
              type: string
              required: true
              description: Single emoji char or shortcode (≤16 chars)
          example_body: "{ \"emoji\": \"\U0001F44D\" }\n"
          example_response: |
            { "success": true, "data": { "added": true } }
        - name: Remove reaction
          method: DELETE
          path: /api/v1/channels/{channel-id}/messages/{message-id}/reactions
          auth: JWT Bearer
          description: |
            Remove your own reaction. Idempotent. Body matches emoji to remove.
          body:
            - name: emoji
              type: string
              required: true
              description: Same emoji string used in POST
          example_body: "{ \"emoji\": \"\U0001F44D\" }\n"
          example_response: |
            { "success": true, "data": { "removed": true } }
    - id: search
      title: Full-text Search
      description: |
        ScyllaDB stores messages by `(channel_id, message_id DESC)` partition
        — perfect for "load latest N" but terrible for cross-channel text
        search. The chat-service maintains a parallel Elasticsearch index
        (`chat-prod-messages`) populated through two paths:

        1. **At-least-once direct index** on Send / Edit / Delete (synchronous
           goroutine, never blocks the request).
        2. **At-most-once Redis pub/sub indexer** — every chat-service replica
           subscribes and writes to ES. Acts as a redundant tier.

        Search is gated by membership — server filters results to channels the
        caller is a member of. No cross-org leakage.
      endpoints:
        - name: Search messages
          method: GET
          path: /api/v1/search
          auth: JWT Bearer
          description: |
            Full-text search across messages the caller can see. Channels
            filter narrows to specific channels (otherwise org-wide).
          query_params:
            - name: q
              type: string
              required: true
              description: Search phrase (≤256 chars)
            - name: channel_id
              type: uuid
              description: Restrict to one channel
            - name: limit
              type: int
              default: "50"
              description: Max hits, ≤100
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "01KS…", "channel_id": "...",
                    "author_id": "...",
                    "body": "the prod deploy is at 17:00",
                    "highlight": "the <em>prod deploy</em> is at 17:00",
                    "created_at": "..."
                  }
                ],
                "total": 1
              }
            }
    - id: starred
      title: Starred Messages (Per-user Bookmarks)
      description: |
        Per-user private bookmark. Berbeda dari pinned messages (yang
        visible ke semua channel members) — starred items hanya visible
        ke pemilik yang star. Backend filter strict by `user_id` di
        repo layer; tidak ada code path yang expose starred orang lain.

        **Storage:** PG table `starred_messages` (composite PK
        `user_id, channel_id, message_id`). Index `idx_starred_user_at`
        untuk list "my stars" DESC.

        **Limit:** tidak ada hard cap per user (low cardinality —
        realistically <1k per user).

        Decorating: backend best-effort lookup body/author dari Scylla
        messages saat list. Kalau message sudah dihapus (soft-delete
        atau retention), field `is_deleted: true` di-set tapi entry
        tetap di-return — user tetap bisa unstar walaupun source
        message hilang.
      endpoints:
        - name: Star a message
          method: POST
          path: /api/v1/channels/{channel-id}/messages/{message-id}/star
          auth: JWT Bearer
          description: |
            Idempotent. Calling kedua kali tidak fail (409); langsung
            return OK. Caller harus member channel — gate ini mencegah
            user mem-bookmark message yang tidak boleh dia akses
            (defense-in-depth, source-channel sudah membership-gated).
          example_response: |
            { "success": true, "data": { "starred": true } }
        - name: Unstar a message
          method: DELETE
          path: /api/v1/channels/{channel-id}/messages/{message-id}/star
          auth: JWT Bearer
          description: |
            Idempotent. Tidak butuh membership check (user boleh hapus
            bookmark sendiri walaupun sudah left channel).
          example_response: |
            { "success": true, "data": { "unstarred": true } }
        - name: List my starred
          method: GET
          path: /api/v1/me/starred
          auth: JWT Bearer
          description: |
            Sorted by `starred_at` DESC. Decorate dengan body/author
            dari Scylla (best-effort). Pagination via `before` (RFC3339
            timestamp).
          query_params:
            - name: limit
              type: int
              description: Default 50, max 100.
            - name: before
              type: timestamp
              description: 'Cursor: paginate items sebelum waktu ini (DESC).'
          example_response: |
            {
              "success": true,
              "data": {
                "count": 2,
                "items": [
                  {
                    "channel_id": "61c625f0-...",
                    "message_id": "01KS1...",
                    "starred_at": "2026-05-19T17:10:08.494637Z",
                    "author_id": "71213598-...",
                    "body": "important message I want to remember",
                    "created_at": "2026-05-19T16:00:00Z"
                  }
                ]
              }
            }
        - name: Bulk check starred
          method: POST
          path: /api/v1/me/starred/bulk-check
          auth: JWT Bearer
          description: |
            Frontend paint indicator efficient — kirim list
            (channel_id, message_id) untuk message yang visible,
            dapat map "<channel_id>:<message_id>" → true untuk yang
            starred (absent = not starred). Bound max 200 items.
          example_response: |
            {
              "success": true,
              "data": {
                "items": {
                  "61c625f0-...:01KS1...": true
                }
              }
            }
    - id: webhooks
      title: Outgoing Webhooks
      description: |
        Per-channel HTTP webhook delivery for bot integrations that prefer a
        stateless ingress (serverless / Lambda / Cloudflare Worker) over a
        WebSocket subscription. Server POSTs an envelope with HMAC-SHA256
        signature on every subscribed event.

        Failed deliveries land in a durable PG retry queue (`webhook_retries`)
        with exponential backoff: **10s → 1m → 5m → 30m → 2h**. After 5
        attempts the row is dead-lettered with `status=dead` for operator
        inspection; dead rows older than 7 days are purged.

        Subscribed event set per webhook is a comma-separated allow-list; the
        special string `*` subscribes to every supported event.

        **⚠️ Anti-loop (bot wajib baca):** webhook fire untuk SETIAP pesan di
        channel, **termasuk pesan yang dikirim bot itu sendiri**. Tidak ada
        filter sender server-side. Bot WAJIB mengabaikan pesannya sendiri —
        cek `author_kind == "service"` dan/atau `author_id == <user_id bot>`
        di payload — supaya tidak membalas balasannya sendiri (infinite loop).

        **Alternatif WebSocket (bot non-browser):** connect
        `wss://chat.ikavia.com/ws?token=<jwt-hasil-token-exchange>` lalu
        `{"type":"subscribe","data":{"channel_ids":[…]}}`. Request tanpa header
        `Origin` (non-browser) diterima; auth via `?token=`. Aturan anti-loop
        yang sama berlaku.
      endpoints:
        - name: Create webhook
          method: POST
          path: /api/v1/channels/{channel-id}/webhooks
          auth: JWT Bearer
          permission: admin/owner of channel
          description: |
            Returns the generated `secret` (one-time visible) used to verify
            HMAC signatures on subsequent fires. URL must be HTTPS; localhost
            / RFC1918 / `.ikavia.com` blocked (anti-SSRF).
          body:
            - name: url
              type: string
              required: true
              description: Target https:// URL
            - name: events
              type: string
              description: Comma-separated event allow-list or '*' for all
              default: message.created
          example_body: |
            {
              "url": "https://my-bot.example.com/chat-hook",
              "events": "message.created, message.interaction"
            }
          example_response: |
            {
              "success": true,
              "data": {
                "id": "...", "channel_id": "...", "url": "...",
                "secret": "abc123…",
                "events": "message.created, message.interaction",
                "is_active": true,
                "failure_streak": 0
              }
            }
        - name: List webhooks
          method: GET
          path: /api/v1/channels/{channel-id}/webhooks
          auth: JWT Bearer
          permission: admin/owner of channel
          description: |
            Returns currently configured webhooks. `secret` is NOT returned
            after create (one-time visibility).
          example_response: |
            {
              "success": true,
              "data": {
                "items": [
                  {
                    "id": "...", "url": "...",
                    "events": "*",
                    "is_active": true, "failure_streak": 0,
                    "last_fired_at": "...", "last_failed_at": null
                  }
                ]
              }
            }
        - name: Delete webhook
          method: DELETE
          path: /api/v1/channels/{channel-id}/webhooks/{webhook-id}
          auth: JWT Bearer
          permission: admin/owner of channel
          description: Removes a webhook config + cancels queued retries.
          example_response: |
            { "success": true, "data": { "deleted": true } }
    - id: webhook-payloads
      title: Webhook Event Reference
      description: |
        Every event POSTs JSON of shape:

        ```json
        {
          "event": "message.created",
          "delivery_id": "1742…",
          "timestamp": "2026-05-20T13:11:01.445Z",
          "data": { ...event-specific... }
        }
        ```

        Headers:

        - `Content-Type: application/json`
        - `User-Agent: chat-service/1.0 webhook`
        - `X-Ikavia-Event: <event-type>`
        - `X-Ikavia-Signature: sha256=<hex>`

        Verify the signature: `hex(hmac_sha256(secret, raw_body))` and compare
        with `compare_digest`. Reject if mismatch.
      endpoints:
        - name: message.created
          method: POST
          path: <your URL>
          description: Fired on POST /messages.
          example_body: |
            {
              "event": "message.created",
              "delivery_id": "1742…",
              "data": {
                "id": "01KS…",
                "channel_id": "...",
                "author_id": "...", "author_kind": "human",
                "body": "hello", "content_type": "text/plain",
                "metadata": "{}",
                "reply_to_id": "",
                "created_at": "2026-05-20T13:11:00.000Z"
              }
            }
        - name: message.edited
          method: POST
          path: <your URL>
          description: Fired on PATCH /messages/:id.
          example_body: |
            {
              "event": "message.edited",
              "data": {
                "id": "01KS…",
                "channel_id": "...", "author_id": "...",
                "body": "hello (edited)",
                "metadata": "{}",
                "edited_at": "2026-05-20T13:12:00.000Z"
              }
            }
        - name: message.deleted
          method: POST
          path: <your URL>
          description: Fired on DELETE /messages/:id (user-initiated).
          example_body: |
            {
              "event": "message.deleted",
              "data": {
                "id": "01KS…",
                "channel_id": "...", "author_id": "...",
                "deleted_by": "...",
                "deleted_at": "2026-05-20T13:13:00.000Z"
              }
            }
        - name: message.expired
          method: POST
          path: <your URL>
          description: |
            Fired by the ephemeral-message sweeper when a message's TTL elapses.
            Payload omits body (already cleared from Scylla).
          example_body: |
            {
              "event": "message.expired",
              "data": {
                "id": "01KS…",
                "channel_id": "...", "author_id": "...",
                "expired_at": "2026-05-20T13:14:00.000Z"
              }
            }
        - name: message.interaction
          method: POST
          path: <your URL>
          description: |
            Fired when a user clicks a callback button on an inline keyboard
            attached to a message. Link buttons (with `url`) do not fire this
            event — they open the browser directly.
          example_body: |
            {
              "event": "message.interaction",
              "data": {
                "channel_id": "...",
                "message_id": "01KS…",
                "user_id": "...",
                "action_id": "approve",
                "action_label": "✅ Approve"
              }
            }
    - id: websocket
      title: WebSocket Handshake
      description: |
        Single endpoint `GET /ws` (wss:// in prod) untuk realtime events. Auth
        via JWT — bisa `Authorization: Bearer …` header **atau** `?token=…`
        query param (browsers tidak bisa set header pada handshake WS).

        Subprotocol: **`ikavia-chat-v1`**. Server reject kalau Origin tidak match
        `ALLOWED_ORIGINS` env var (CORS-equivalent untuk WS).

        Heartbeat: server ping setiap 25s, client pong (gorilla auto). Pong wait
        = 2.5× ping. Per-conn outbound buffer 64 frames; slow client di-drop
        frame, bukan diblock fan-out lain.

        Per-user cap: max 8 concurrent connections per `user_id`. Connection
        ke-9 boots oldest (FIFO).
      endpoints:
        - name: WebSocket handshake
          method: GET
          path: /ws
          auth: JWT Bearer
          description: |
            Standard RFC 6455 upgrade. Server reply
            `Sec-WebSocket-Protocol: ikavia-chat-v1` kalau match. First frame
            yang server kirim adalah `hello_ack` dengan info session.

            Detail protocol frame ada di section **Events** (di overview page,
            bawah architecture diagram) — termasuk subscribe/unsubscribe,
            message_new, message_edit, message_delete, error.
          query_params:
            - name: token
              type: string
              description: JWT — alternative to Authorization header (untuk browser yang tidak bisa set header).
          example_response: |
            # First frame yang server kirim setelah upgrade:
            {
              "type": "hello_ack",
              "data": {
                "user_id":     "874f0605-19d7-4e18-8aef-c544d9620157",
                "session_id":  "4c6d67b6-228f-40b1-b765-72ea130a34de",
                "server_time": "2026-05-19T01:23:01.610Z"
              }
            }
flow_diagram_nodes:
    - id: web
      label: "\U0001F310 chat-web"
      type: client
      color: '#0ea5e9'
    - id: chat
      label: "\U0001F4AC chat-service"
      type: service
      color: '#4f46e5'
    - id: account
      label: "\U0001F510 account-service"
      type: service
      color: '#360185'
    - id: pg
      label: "\U0001F418 PostgreSQL"
      type: data
      color: '#10b981'
    - id: scylla
      label: "\U0001FAA8 ScyllaDB"
      type: data
      color: '#10b981'
    - id: redis
      label: "\U0001F4E1 Redis pub/sub"
      type: queue
      color: '#8b5cf6'
    - id: jwt
      label: "\U0001F511 JWT (RS256)"
      type: data
      color: '#f59e0b'
flow_diagram_edges:
    - source: web
      target: chat
      label: REST + wss
      animated: true
      color: '#0ea5e9'
    - source: web
      target: account
      label: login/refresh
      animated: true
      color: '#0ea5e9'
    - source: account
      target: jwt
      label: issues
      color: '#f59e0b'
    - source: chat
      target: jwt
      label: verify (JWKS)
      color: '#f59e0b'
    - source: chat
      target: pg
      label: channels, members, audit
      color: '#4f46e5'
    - source: chat
      target: scylla
      label: messages (ULID DESC)
      color: '#4f46e5'
    - source: chat
      target: redis
      label: publish event
      animated: true
      color: '#4f46e5'
    - source: redis
      target: chat
      label: fan-out subscribers
      animated: true
      color: '#8b5cf6'
      style: dashed
    - source: chat
      target: web
      label: ws frames
      animated: true
      color: '#4f46e5'
      style: dashed
api_tester_defaults:
    methods:
        - GET
        - POST
        - PATCH
        - DELETE
        - PUT
    auth_modes:
        - name: JWT Bearer
          header: Authorization
          prefix: 'Bearer '
          placeholder: YOUR_JWT_TOKEN_HERE
events:
    - id: ws-subscribe
      title: subscribe / unsubscribe
      description: |
        Subscribe atau unsubscribe ke realtime events untuk channel. Server
        check membership untuk setiap `channel_id` — non-member dikirim
        `error` dengan code `not_member`. Server reply `subscribed` dengan
        list `channel_ids` yang akhirnya tersubscribed.

        User-private topic (`chat:user:{user_id}`) auto-subscribed saat
        handshake — client tidak perlu subscribe manual.
      protocol: websocket
      address: /ws
      operations:
        - type: subscribe
          summary: Client kirim `subscribe` frame
          description: Daftar channel_ids untuk mulai terima realtime events.
          payload:
            - name: type
              type: string
              required: true
              description: '`subscribe`'
            - name: id
              type: string
              description: Client correlation id (echoed di reply).
            - name: data.channel_ids
              type: uuid[]
              required: true
              description: List channel UUID.
          example: |
            {
              "type": "subscribe",
              "id":   "client-correlation-1",
              "data": { "channel_ids": ["27e3cd2b-…","6805901e-…"] }
            }
        - type: publish
          summary: Server reply `subscribed` frame
          description: Echo list channel_ids yang berhasil di-subscribe.
          example: |
            {
              "type": "subscribed",
              "id":   "client-correlation-1",
              "data": { "channel_ids": ["27e3cd2b-…"] }
            }
    - id: ws-message-new
      title: message_new (server push)
      description: |
        Server push untuk message baru di channel yang caller subscribe. Diterima
        baik dari diri sendiri (race REST response) maupun dari member lain —
        **client wajib dedup by `data.data.id`** kalau optimistic-append dari
        REST response.
      protocol: websocket
      address: chat:ch:{channel_id}
      operations:
        - type: publish
          summary: Server → Client
          description: Frame yang server kirim saat message_new event ke topic.
          example: |
            {
              "type": "message_new",
              "data": {
                "channel_id": "27e3cd2b-…",
                "ts":         "2026-05-18T23:37:59.522Z",
                "data": {
                  "id":           "01KRYQ86F25R3CGYXTTB5PN0FY",
                  "channel_id":   "27e3cd2b-…",
                  "author_id":    "874f0605-…",
                  "author_kind":  "human",
                  "body":         "Realtime fanout via REST",
                  "content_type": "text/plain",
                  "reply_to_id":  "",
                  "mentions":     null,
                  "created_at":   "2026-05-18T23:37:59.522Z"
                }
              }
            }
    - id: ws-message-edit
      title: message_edit (server push)
      description: |
        Body & edited_at update. `data.data` hanya carry field yang berubah
        (`id`, `body`, `edited_at`), bukan full message envelope.
      protocol: websocket
      address: chat:ch:{channel_id}
      operations:
        - type: publish
          summary: Server → Client
          example: |
            {
              "type": "message_edit",
              "data": {
                "channel_id": "27e3cd2b-…",
                "data": {
                  "id":        "01KRYQ86F25R…",
                  "body":      "Edited via REST",
                  "edited_at": "2026-05-18T23:38:00.123Z"
                }
              }
            }
    - id: ws-message-delete
      title: message_delete (server push)
      description: |
        Soft-delete event. Client render placeholder untuk `message_id` ini.
        Row tetap ada di Scylla untuk ordering + audit.
      protocol: websocket
      address: chat:ch:{channel_id}
      operations:
        - type: publish
          summary: Server → Client
          example: |
            {
              "type": "message_delete",
              "data": {
                "channel_id": "27e3cd2b-…",
                "data": {
                  "channel_id": "27e3cd2b-…",
                  "message_id": "01KRYQ86F25R…"
                }
              }
            }
    - id: ws-read-marker
      title: read_marker (server push)
      description: |
        Broadcast saat seorang member meng-set read marker (REST `POST
        /channels/{channel-id}/read-marker`). Dipakai untuk **read receipt (✓✓)**:
        pesan kita dengan `id <= data.data.last_read_id` (ULID lexicographic)
        sudah dibaca oleh `data.user_id`. Untuk DM ini menggerakkan ✓✓ biru;
        untuk grup, agregasi "dibaca semua" adalah tanggung jawab klien (server
        hanya menyiarkan per-user). Best-effort — tak menutup koneksi bila gagal.
      protocol: websocket
      address: chat:ch:{channel_id}
      operations:
        - type: publish
          summary: Server → Client
          description: Frame yang server kirim saat ada read marker baru di topic.
          example: |
            {
              "type": "read_marker",
              "data": {
                "channel_id": "27e3cd2b-…",
                "user_id":    "874f0605-…",
                "ts":         "2026-05-26T16:00:00.000Z",
                "data": {
                  "last_read_id": "01KRYQ86F25R3CGYXTTB5PN0FY"
                }
              }
            }
    - id: ws-ping
      title: ping / pong
      description: |
        Application-level ping (selain WS frame-level ping/pong gorilla yang
        jalan auto). Berguna untuk client RTT measurement.
      protocol: websocket
      address: /ws
      operations:
        - type: subscribe
          summary: Client → Server
          example: |
            { "type": "ping", "id": "rtt-test-1" }
        - type: publish
          summary: Server → Client (pong echo)
          example: |
            { "type": "pong", "id": "rtt-test-1" }
    - id: ws-error
      title: error (server push)
      description: |
        Per-request error reply. Carries stable `code` untuk client routing
        plus human-readable `message`. **Tidak menutup koneksi** — sekedar
        response ke frame yang failed.

        Stable error codes:

        | Code | Meaning |
        |---|---|
        | `invalid_frame` | JSON parse fail atau payload shape salah |
        | `unsupported_type` | `type` tidak dikenal |
        | `permission_denied` | Authorization fail di JWT permissions |
        | `not_member` | Subscribe attempt ke channel bukan member |
        | `rate_limited` | Future feature (Step 7) |
        | `session_revoked` | Session di-logout di account-service |
        | `max_connections_per_user` | Boot saat conn ke-9 (oldest FIFO) |
        | `internal_error` | Generic 5xx — biasanya transient |
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Client
          example: |
            {
              "type": "error",
              "id":   "client-correlation-1",
              "data": {
                "code":    "not_member",
                "message": "not a member of channel cfa1942f-…"
              }
            }
    - id: ws-typing
      title: typing (bi-directional)
      description: |
        Client sends `typing` frame when user starts/stops typing in the
        composer. Server fans out to other channel subscribers (skip self).
        Rate-limited per (conn, channel) to 1 broadcast per 3s.
        Server uses `conn.UserID` (JWT-validated) not the payload — anti-spoof.
      protocol: websocket
      address: /ws
      operations:
        - type: subscribe
          summary: Client → Server
          example: |
            { "type": "typing", "data": { "channel_id": "cfa1942f-…", "is_typing": true } }
        - type: publish
          summary: Server → Other subscribers
          example: |
            {
              "type": "typing",
              "data": {
                "channel_id": "cfa1942f-…",
                "user_id":    "<UUID of typer>",
                "is_typing":  true
              }
            }
    - id: ws-presence
      title: presence (server push)
      description: |
        Fired when a user transitions online ↔ offline. Online = Redis
        presence key exists (TTL 90s, refreshed every 30s by an active WS
        conn). Offline = TTL expired OR last connection closed cleanly.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Client
          example: |
            {
              "type": "presence",
              "data": { "user_id": "...", "status": "online" }
            }
    - id: ws-reaction
      title: reaction (server push)
      description: |
        Fan-out of reaction add/remove. Companion to REST endpoints in the
        Reactions section.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Client
          example: "{\n  \"type\": \"reaction\",\n  \"data\": {\n    \"channel_id\": \"...\",\n    \"message_id\": \"01KS…\",\n    \"user_id\":    \"...\",\n    \"emoji\":      \"\U0001F44D\",\n    \"op\":         \"add\"\n  }\n}\n"
    - id: ws-mention
      title: mention (per-user push)
      description: |
        Published on the `chat:user:{user_id}` topic (not channel topic), so
        the user gets a high-priority notification (toast + ping bypassing
        mute) even if they're not currently focused on that channel.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Mentioned user only
          example: |
            {
              "type": "mention",
              "data": {
                "channel_id": "...",
                "message_id": "01KS…",
                "author_id":  "...",
                "body_preview": "@you check the deploy at 17:00…"
              }
            }
    - id: ws-member
      title: member_added / member_removed (server push)
      description: Fired when channel roster changes.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Subscribed clients
          example: |
            {
              "type": "member_added",
              "data": { "channel_id": "...", "user_id": "..." }
            }
    - id: ws-message-pinned
      title: message_pinned / message_unpinned (server push)
      description: |
        Pin/unpin event. Companion to REST endpoints in the Pinned section.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Subscribed clients
          example: |
            {
              "type": "message_pinned",
              "data": {
                "channel_id": "...",
                "message_id": "01KS…",
                "pinned_by":  "..."
              }
            }
    - id: ws-message-expired
      title: message_expired (server push)
      description: |
        Fired by the ephemeral-message sweeper when a message's TTL elapses.
        FE renders distinct "⏱ pesan hilang" placeholder (separate from
        regular `message_delete`).
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Subscribed clients
          example: |
            {
              "type": "message_expired",
              "data": { "channel_id": "...", "message_id": "01KS…" }
            }
    - id: ws-message-interaction
      title: message.interaction (server push)
      description: |
        Fired when a user clicks an inline-keyboard callback button. The bot
        that owns the message subscribes to the channel topic to receive
        these — typically responds by `PATCH`ing the original message to
        acknowledge.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: Server → Subscribed clients
          example: |
            {
              "type": "message.interaction",
              "data": {
                "channel_id":   "...",
                "message_id":   "01KS…",
                "user_id":      "<clicker>",
                "action_id":    "approve",
                "action_label": "✅ Approve"
              }
            }
    - id: ws-call
      title: call.* events (voice/video signaling)
      description: |
        WebRTC signaling tunneled over WS. Six event types — three are
        broadcast on the channel topic (lifecycle: invite, join, leave,
        end) and three are routed peer-to-peer via the `chat:user:{user_id}`
        topic (SDP/ICE exchange).

        Server anti-spoof: `from_user_id` is locked to `conn.UserID`, payload
        contents (SDP/ICE) are opaque and forwarded as-is.
      protocol: websocket
      address: /ws
      operations:
        - type: publish
          summary: call.invite (server → channel)
          example: |
            {
              "type": "call.invite",
              "data": {
                "call_id":      "...",
                "channel_id":   "...",
                "initiator_id": "...",
                "type":         "video",
                "status":       "dialing",
                "started_at":   "..."
              }
            }
        - type: publish
          summary: call.participant_joined (server → channel)
          example: |
            {
              "type": "call.participant_joined",
              "data": {
                "call_id":    "...",
                "channel_id": "...",
                "user_id":    "...",
                "status":     "active"
              }
            }
        - type: publish
          summary: call.participant_left (server → channel)
          example: |
            {
              "type": "call.participant_left",
              "data": {
                "call_id":    "...",
                "channel_id": "...",
                "user_id":    "...",
                "leave_code": "hangup"
              }
            }
        - type: publish
          summary: call.end (server → channel)
          example: |
            {
              "type": "call.end",
              "data": {
                "call_id":     "...",
                "channel_id":  "...",
                "end_reason":  "hangup",
                "duration_ms": 359
              }
            }
        - type: subscribe
          summary: call.sdp_offer / sdp_answer / ice (client → server, routed to peer)
          example: |
            {
              "type": "call.sdp_offer",
              "data": {
                "call_id":    "...",
                "channel_id": "...",
                "to_user_id": "<recipient>",
                "kind":       "sdp_offer",
                "payload":    { "type": "offer", "sdp": "v=0\\r\\n…" }
              }
            }
        - type: publish
          summary: call.sdp_offer (server → recipient peer)
          example: |
            {
              "type": "call.sdp_offer",
              "data": {
                "call_id":      "...",
                "channel_id":   "...",
                "from_user_id": "<offerer>",
                "to_user_id":   "<recipient>",
                "kind":         "sdp_offer",
                "payload":      { "type": "offer", "sdp": "v=0\\r\\n…" }
              }
            }
theme:
    title: Chat · Ikavia
    logo_icon: "\U0001F4AC"
    primary_color: '#4f46e5'
