{"info":{"title":"Chat Service","version":"0.1.0","description":"Realtime team-chat service untuk ekosistem ikavia. Backend Go (gin + GORM +\ngocql + gorilla/websocket), data berlapis: **PostgreSQL** untuk metadata\n(channels, memberships, audit), **ScyllaDB** untuk message body (ULID-\nordered), **Redis pub/sub** untuk multi-instance fanout antar WS gateway.\n\nAuth delegated ke **Account Service** — JWT RS256 ditandatangani di\n`account.ikavia.com` + verified via `/api/v1/auth/public-key`. Setiap\nrequest authenticated lazy-create row di `users` table chat-side (1:1 ke\nJWT `sub`).\n","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":"💬","title":"REST + WebSocket","description":"Identical message model via either surface","content":"Same envelope shape (`{success, data, request_id}`) untuk REST. WS\nframe (`{type, id, data}`) untuk realtime events. Send via `POST\n/api/v1/channels/{channel-id}/messages` → persist Scylla → publish\nRedis topic `chat:ch:{channel_id}` → fan-out ke WS subscribers di\ninstance manapun.\n"},{"icon":"🆔","title":"Identity model","description":"Two-layer — account_id (JWT) ↔ chat user.id (local)","content":"- **`account_id`** = JWT `sub`, dimiliki account-service (otoritas).\n- **`user_id`** = chat-local UUID di tabel `users`. Lazy-created saat\n  principal pertama kali hit endpoint authenticated.\n- **`org_id`** dari JWT `org_id` claim — strict per-org isolation\n  (channels, messages, memberships semua scoped by org).\n- **`principal_type`** = `human` atau `service` (bot/service account).\n"},{"icon":"🆙","title":"Channel kinds","description":"channel | dm | group_dm","content":"- **`channel`** — named, public-by-default, owner+admins+members.\n  Bisa `is_private=true` untuk invite-only.\n- **`dm`** — 1:1 direct message. Idempotent canonical pair (sortir\n  `user_id` lalu hash → `metadata.dm_pair`). `POST /api/v1/dm`\n  dari A→B vs B→A return channel_id sama.\n- **`group_dm`** — 3-9 user, tidak bisa di-rename/archive.\n"},{"icon":"🧾","title":"Standard envelope","description":"Match ikavia pattern (snake_case JSON, kebab-case URL)","content":"Success:\n```json\n{ \"success\": true, \"data\": { ... }, \"request_id\": \"uuid\" }\n```\nError:\n```json\n{ \"success\": false, \"message\": \"...\", \"request_id\": \"uuid\" }\n```\nPagination response shape:\n```json\n{\n  \"items\": [...],\n  \"pagination\": {\n    \"page\": 1, \"per_page\": 50,\n    \"total\": 123, \"total_pages\": 3\n  }\n}\n```\n"},{"icon":"🔐","title":"Permission model","description":"Org-level RBAC inherited dari account-service grants","content":"Chat endpoints gate-keep dengan permission claim di JWT. Required:\n\n| Endpoint family | Permission required |\n|---|---|\n| `POST /channels`               | `channel:create` |\n| `PATCH /channels/{id}`         | `channel:update` |\n| `DELETE /channels/{id}`        | `channel:archive` |\n| `POST /channels/{id}/members`  | `channel:invite` |\n| `DELETE …/members/{user-id}`   | `channel:remove_member` |\n| `POST /channels/{id}/messages` | `chat:write` |\n| `GET …/messages`               | (channel member auto-OK) |\n\nWildcards `*` dan `chat:*` di-honor. Service principals (bot) tetap\nkena permission gate — tidak ada bypass.\n"},{"icon":"📡","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,\ndb=6 prod). Topics:\n\n- `chat:ch:{channel_id}` — message events\n- `chat:user:{user_id}` — per-user events (membership added, badges)\n- `chat:org:{org_id}` — org-wide announcements (future)\n\nWS hub di-tiap-instance subscribe Redis untuk topics yang ada conn\naktif. Saat user pindah load-balancer node, langganan WS re-build\notomatis pakai topic state in-process.\n"},{"icon":"📚","title":"Storage layout","description":"Per-stack isolation untuk prod vs staging","content":"| Layer | Staging | Production |\n|---|---|---|\n| PostgreSQL | DB `chat`, role `chat_app` | DB `chat_prod`, role `chat_app_prod` |\n| ScyllaDB | keyspace `chat`, RF=3 | keyspace `chat_prod`, RF=3 |\n| Redis | db=5, prefix `chat:` | db=6, prefix `chat_prod:` |\n| JWT issuer | `account-service-dev` | `account-service` |\n\nTidak ada cross-stack data bleed — di-verify via test `GET prod-channel\nvia dev-token` → `404 channel not found`.\n"},{"icon":"🔄","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\nswitch via account-service untuk pindah konteks:\n\n```\nPOST https://account.ikavia.com/api/v1/organizations/{org_id}/switch\nAuthorization: Bearer \u003ccurrent_jwt\u003e\nBody: {}\n→ 200 { access_token, refresh_token, org_id (new) }\n```\n\nChat-service tidak pegang switcher sendiri — cukup pakai JWT baru di\nrequest berikutnya. Lazy-create di `users` table emit row terpisah\nper (account_id, org_id) → tiap context punya `user.id` sendiri.\n\n**Visibility rule saat switch:**\n\n| Channel kind | Visible setelah switch ke org B? |\n|---|---|\n| Org A internal (`is_public=false`) | ❌ Hilang (membership scoped per-org) |\n| Org A private | ❌ Hilang |\n| Public (cross-org, `is_public=true`) | ✅ Tetap visible |\n| DM peer di org B | ✅ Visible (DM canonical pair tetap aktif) |\n| DM peer di org A | ❌ Hilang sementara |\n\nFrontend `chat-web` punya dropdown switcher di top-bar yang call\nendpoint ini, simpan token baru di localStorage, tear-down WS, lalu\nre-bootstrap chat data untuk org target.\n"}]},"authentication":{"methods":[{"type":"JWT Bearer","header":"Authorization","format":"Bearer \u003caccess_token\u003e","source":"account-service","description":"RS256 JWT diterbitkan oleh **account-service** (prod: `account.ikavia.com`,\nstaging: `account-dev.ikavia.com`). Chat-service verify signature via JWKS\nendpoint dengan cache + auto-refresh saat kid berubah.\n","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_\u003crandom\u003e","source":"account-service (issuing) + chat-service (transparent exchange)","description":"Service-to-service auth for bots / integrations. Sent in `X-API-Key`\nheader directly to any chat-service endpoint — server transparently\nexchanges with account-service and caches the resulting JWT in-memory\n(per-replica, 60s pre-exp refresh). Bot integration code does NOT\nneed to call `/auth/token-exchange` manually.\n\nHeader precedence: `X-API-Key` \u003e `Authorization: Bearer` (kalau\nboth ada). Audit `actor_kind=\"service\"` otomatis (dari resulting\nJWT's `principal_type`).\n","note":"For WebSocket (no header support after the upgrade handshake), bots\nexchange once via `/auth/token-exchange` and pass JWT in `?token=`\nquery param.\n"}]},"flow_overview":{"methods":[{"type":"JWT Bearer","steps":[{"title":"Login (atau token-exchange)","detail":"Human: `POST https://account.ikavia.com/api/v1/auth/login` dengan\nemail+password → terima access_token + refresh_token.\nService principal: `POST /auth/token-exchange` dengan X-API-Key →\nterima 1-hour JWT.\n"},{"title":"Switch org saat user multi-tenant","detail":"User member di banyak org → satu org aktif per JWT. Untuk pindah:\n\n```\nPOST https://account.ikavia.com/api/v1/organizations/{org_id}/switch\nAuthorization: Bearer \u003ccurrent_jwt\u003e\n```\n\nResponse berisi access_token + refresh_token baru yang scoped ke\norg target. Replace token lokal, panggil ulang `GET /api/v1/me`\ndi chat-service untuk lazy-create chat user di org baru, lalu\nlist channels akan berubah ke konteks org tersebut.\n\n**List my orgs**: `GET https://account.ikavia.com/api/v1/organizations`\n→ `{items: [{org_id, name, my_role, ...}]}` — sumber data untuk\ndropdown switcher di UI.\n\n**Per-org boundary tetap kuat** untuk channel non-public — kalau\norg A punya channel `secret-stuff`, switch ke org B akan hilang\ndari list. Channel `is_public=true` tetap visible cross-org.\n"},{"title":"Hit endpoint chat","detail":"Setiap request: `Authorization: Bearer \u003caccess_token\u003e`. First call\nauto lazy-create chat user row (1:1 ke JWT.sub). Subsequent call\nlookup existing.\n"},{"title":"Refresh saat 401","detail":"Access token TTL ~15 menit. Saat 401, panggil\n`POST /auth/refresh` di account-service untuk dapat token baru\n(atau pakai refresh_token cookie SSO `.ikavia.com`).\n"},{"title":"Open WebSocket (realtime)","detail":"`wss://chat.ikavia.com/ws?token=\u003cjwt\u003e` atau pakai Authorization\nheader. Subprotocol `ikavia-chat-v1`. Subscribe ke channel\nvia `{\"type\":\"subscribe\",\"data\":{\"channel_ids\":[...]}}`.\n"}]}]},"sections":[{"id":"attachments","title":"Attachments (Signed URL + Cleanup)","description":"Chat-service tidak menyimpan file — upload dilakukan FE ke\n`media.ikavia.com` (separate service). Yang chat-service simpan adalah\npointer di `metadata.attachments[]` pada message row.\n\n**Note share_url format:** media-service mengeluarkan share-link\ndi `https://media.ikavia.com/share/\u003ctoken\u003e` (tanpa prefix\n`/api/v1/`). Lihat docs media-service \"Shared Links\" untuk\ndetail kenapa path-nya terpisah dari `/api/v1`.\n\n**Backward-compat normalize:** untuk attachment historis yang\nsempat ter-publish dengan URL lama `/api/v1/share/\u003ctoken\u003e`\n(sebelum media-service di-fix), chat-service `Access` handler\nstrip prefix saat redirect — `s|/api/v1/share/|/share/|` di\nruntime. Storage value tidak ikut di-rewrite. Aman pakai\nkembali walaupun ada attachment lama nyangkut di backup\nrestore atau service yang lupa update.\n\n```json\n{\n  \"attachments\": [\n    {\n      \"media_id\":  \"abc-uuid\",\n      \"share_url\": \"https://media.ikavia.com/share/xyz\",\n      \"mime\":      \"image/jpeg\",\n      \"kind\":      \"image\",\n      \"filename\":  \"ktp.jpg\",\n      \"size\":      123456\n    }\n  ]\n}\n```\n\n**Access control problem.** `share_url` dari media-service bersifat\npermanen + publik — siapa pun yang dapat URL bisa akses tanpa cek\nmembership channel. Risk: link bocor → outsider lihat file internal.\n\n**Solusi (per ADR Step 6 close-out 2026-05-19).** Chat-service issue\nsigned URL HMAC-SHA256 dengan TTL 1 jam. Browser pakai signed URL\nuntuk render attachment (`\u003cimg src=...\u003e`). Saat signed URL di-hit,\nchat-service verify HMAC + expire, audit `attachment.viewed`/\n`attachment.downloaded`, dan 302 ke share_url permanent. Setelah 1\njam URL basi, FE auto-refresh transparan.\n\n**Lifecycle file.** File ikut dihapus dari media-service saat:\n- Message dihapus (oleh siapa pun: uploader atau admin), atau\n- Message di-edit dan attachment dilepas dari metadata.\n\nAksi delete dilakukan asynchronous (goroutine) — request HTTP return\nduluan. Best-effort. Setiap percobaan delete di-audit sebagai\n`attachment.released` dengan reason `message_deleted` atau\n`message_edited`.\n","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.\nMembership channel di-verify per item; item invalid masuk hasil\ndengan `ok=false` + `error` (batch tidak fail keseluruhan).\n\nFE biasanya panggil ini saat normalize message list (debounced\n100ms), kemudian replace `share_url` dengan `signed_url` di\nrender template.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"media_id\":  \"abc-uuid\",\n        \"signed_url\": \"https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?c=27e3...\u0026m=01K...\u0026u=874f...\u0026e=1779189144\u0026a=view\u0026t=11d2f44b...\",\n        \"expires_at\": \"2026-05-19T11:12:24Z\",\n        \"ok\": true\n      }\n    ]\n  }\n}\n"},{"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.\n\nVerify HMAC + expire → audit (`attachment.viewed` atau\n`attachment.downloaded`) → 302 redirect ke `share_url` permanent\nmedia-service.\n\nURL ini boleh embedded langsung di `\u003cimg src\u003e`, `\u003cvideo\u003e`, dst —\nbrowser akan follow 302 ke media-service.\n","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\n+ wildcard `*` bypass. Tiap item sudah include `signed_url` siap\npakai — FE tidak perlu re-sign.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"media_id\":   \"abc-uuid\",\n        \"signed_url\": \"https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?...\",\n        \"expires_at\": \"2026-05-19T11:12:24Z\",\n        \"kind\":       \"image\",\n        \"mime\":       \"image/jpeg\",\n        \"filename\":   \"ktp.jpg\",\n        \"size\":       123456,\n        \"channel_id\": \"27e3...\",\n        \"message_id\": \"01KR...\",\n        \"sender_id\":  \"874f...\",\n        \"sent_at\":    \"2026-05-19T10:00:00Z\"\n      }\n    ],\n    \"next_cursor\": \"01KR...\"\n  }\n}\n"}]},{"id":"auth-helpers","title":"Auth helpers (account-service)","description":"Endpoint yang chat-web call ke **account-service** (beda host dari\nchat-service). Wajib di-implement caller untuk dapatkan/refresh JWT\ndan switch org context.\n\n⚠️ Endpoint ini di-host di `account.ikavia.com`, bukan `chat.ikavia.com`.\nAuthoritative documentation ada di [account docs](/docs?p=account) —\ndi sini cuma copy yang relevan untuk chat integration.\n","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\ndi-set sebagai HttpOnly cookie scoped `.ikavia.com` untuk SSO\ncross-subdomain.\n","body":[{"name":"email","type":"string","required":true},{"name":"password","type":"string","required":true}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\":   \"uuid\",\n    \"email\":        \"user@example.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"\u003cjwt\u003e\",\n    \"refresh_token\": \"\u003cjwt\u003e\",\n    \"expires_in\":    900,\n    \"organizations\": [{ \"id\": \"uuid\", \"role\": \"owner\" }]\n  }\n}\n"},{"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\npopulate org-switcher dropdown.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"org_id\":       \"073bebe9-…\",\n        \"name\":         \"Acme Inc\",\n        \"slug\":         \"acme-inc\",\n        \"status\":       \"active\",\n        \"owner_id\":     \"uuid\",\n        \"member_count\": 42,\n        \"my_role\":      \"owner\"\n      }\n    ],\n    \"total\": 1, \"page\": 1, \"per_page\": 20, \"total_pages\": 1\n  }\n}\n"},{"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.\nSession ID (`sid`) preserved — bukan re-login. Refresh token\njuga di-rotate (lama otomatis ke-revoke saat next refresh).\n\nSetelah dapat token baru, **caller harus tear-down WebSocket lama**\ndan reconnect dengan token baru — server gateway tidak punya\nmechanism in-flight untuk re-auth. Frontend juga harus\ncall ulang `/api/v1/me` chat-service untuk lazy-create chat user\ndi org target.\n","body":[{"name":"(empty body)","type":"—","description":"POST dengan body `{}` cukup. Org_id di path."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"account_id\":   \"uuid\",\n    \"email\":        \"user@example.com\",\n    \"display_name\": \"Jane Doe\",\n    \"access_token\": \"\u003cnew jwt scoped to target org\u003e\",\n    \"refresh_token\":\"\u003crotated\u003e\",\n    \"expires_in\":   900,\n    \"organizations\":[ ... ]\n  }\n}\n"},{"name":"Refresh token","method":"POST","path":"/api/v1/auth/refresh","auth":"none","description":"Pakai refresh_token (body atau HttpOnly cookie) → terima access\nbaru. Setiap refresh rotate refresh token; presentasi refresh\nlama setelah rotation akan revoke seluruh session (reuse\ndetection).\n\nChat-web kasih ini ke fetch interceptor: pada 401 dari chat,\ncoba refresh sekali → retry request asli; gagal → redirect ke\nlogin.\n","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\ntoken segera berhenti valid di seluruh service yang verify via\nJWKS + revocation cache. Cookie refresh juga di-clear.\n\nChat-service akan tutup WebSocket aktif untuk sid tersebut via\n`error{code: session_revoked}` frame + close.\n"},{"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`\nheader and receive a short-lived JWT scoped to the service\naccount's permissions. JWT carries `principal_type=service`.\n\n**Note for chat-service callers:** you do NOT need to call this\nendpoint directly. chat-service accepts `X-API-Key` natively on\nevery authenticated route — server exchanges + caches the JWT\ninternally (in-memory cache per replica, 60s pre-exp refresh).\nSee the dedicated **Bot integration** section in the chat-service\nREADME for the one-step pattern.\n\nUse this endpoint when:\n  - You need the raw JWT for WS auth (`wss://chat.ikavia.com/ws?token=\u003cjwt\u003e`)\n  - You're calling a service that does NOT support X-API-Key dual-mode\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"access_token\": \"eyJhbGciOiJSUzI1NiI…\",\n    \"expires_in\": 3600,\n    \"token_type\": \"Bearer\",\n    \"principal_type\": \"service\"\n  }\n}\n"}]},{"id":"calls","title":"Voice \u0026 Video Calls","description":"Mesh WebRTC calling (≤4 peers per group) via Cloudflare Realtime TURN.\nSignaling SDP/ICE messages tunnel through the existing WebSocket gateway\n(see WebSocket section, frame types `call.sdp_offer` / `sdp_answer` /\n`ice`). Call lifecycle (start/join/leave/end) goes through these REST\nendpoints which fan-out `call.invite` / `call.participant_joined` /\n`call.participant_left` / `call.end` events.\n\nA background sweeper promotes calls stuck in `dialing` past the 60-second\ntimeout to `missed` and publishes `call.end` so the initiator's UI can\nauto-dismiss the outgoing modal.\n","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\ncredentials. The browser uses these to negotiate WebRTC. TTL is\nrequested in the body (default 3600s, server clamps to 60..86400).\n\nResponse shape matches `RTCConfiguration.iceServers` so the FE can\npass it through to `new RTCPeerConnection({ iceServers })` with\nminimal massaging.\n","body":[{"name":"ttl_seconds","type":"int","description":"Lifetime of credentials in seconds (60..86400, default 3600)"}],"example_body":"{ \"ttl_seconds\": 3600 }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"ice_servers\": [\n      { \"urls\": [\"stun:stun.cloudflare.com:3478\"] },\n      {\n        \"urls\": [\n          \"turn:turn.cloudflare.com:3478?transport=udp\",\n          \"turns:turn.cloudflare.com:5349?transport=tcp\"\n        ],\n        \"username\": \"g070e2…\",\n        \"credential\": \"ca7a41…\"\n      }\n    ],\n    \"expires_at\": \"2026-05-20T13:00:00Z\",\n    \"ttl_seconds\": 3600\n  }\n}\n"},{"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\nrow with `status=dialing`, adds initiator as a participant, then\nfans out `call.invite` on the channel topic so other members'\ndevices ring.\n","body":[{"name":"type","type":"enum(\"audio\"|\"video\")","required":true,"description":"audio or video"}],"example_body":"{ \"type\": \"video\" }\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"9bf2bc06-c00c-4cc1-ab06-5ba9fcc98f87\",\n    \"org_id\": \"...\",\n    \"channel_id\": \"...\",\n    \"initiator_id\": \"...\",\n    \"type\": \"video\",\n    \"status\": \"dialing\",\n    \"started_at\": \"2026-05-20T08:13:33.458841Z\"\n  }\n}\n"},{"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\nfirst non-initiator join. Returns 422 if call has already ended or\nmesh capacity (4) is full.\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"status\": \"active\", ... }\n}\n"},{"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\nthe last active participant leaves, the call transitions to\n`status=ended` automatically and `call.end` is published.\n","body":[{"name":"code","type":"enum(\"hangup\"|\"disconnect\"|\"rejected\"|\"permission_denied\")","description":"Why we're leaving — default 'hangup'"}],"example_body":"{ \"code\": \"hangup\" }\n","example_response":"{ \"success\": true, \"data\": { \"ok\": true } }\n"},{"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\n`call.end`. Duration is recorded in milliseconds in the response.\n","body":[{"name":"reason","type":"string","description":"End reason, free-form (defaults to 'hangup')"}],"example_body":"{ \"reason\": \"hangup\" }\n","example_response":"{ \"success\": true, \"data\": { \"ended\": true } }\n"},{"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\n(active / ended / missed / failed). Used by the chat-web \"Call\nhistory\" panel.\n","query_params":[{"name":"limit","type":"int","default":"50","description":"Max items, ≤100"}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"...\", \"type\": \"video\",\n        \"status\": \"ended\", \"end_reason\": \"hangup\",\n        \"started_at\": \"...\", \"ended_at\": \"...\",\n        \"duration_ms\": 359, \"initiator_id\": \"...\"\n      }\n    ]\n  }\n}\n"}]},{"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 \u0026\u0026 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** 🌐              | 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`).\nFilter optional via query.\n\nTiap item di-dekorasi per-caller: `is_member`, `last_read_id`, dan\n**`unread_count`** = jumlah pesan setelah `last_read_id` (di-cap 99 →\nklien tampilkan \"99+\" bila ≥99; 0 = sudah terbaca semua). Dihitung\nhanya untuk channel yang ada unread (range-scan Scylla ter-bound).\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\":             \"27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf\",\n        \"org_id\":         \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n        \"kind\":           \"channel\",\n        \"name\":           \"general\",\n        \"topic\":          \"All-hands\",\n        \"is_private\":     false,\n        \"is_archived\":    false,\n        \"e2e_enabled\":    false,\n        \"created_by\":     \"874f0605-19d7-4e18-8aef-c544d9620157\",\n        \"message_count\":  4,\n        \"last_message_id\":\"01KRYQ7K1JQX75GVZ5PT06Y4FY\",\n        \"last_message_at\":\"2026-05-18T23:37:39.634Z\",\n        \"is_member\":      true,\n        \"last_read_id\":   \"01KRYQ7B0000000000000000\",\n        \"unread_count\":   3,\n        \"created_at\":     \"2026-05-18T23:08:21Z\",\n        \"updated_at\":     \"2026-05-18T23:37:39Z\"\n      }\n    ],\n    \"pagination\": { \"page\": 1, \"per_page\": 20, \"total\": 2, \"total_pages\": 1 }\n  }\n}\n"},{"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\n(public channels visible org-wide); private channel data hanya\nterlihat kalau caller member.\n"},{"name":"Create channel","method":"POST","path":"/api/v1/channels","auth":"JWT Bearer","description":"Create channel baru. Creator auto-jadi owner-membership.\n`kind` selalu `channel` — DM / group_dm pakai endpoint dedicated.\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"id\":          \"fc695580-e54e-4f9f-9101-55a0b078fc58\",\n    \"kind\":        \"channel\",\n    \"name\":        \"general\",\n    \"is_private\":  false,\n    \"created_by\":  \"...\",\n    ...\n  }\n}\n"},{"name":"Update channel","method":"PATCH","path":"/api/v1/channels/{channel-id}","auth":"JWT Bearer","description":"Patch name / topic / description / is_private. `kind`, `e2e_enabled`,\n`created_by` immutable. DM/group_dm tidak boleh di-rename.\n","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\n`archived_at` di-set. Tidak bisa kirim message baru. DM / group_dm\ntidak boleh di-archive.\n","example_response":"{ \"success\": true, \"data\": { \"id\": \"...\", \"is_archived\": true, \"archived_at\": \"...\" } }\n"},{"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,\ntanpa invite. Endpoint ini **menolak** kalau channel `is_public=false`\n(private/org-internal pakai invite flow biasa).\n\nIdempotent: kalau caller sudah member, return 200 dengan channel\nexisting (bukan 409). Caller jadi role `member` (owner cuma creator).\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"is_public\": true, \"name\": \"public-square\" }\n}\n"}]},{"id":"compliance","title":"Compliance (Export / Anonymize)","description":"GDPR-style data-subject endpoints. Self-service: a user can dump their\nown data or trigger an anonymization without admin involvement.\nAdmin-driven flows for forgetting-on-behalf live in account-service\nwith chat-service exposing webhook callbacks.\n","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\ncaller: user row, memberships, messages authored, reactions,\nstars, pinned, attachments referenced. Streamed for large\naccounts; gzip-encoded.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"user\": { \"id\": \"...\", \"display_name\": \"...\", ... },\n    \"memberships\": [ ... ],\n    \"messages\": [ ... ],\n    \"reactions\": [ ... ],\n    \"starred\": [ ... ],\n    \"exported_at\": \"2026-05-20T...\"\n  }\n}\n"},{"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)`.\nMessages remain (for channel-conversation integrity) but author\nfields are scrubbed of identifying info. Channel memberships are\nleft for audit but `left_at` set to now.\n\nIrreversible. account-service should be the orchestrator for\nfull-cascade anonymization across services.\n","example_response":"{ \"success\": true, \"data\": { \"anonymized\": true } }\n"}]},{"id":"cs-customer","title":"Customer Support — Customer","description":"Sesi **Customer Support (CS)**: sebuah channel jadi percakapan terdukung\ndengan lifecycle state machine `bot → waiting → assigned → resolved →\nclosed` (+ `abandoned`). Customer-initiated — customer membuka sesi, masuk\nantrian FIFO, lalu ditangani agen manusia (auto-assign atau manual claim).\n\n**Aturan kunci:**\n\n- **1 sesi aktif per customer** (lintas channel) — server menolak sesi\n  kedua selama masih `bot/waiting/assigned`. Setelah `resolved`/`closed`\n  boleh mulai sesi baru.\n- **1 sesi aktif per channel**.\n- **Skill (opsional)** — customer memilih kategori dari katalog (lihat\n  bagian *Admin*); routing memprioritaskan agen ber-skill cocok.\n- Realtime via WebSocket event `cs_session_updated` (per channel) di tiap\n  transisi status.\n","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\n`waiting` (masuk antrian), atau `bot` bila `use_bot=true` \u0026 bot org\naktif. `skill` opsional — bila diisi harus slug yang **ada \u0026 aktif** di\nkatalog.\n\nBody:\n```json\n{\n  \"channel_id\": \"uuid\",\n  \"topic\": \"status pesanan\",\n  \"priority\": \"medium\",\n  \"use_bot\": false,\n  \"skill\": \"billing\"\n}\n```\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"session_id\": \"…\", \"channel_id\": \"…\", \"status\": \"waiting\",\n    \"queue_position\": 2, \"estimated_wait_seconds\": 240, \"priority\": \"medium\"\n  }\n}\n"},{"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`),\nlintas channel. Dipakai klien untuk pre-check sebelum membuat sesi baru\n(buka yang ada daripada bikin duplikat).\n"},{"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\ndi room). Caller harus member channel.\n"},{"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:\n`{ \"reason\": \"butuh agen\", \"priority\": \"high\" }`.\n"},{"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`.\nBody: `{ \"rating\": 5, \"comment\": \"mantap\" }`.\n"}]},{"id":"cs-agent","title":"Customer Support — Agent","description":"Endpoint sisi agen: presence/skill, antrian, dan assignment. Akses butuh\nrole agen (tabel `cs_agents`) atau permission `cs:respond` / `cs:manage` /\n`*`.\n\n**Auto-assign** (feature flag `CS_AUTO_ASSIGN`): saat sesi masuk `waiting`,\nsistem meng-klaim agen online *least-loaded* yang skill-nya cocok (fallback\nke agen umum) secara **atomik** (`SELECT … FOR UPDATE` + increment dalam\nsatu transaksi) sehingga `active_count` tak pernah melebihi `max_concurrent`\nwalau multi-instance. Realtime: WS `cs.session.waiting` / `cs.session.assigned`\n(per-agen).\n","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\n`offline` bila agen belum pernah set presence.\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"status\": \"online\", \"skills\": [\"billing\"], \"max_concurrent\": 5, \"active_count\": 2 }\n}\n"},{"name":"Set my agent state","method":"PUT","path":"/api/v1/cs/agent/state","auth":"JWT Bearer","description":"Set presence + kapasitas + skills. `skills` dinormalisasi (lowercase /\ntrim / dedup) dan bersifat **lenient** — slug yang tak ada/non-aktif di\nkatalog di-drop diam-diam (bukan ditolak) supaya agen tak terkunci dari\nupdate presence.\n\nBody: `{ \"status\": \"online\", \"max_concurrent\": 5, \"skills\": [\"billing\",\"refund\"] }`\n(`status` ∈ `online|away|busy|offline`).\n"},{"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\n(`waiting_since`). Tiap item menyertakan `skill` yang diminta.\n"},{"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\nself-assign** (claim oleh caller). Transisi menjadi `assigned`.\n"},{"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\nsupervisor/admin** (`cs:manage` / `*`). Body `{ \"agent_id\": \"uuid\" }`.\n"},{"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`,\n`resolved → closed`). Body `{ \"status\": \"resolved\" }`. Transisi ilegal\nditolak.\n"}]},{"id":"cs-admin","title":"Customer Support — Admin (Agents \u0026 Skills)","description":"Pendaftaran agen + **katalog skill per-org** (managed/dinamis). Operasi\ntulis butuh admin (`cs:manage` / `chat:moderate` / `*`). `GET /cs/skills`\n(skill aktif) boleh dibaca siapa pun untuk mengisi picker kategori customer.\n","endpoints":[{"name":"List agents","method":"GET","path":"/api/v1/cs/agents","auth":"JWT Bearer","description":"Daftar agen org (decorated `account_id` + `display_name` + `role`).\nGated admin — endpoint ini juga dipakai klien untuk mendeteksi apakah\ncaller seorang admin CS (200) atau bukan (403).\n","example_response":"{\n  \"success\": true,\n  \"data\": [ { \"user_id\": \"…\", \"account_id\": \"…\", \"display_name\": \"Budi\", \"role\": \"agent\" } ]\n}\n"},{"name":"Register agent","method":"POST","path":"/api/v1/cs/agents","auth":"JWT Bearer","description":"Jadikan user agen. Pilih salah satu identifier:\n`{ \"account_id\": \"uuid\", \"display_name\": \"Budi\", \"role\": \"agent\" }`\n(dari daftar member org account-service — direkomendasikan) **atau**\n`{ \"user_id\": \"uuid\", ... }` (chat user_id). `role` ∈ `agent | supervisor`.\nGated admin.\n\nBila pakai `account_id` dan user belum punya akun chat, server\n**auto-provision** user chat (pakai `display_name`; nama final\nauto-heal saat user pertama login) → owner bisa jadikan agen member\nmana pun walau belum pernah buka chat.\n"},{"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`\nuntuk menyertakan yang non-aktif (admin only).\n","example_response":"{\n  \"success\": true,\n  \"data\": [ { \"id\": \"…\", \"slug\": \"billing\", \"label\": \"Tagihan\", \"active\": true } ]\n}\n"},{"name":"Create skill","method":"POST","path":"/api/v1/cs/skills","auth":"JWT Bearer","description":"Tambah skill ke katalog. Body `{ \"slug\": \"billing\", \"label\": \"Tagihan\" }`.\nSlug dinormalisasi lowercase dan divalidasi pola `^[a-z0-9][a-z0-9_-]{0,63}$`.\n"},{"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 }`.\nMenonaktifkan skill membuatnya tak bisa dipakai sesi baru (routing tetap\npunya fallback ke agen umum).\n"},{"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\ntetap aman (routing fallback). Gated admin.\n"},{"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\n(untuk tahu CS buka/tutup). `open_now` \u0026 `next_open_at` dihitung di\nserver (timezone-aware). `enabled=false` → CS dianggap selalu buka.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"enabled\": true,\n    \"timezone\": \"Asia/Jakarta\",\n    \"schedule\": \"{\\\"mon\\\":{\\\"open\\\":\\\"09:00\\\",\\\"close\\\":\\\"17:00\\\"}}\",\n    \"closed_message\": \"Kami buka Sen–Jum 09.00–17.00 WIB.\",\n    \"open_now\": false,\n    \"next_open_at\": \"2026-06-01T02:00:00Z\"\n  }\n}\n"},{"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\n(`mon|tue|wed|thu|fri|sat|sun`; hari tanpa entry = tutup), jam `HH:MM`\n24-jam di `timezone`. Validasi: timezone valid + `close \u003e open`.\n\nDi luar jam: sesi `waiting` **tidak** di-auto-abandon (tetap antri\nsampai buka); response sesi customer (`POST /cs/sessions`, `GET\n/cs/sessions/mine`) membawa `business_open` + `next_open_at` untuk\nmenampilkan `closed_message` + ETA.\n","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":"{\n  \"enabled\": true,\n  \"timezone\": \"Asia/Jakarta\",\n  \"schedule\": { \"mon\": {\"open\":\"09:00\",\"close\":\"17:00\"}, \"fri\": {\"open\":\"09:00\",\"close\":\"15:00\"} },\n  \"closed_message\": \"Kami buka Sen–Jum.\"\n}\n"}]},{"id":"dlp","title":"DLP Audit","description":"Server-side compliance trail for the **frontend** Data Loss Prevention\nscanner. chat-web runs regex checks on the composer body before every\nsend (10 rule classes — JWT / AWS keys / PEM private key / GitHub PAT /\nSlack token / Stripe key / Google API key / Ikavia `ak_*` key / credit\ncard with Luhn validation). When hits are found, a warning modal asks\nthe user to confirm or cancel.\n\nDefault policy is **warn-only** — user can override and send anyway.\nThis endpoint records every detection regardless of override so the\ncompliance team has a record of near-misses + actual leaks.\n\nThe scanner is entirely client-side; raw secret bodies never leave the\nbrowser. Only rule IDs and a `sent_anyway` flag are POSTed.\n","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\na match. Server sanitizes rule IDs (alphanumeric + underscore, ≤32\nchars), caps the list at 20 rules per call (rate-limit on audit\ntable), writes `dlp.flagged` to `audit_logs`.\n\nSurface field disambiguates where the scan ran (composer / edit /\nreply / annotation caption) so reports can slice by entry point.\n","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":"{\n  \"rule_ids\": [\"jwt\", \"aws_access_key\"],\n  \"channel_id\": \"61c625f0-7a2e-44af-aa4c-8fefb0ce0ab4\",\n  \"sent_anyway\": false,\n  \"surface\": \"composer\"\n}\n","example_response":"{ \"success\": true, \"data\": { \"recorded\": true } }\n"}]},{"id":"identity","title":"Identity (`/me`)","description":"Endpoint untuk lookup atau update chat-side user record. Semua endpoint\nauth via `Authorization: Bearer \u003cJWT\u003e`. Lazy-create kalau row belum\nada — caller tidak perlu register dulu.\n","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\npertama (lazy-create dari JWT claim).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\":             \"874f0605-19d7-4e18-8aef-c544d9620157\",\n    \"account_id\":     \"d130939c-f3ce-4cfb-bb6f-aee94e06419a\",\n    \"org_id\":         \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n    \"kind\":           \"human\",\n    \"display_name\":   \"Chat Tester\",\n    \"avatar_url\":     null,\n    \"status\":         \"active\",\n    \"mute_keywords\":  [\"lunch\", \"standup\"],\n    \"created_at\":     \"2026-05-18T23:03:07.367883Z\",\n    \"updated_at\":     \"2026-05-18T23:03:07.367883Z\"\n  },\n  \"request_id\": \"uuid\"\n}\n"},{"name":"Update my profile","method":"PATCH","path":"/api/v1/me","auth":"JWT Bearer","description":"Patch display_name / avatar_url / mute_keywords. Omitted\nfield = unchanged. `kind`, `account_id`, `org_id` immutable.\n\n**display_name forwarding (TIER 2 fix):** chat-service relay\nPATCH ke account-service `PUT /api/v1/me` dengan caller's\nbearer. Account-service jadi source of truth permanent.\nKalau forward gagal, local PG TIDAK di-update (return 502).\nReverse direction (update di account.ikavia.com → propagate\nke chat) ditangani via RabbitMQ event `account.account.updated`\n(auto-sync sub-1 detik di prod).\n\n**mute_keywords (TIER 2):** per-user notif suppression.\nServer normalize: trim whitespace + lowercase + dedupe. FE\nfilter audio ping + desktop notif kalau body WS message_new\nmatch substring. Message tetap di-render (user lihat di\nchat list, hanya audio/popup yang silent).\n","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":"{\n  \"mute_keywords\": [\"lunch\", \"standup\", \"deploy\"]\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": { \"id\": \"...\", \"display_name\": \"Updated Name\", \"mute_keywords\": [\"lunch\", \"standup\", \"deploy\"], \"...\" : \"...\" }\n}\n"}]},{"id":"memberships","title":"Memberships \u0026 DM","description":"Membership row = user ↔ channel join (composite PK `user_id`,\n`channel_id`). Carries role (`owner`|`admin`|`member`|`guest`),\nnotification pref, mute timestamp, last-read marker.\n\nDM endpoint idempotent canonical — `POST /dm` dari A→B vs B→A return\nsame channel_id (`metadata.dm_pair = sortedHash(a,b)`).\n","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\nleave (`left_at` set) tidak ke-include.\n","query_params":[{"name":"page","type":"int"},{"name":"per-page","type":"int","description":"Default 50, max 100."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"user_id\":           \"874f0605-19d7-4e18-8aef-c544d9620157\",\n        \"channel_id\":        \"27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf\",\n        \"role\":              \"owner\",\n        \"notification_pref\": \"all\",\n        \"joined_at\":         \"2026-05-18T23:08:21Z\"\n      }\n    ],\n    \"pagination\": { \"page\": 1, \"per_page\": 50, \"total\": 1, \"total_pages\": 1 }\n  }\n}\n"},{"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\n(return 200, bukan 409). Untuk private channel, caller harus\nadmin/owner; public channel any member boleh invite.\n\nDua cara identifikasi (boleh dipakai bersama):\n- `user_ids` — chat-side user IDs (target sudah pernah buka chat).\n- `members` — `[{account_id, display_name}]` dari daftar member org\n  (account-service). Server **auto-provision** akun chat bila target\n  belum pernah buka chat (pakai `display_name`; nama auto-heal saat\n  user login). Owner bisa undang member mana pun walau belum buka chat.\n","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 } }\n"},{"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\n(for audit + last_read preservation). Self-remove auto-route ke\nleave endpoint.\n"},{"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 —\nharus transfer ownership dulu (out of scope Step 3).\n"},{"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.\n","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\nchannel_id. Kalau belum, create new + bootstrap 2 membership row\n(caller + peer, both role=`member`).\n\nCanonical pair key: `metadata.dm_pair = \"${min(a,b)}:${max(a,b)}\"`,\njadi A→B dan B→A return record sama. Self-DM ditolak (422).\n","body":[{"name":"user_id","type":"uuid","required":true,"description":"Peer chat-side user_id (bukan account_id)."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"channel_id\": \"6805901e-5912-4a5c-92de-146c1dd35d4f\",\n    \"created\":    true\n  }\n}\n"}]},{"id":"messages","title":"Messages (REST)","description":"Message persistence layer: ScyllaDB `messages` table, partition by\n`channel_id`, clustered DESC by `message_id` (ULID).\n\n**Why ULID instead of UUID?** Lexicographic time-ordered → cluster-key\nDESC = newest first scan, no separate timestamp index needed. 80-bit\nrandom entropy → effectively no collision di tetangga millisecond.\nULID juga sortable as string — gampang di-paginate via `before` /\n`after`.\n\n**Why no LWT?** Scylla tablets (default modern) belum dukung\nLightweight Transactions. ULID monotonic per-process + random entropy\n→ CAS tidak perlu. Edit/Delete: read-before-write di use_case lapisan,\nbukan CAS pada Scylla.\n\nSoft-delete: body di-kosongkan + `deleted_at` di-set. Row tetap ada\nuntuk preserve ordering + audit. Client render placeholder.\n","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` /\n`after` cursor (message_id), bukan offset (offset di Scylla\nmahal — pakai cursor).\n","query_params":[{"name":"limit","type":"int","description":"Default 50, max 100."},{"name":"before","type":"ulid","description":"Pesan dengan id \u003c before (lebih lama)."},{"name":"after","type":"ulid","description":"Pesan dengan id \u003e after (lebih baru)."}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"count\": 4,\n    \"items\": [\n      {\n        \"id\":           \"01KRYQ7K1JQX75GVZ5PT06Y4FY\",\n        \"channel_id\":   \"27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf\",\n        \"org_id\":       \"0db3bcc1-8e2e-4519-9a41-d4f1d314136a\",\n        \"author_id\":    \"874f0605-19d7-4e18-8aef-c544d9620157\",\n        \"author_kind\":  \"human\",\n        \"body\":         \"Fourth message\",\n        \"content_type\": \"text/plain\",\n        \"edited_at\":    null,\n        \"deleted_at\":   null,\n        \"created_at\":   \"2026-05-18T23:37:39.634Z\"\n      }\n    ]\n  }\n}\n"},{"name":"Send message","method":"POST","path":"/api/v1/channels/{channel-id}/messages","auth":"JWT Bearer","description":"Persist + publish. Pipeline:\n\n1. ACL: caller harus active member (atau hold wildcard).\n2. ULID di-generate server-side (monotonic + random entropy).\n3. INSERT ke Scylla `messages` (no LWT — see above).\n4. Update `channels.last_message_*` denormalized (async goroutine).\n5. Publish envelope ke Redis topic `chat:ch:{channel_id}` →\n   WS subscribers di all instances dapat `message_new` frame.\n\nResponse REST datang sebelum atau bareng WS frame — client\n**harus** dedup by message id kalau optimistic-append.\n","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":"{\n  \"body\": \"Deploy ke prod?\",\n  \"metadata\": \"{\\\"interactive\\\":{\\\"buttons\\\":[[{\\\"id\\\":\\\"approve\\\",\\\"label\\\":\\\"✅ Approve\\\",\\\"style\\\":\\\"success\\\"},{\\\"id\\\":\\\"reject\\\",\\\"label\\\":\\\"❌ Reject\\\",\\\"style\\\":\\\"danger\\\"}]]}}\"\n}\n"},{"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.\nDeleted message tidak bisa di-edit (422). `edited_at` di-set ke now.\nBroadcasts `message_edit` event via WS.\n","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\ndi Scylla untuk ordering + audit. Broadcasts `message_delete`\nvia WS. Hanya author atau holder `chat:moderate`.\n\nCascade cleanup: attachment_refs cleared, pinned/starred rows\ndropped, ES delete-marked, parent reply_count decremented when\nthe deleted message had a `reply_to_id`.\n","example_response":"{ \"success\": true, \"data\": { \"deleted\": true } }\n"},{"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\":\"📋 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\" }\n","example_response":"{ \"success\": true, \"data\": { \"recorded\": true } }\n"}]},{"id":"pinned","title":"Pinned Messages","description":"Pin important messages to a channel for quick reference. Cascade-cleaned\nwhen source message is deleted (the `pinned_messages` row is dropped via\na cascade hook in `messages.Delete`). Member-gated; only the user who\npinned (or a `chat:moderate` holder) can unpin.\n\nWS events: `message_pinned`, `message_unpinned` fan-out on channel topic.\n","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\n50 pins per channel (idempotent — re-pin returns 200 not 201).\n","example_response":"{ \"success\": true, \"data\": { \"pinned\": true } }\n"},{"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\nmessage returns 200.\n","example_response":"{ \"success\": true, \"data\": { \"unpinned\": true } }\n"},{"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.\nEnriched with message body so FE doesn't need to re-fetch.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"channel_id\": \"...\", \"message_id\": \"01KS24…\",\n        \"pinned_by\": \"...\", \"pinned_at\": \"...\",\n        \"body\": \"Read the runbook before deploy\",\n        \"author_id\": \"...\", \"is_deleted\": false\n      }\n    ]\n  }\n}\n"}]},{"id":"presence","title":"Presence \u0026 User Lookup","description":"Two complementary endpoints for resolving \"who is this user\" and\n\"are they online right now\".\n\n**Presence** lives in Redis with a 90-second TTL. Connected WS clients\nrefresh their own key every 30 seconds (3× safety margin against drift).\nOn the WS gateway side, an explicit \"last-conn-closed\" path also fires\n`ClearPresence` so a graceful disconnect transitions the user to\noffline without waiting for TTL.\n\n**User lookup** returns `display_name`, `avatar_url`, `kind` for known\naccount IDs. Used by chat-web to cache identities for messages whose\nauthor isn't currently in the member panel.\n","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\nonly resolve users in the same org. Cross-org peers are name-\nresolved via the channel member-list endpoint (membership proves\nshared context).\n","query_params":[{"name":"ids","type":"csv-uuid","required":true,"description":"Comma-separated user IDs, max 100"}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"...\", \"account_id\": \"...\",\n        \"display_name\": \"Arian Saputra\",\n        \"avatar_url\": null,\n        \"kind\": \"human\"\n      }\n    ]\n  }\n}\n"},{"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\nRedis presence keys (TTL 90s).\n","query_params":[{"name":"ids","type":"csv-uuid","required":true,"description":"Comma-separated user IDs"}],"example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      { \"user_id\": \"...\", \"online\": true },\n      { \"user_id\": \"...\", \"online\": false }\n    ]\n  }\n}\n"}]},{"id":"read-markers","title":"Read Markers","description":"Per-user-per-channel last-read pointer. Stored on the membership row\n(`memberships.last_read_id`) and cached in Redis hot-path. The FE uses\nthis to render unread counts (`COUNT(messages WHERE message_id \u003e\nlast_read_id)` via Scylla range query).\n\nNo-clobber semantic: if client sends an older ULID than the one\nalready stored, the update is ignored — out-of-order WS deliveries\ncan't roll back a marker.\n","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\" }\n","example_response":"{ \"success\": true, \"data\": { \"ok\": true } }\n"}]},{"id":"reactions","title":"Reactions","description":"Emoji reactions on messages. Multi-user, single-emoji-per-user-per-msg\nsemantics — toggling the same emoji twice from one user removes it.\nReactions co-located in ScyllaDB partition with the parent message.\nWS event `reaction` fans out `{op: \"add\"|\"remove\", emoji, user_id}`.\n","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\n16 chars). Returns 422 if emoji invalid or message deleted.\n","body":[{"name":"emoji","type":"string","required":true,"description":"Single emoji char or shortcode (≤16 chars)"}],"example_body":"{ \"emoji\": \"👍\" }\n","example_response":"{ \"success\": true, \"data\": { \"added\": true } }\n"},{"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.\n","body":[{"name":"emoji","type":"string","required":true,"description":"Same emoji string used in POST"}],"example_body":"{ \"emoji\": \"👍\" }\n","example_response":"{ \"success\": true, \"data\": { \"removed\": true } }\n"}]},{"id":"search","title":"Full-text Search","description":"ScyllaDB stores messages by `(channel_id, message_id DESC)` partition\n— perfect for \"load latest N\" but terrible for cross-channel text\nsearch. The chat-service maintains a parallel Elasticsearch index\n(`chat-prod-messages`) populated through two paths:\n\n1. **At-least-once direct index** on Send / Edit / Delete (synchronous\n   goroutine, never blocks the request).\n2. **At-most-once Redis pub/sub indexer** — every chat-service replica\n   subscribes and writes to ES. Acts as a redundant tier.\n\nSearch is gated by membership — server filters results to channels the\ncaller is a member of. No cross-org leakage.\n","endpoints":[{"name":"Search messages","method":"GET","path":"/api/v1/search","auth":"JWT Bearer","description":"Full-text search across messages the caller can see. Channels\nfilter narrows to specific channels (otherwise org-wide).\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"01KS…\", \"channel_id\": \"...\",\n        \"author_id\": \"...\",\n        \"body\": \"the prod deploy is at 17:00\",\n        \"highlight\": \"the \u003cem\u003eprod deploy\u003c/em\u003e is at 17:00\",\n        \"created_at\": \"...\"\n      }\n    ],\n    \"total\": 1\n  }\n}\n"}]},{"id":"starred","title":"Starred Messages (Per-user Bookmarks)","description":"Per-user private bookmark. Berbeda dari pinned messages (yang\nvisible ke semua channel members) — starred items hanya visible\nke pemilik yang star. Backend filter strict by `user_id` di\nrepo layer; tidak ada code path yang expose starred orang lain.\n\n**Storage:** PG table `starred_messages` (composite PK\n`user_id, channel_id, message_id`). Index `idx_starred_user_at`\nuntuk list \"my stars\" DESC.\n\n**Limit:** tidak ada hard cap per user (low cardinality —\nrealistically \u003c1k per user).\n\nDecorating: backend best-effort lookup body/author dari Scylla\nmessages saat list. Kalau message sudah dihapus (soft-delete\natau retention), field `is_deleted: true` di-set tapi entry\ntetap di-return — user tetap bisa unstar walaupun source\nmessage hilang.\n","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\nreturn OK. Caller harus member channel — gate ini mencegah\nuser mem-bookmark message yang tidak boleh dia akses\n(defense-in-depth, source-channel sudah membership-gated).\n","example_response":"{ \"success\": true, \"data\": { \"starred\": true } }\n"},{"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\nbookmark sendiri walaupun sudah left channel).\n","example_response":"{ \"success\": true, \"data\": { \"unstarred\": true } }\n"},{"name":"List my starred","method":"GET","path":"/api/v1/me/starred","auth":"JWT Bearer","description":"Sorted by `starred_at` DESC. Decorate dengan body/author\ndari Scylla (best-effort). Pagination via `before` (RFC3339\ntimestamp).\n","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":"{\n  \"success\": true,\n  \"data\": {\n    \"count\": 2,\n    \"items\": [\n      {\n        \"channel_id\": \"61c625f0-...\",\n        \"message_id\": \"01KS1...\",\n        \"starred_at\": \"2026-05-19T17:10:08.494637Z\",\n        \"author_id\": \"71213598-...\",\n        \"body\": \"important message I want to remember\",\n        \"created_at\": \"2026-05-19T16:00:00Z\"\n      }\n    ]\n  }\n}\n"},{"name":"Bulk check starred","method":"POST","path":"/api/v1/me/starred/bulk-check","auth":"JWT Bearer","description":"Frontend paint indicator efficient — kirim list\n(channel_id, message_id) untuk message yang visible,\ndapat map \"\u003cchannel_id\u003e:\u003cmessage_id\u003e\" → true untuk yang\nstarred (absent = not starred). Bound max 200 items.\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": {\n      \"61c625f0-...:01KS1...\": true\n    }\n  }\n}\n"}]},{"id":"webhooks","title":"Outgoing Webhooks","description":"Per-channel HTTP webhook delivery for bot integrations that prefer a\nstateless ingress (serverless / Lambda / Cloudflare Worker) over a\nWebSocket subscription. Server POSTs an envelope with HMAC-SHA256\nsignature on every subscribed event.\n\nFailed deliveries land in a durable PG retry queue (`webhook_retries`)\nwith exponential backoff: **10s → 1m → 5m → 30m → 2h**. After 5\nattempts the row is dead-lettered with `status=dead` for operator\ninspection; dead rows older than 7 days are purged.\n\nSubscribed event set per webhook is a comma-separated allow-list; the\nspecial string `*` subscribes to every supported event.\n\n**⚠️ Anti-loop (bot wajib baca):** webhook fire untuk SETIAP pesan di\nchannel, **termasuk pesan yang dikirim bot itu sendiri**. Tidak ada\nfilter sender server-side. Bot WAJIB mengabaikan pesannya sendiri —\ncek `author_kind == \"service\"` dan/atau `author_id == \u003cuser_id bot\u003e`\ndi payload — supaya tidak membalas balasannya sendiri (infinite loop).\n\n**Alternatif WebSocket (bot non-browser):** connect\n`wss://chat.ikavia.com/ws?token=\u003cjwt-hasil-token-exchange\u003e` lalu\n`{\"type\":\"subscribe\",\"data\":{\"channel_ids\":[…]}}`. Request tanpa header\n`Origin` (non-browser) diterima; auth via `?token=`. Aturan anti-loop\nyang sama berlaku.\n","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\nHMAC signatures on subsequent fires. URL must be HTTPS; localhost\n/ RFC1918 / `.ikavia.com` blocked (anti-SSRF).\n","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":"{\n  \"url\": \"https://my-bot.example.com/chat-hook\",\n  \"events\": \"message.created, message.interaction\"\n}\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"id\": \"...\", \"channel_id\": \"...\", \"url\": \"...\",\n    \"secret\": \"abc123…\",\n    \"events\": \"message.created, message.interaction\",\n    \"is_active\": true,\n    \"failure_streak\": 0\n  }\n}\n"},{"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\nafter create (one-time visibility).\n","example_response":"{\n  \"success\": true,\n  \"data\": {\n    \"items\": [\n      {\n        \"id\": \"...\", \"url\": \"...\",\n        \"events\": \"*\",\n        \"is_active\": true, \"failure_streak\": 0,\n        \"last_fired_at\": \"...\", \"last_failed_at\": null\n      }\n    ]\n  }\n}\n"},{"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 } }\n"}]},{"id":"webhook-payloads","title":"Webhook Event Reference","description":"Every event POSTs JSON of shape:\n\n```json\n{\n  \"event\": \"message.created\",\n  \"delivery_id\": \"1742…\",\n  \"timestamp\": \"2026-05-20T13:11:01.445Z\",\n  \"data\": { ...event-specific... }\n}\n```\n\nHeaders:\n\n- `Content-Type: application/json`\n- `User-Agent: chat-service/1.0 webhook`\n- `X-Ikavia-Event: \u003cevent-type\u003e`\n- `X-Ikavia-Signature: sha256=\u003chex\u003e`\n\nVerify the signature: `hex(hmac_sha256(secret, raw_body))` and compare\nwith `compare_digest`. Reject if mismatch.\n","endpoints":[{"name":"message.created","method":"POST","path":"\u003cyour URL\u003e","description":"Fired on POST /messages.","example_body":"{\n  \"event\": \"message.created\",\n  \"delivery_id\": \"1742…\",\n  \"data\": {\n    \"id\": \"01KS…\",\n    \"channel_id\": \"...\",\n    \"author_id\": \"...\", \"author_kind\": \"human\",\n    \"body\": \"hello\", \"content_type\": \"text/plain\",\n    \"metadata\": \"{}\",\n    \"reply_to_id\": \"\",\n    \"created_at\": \"2026-05-20T13:11:00.000Z\"\n  }\n}\n"},{"name":"message.edited","method":"POST","path":"\u003cyour URL\u003e","description":"Fired on PATCH /messages/:id.","example_body":"{\n  \"event\": \"message.edited\",\n  \"data\": {\n    \"id\": \"01KS…\",\n    \"channel_id\": \"...\", \"author_id\": \"...\",\n    \"body\": \"hello (edited)\",\n    \"metadata\": \"{}\",\n    \"edited_at\": \"2026-05-20T13:12:00.000Z\"\n  }\n}\n"},{"name":"message.deleted","method":"POST","path":"\u003cyour URL\u003e","description":"Fired on DELETE /messages/:id (user-initiated).","example_body":"{\n  \"event\": \"message.deleted\",\n  \"data\": {\n    \"id\": \"01KS…\",\n    \"channel_id\": \"...\", \"author_id\": \"...\",\n    \"deleted_by\": \"...\",\n    \"deleted_at\": \"2026-05-20T13:13:00.000Z\"\n  }\n}\n"},{"name":"message.expired","method":"POST","path":"\u003cyour URL\u003e","description":"Fired by the ephemeral-message sweeper when a message's TTL elapses.\nPayload omits body (already cleared from Scylla).\n","example_body":"{\n  \"event\": \"message.expired\",\n  \"data\": {\n    \"id\": \"01KS…\",\n    \"channel_id\": \"...\", \"author_id\": \"...\",\n    \"expired_at\": \"2026-05-20T13:14:00.000Z\"\n  }\n}\n"},{"name":"message.interaction","method":"POST","path":"\u003cyour URL\u003e","description":"Fired when a user clicks a callback button on an inline keyboard\nattached to a message. Link buttons (with `url`) do not fire this\nevent — they open the browser directly.\n","example_body":"{\n  \"event\": \"message.interaction\",\n  \"data\": {\n    \"channel_id\": \"...\",\n    \"message_id\": \"01KS…\",\n    \"user_id\": \"...\",\n    \"action_id\": \"approve\",\n    \"action_label\": \"✅ Approve\"\n  }\n}\n"}]},{"id":"websocket","title":"WebSocket Handshake","description":"Single endpoint `GET /ws` (wss:// in prod) untuk realtime events. Auth\nvia JWT — bisa `Authorization: Bearer …` header **atau** `?token=…`\nquery param (browsers tidak bisa set header pada handshake WS).\n\nSubprotocol: **`ikavia-chat-v1`**. Server reject kalau Origin tidak match\n`ALLOWED_ORIGINS` env var (CORS-equivalent untuk WS).\n\nHeartbeat: server ping setiap 25s, client pong (gorilla auto). Pong wait\n= 2.5× ping. Per-conn outbound buffer 64 frames; slow client di-drop\nframe, bukan diblock fan-out lain.\n\nPer-user cap: max 8 concurrent connections per `user_id`. Connection\nke-9 boots oldest (FIFO).\n","endpoints":[{"name":"WebSocket handshake","method":"GET","path":"/ws","auth":"JWT Bearer","description":"Standard RFC 6455 upgrade. Server reply\n`Sec-WebSocket-Protocol: ikavia-chat-v1` kalau match. First frame\nyang server kirim adalah `hello_ack` dengan info session.\n\nDetail protocol frame ada di section **Events** (di overview page,\nbawah architecture diagram) — termasuk subscribe/unsubscribe,\nmessage_new, message_edit, message_delete, error.\n","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:\n{\n  \"type\": \"hello_ack\",\n  \"data\": {\n    \"user_id\":     \"874f0605-19d7-4e18-8aef-c544d9620157\",\n    \"session_id\":  \"4c6d67b6-228f-40b1-b765-72ea130a34de\",\n    \"server_time\": \"2026-05-19T01:23:01.610Z\"\n  }\n}\n"}]}],"flow_diagram_nodes":[{"id":"web","label":"🌐 chat-web","type":"client","color":"#0ea5e9","position":{}},{"id":"chat","label":"💬 chat-service","type":"service","color":"#4f46e5","position":{}},{"id":"account","label":"🔐 account-service","type":"service","color":"#360185","position":{}},{"id":"pg","label":"🐘 PostgreSQL","type":"data","color":"#10b981","position":{}},{"id":"scylla","label":"🪨 ScyllaDB","type":"data","color":"#10b981","position":{}},{"id":"redis","label":"📡 Redis pub/sub","type":"queue","color":"#8b5cf6","position":{}},{"id":"jwt","label":"🔑 JWT (RS256)","type":"data","color":"#f59e0b","position":{}}],"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\ncheck membership untuk setiap `channel_id` — non-member dikirim\n`error` dengan code `not_member`. Server reply `subscribed` dengan\nlist `channel_ids` yang akhirnya tersubscribed.\n\nUser-private topic (`chat:user:{user_id}`) auto-subscribed saat\nhandshake — client tidak perlu subscribe manual.\n","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":"{\n  \"type\": \"subscribe\",\n  \"id\":   \"client-correlation-1\",\n  \"data\": { \"channel_ids\": [\"27e3cd2b-…\",\"6805901e-…\"] }\n}\n"},{"type":"publish","summary":"Server reply `subscribed` frame","description":"Echo list channel_ids yang berhasil di-subscribe.","example":"{\n  \"type\": \"subscribed\",\n  \"id\":   \"client-correlation-1\",\n  \"data\": { \"channel_ids\": [\"27e3cd2b-…\"] }\n}\n"}]},{"id":"ws-message-new","title":"message_new (server push)","description":"Server push untuk message baru di channel yang caller subscribe. Diterima\nbaik dari diri sendiri (race REST response) maupun dari member lain —\n**client wajib dedup by `data.data.id`** kalau optimistic-append dari\nREST response.\n","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":"{\n  \"type\": \"message_new\",\n  \"data\": {\n    \"channel_id\": \"27e3cd2b-…\",\n    \"ts\":         \"2026-05-18T23:37:59.522Z\",\n    \"data\": {\n      \"id\":           \"01KRYQ86F25R3CGYXTTB5PN0FY\",\n      \"channel_id\":   \"27e3cd2b-…\",\n      \"author_id\":    \"874f0605-…\",\n      \"author_kind\":  \"human\",\n      \"body\":         \"Realtime fanout via REST\",\n      \"content_type\": \"text/plain\",\n      \"reply_to_id\":  \"\",\n      \"mentions\":     null,\n      \"created_at\":   \"2026-05-18T23:37:59.522Z\"\n    }\n  }\n}\n"}]},{"id":"ws-message-edit","title":"message_edit (server push)","description":"Body \u0026 edited_at update. `data.data` hanya carry field yang berubah\n(`id`, `body`, `edited_at`), bukan full message envelope.\n","protocol":"websocket","address":"chat:ch:{channel_id}","operations":[{"type":"publish","summary":"Server → Client","example":"{\n  \"type\": \"message_edit\",\n  \"data\": {\n    \"channel_id\": \"27e3cd2b-…\",\n    \"data\": {\n      \"id\":        \"01KRYQ86F25R…\",\n      \"body\":      \"Edited via REST\",\n      \"edited_at\": \"2026-05-18T23:38:00.123Z\"\n    }\n  }\n}\n"}]},{"id":"ws-message-delete","title":"message_delete (server push)","description":"Soft-delete event. Client render placeholder untuk `message_id` ini.\nRow tetap ada di Scylla untuk ordering + audit.\n","protocol":"websocket","address":"chat:ch:{channel_id}","operations":[{"type":"publish","summary":"Server → Client","example":"{\n  \"type\": \"message_delete\",\n  \"data\": {\n    \"channel_id\": \"27e3cd2b-…\",\n    \"data\": {\n      \"channel_id\": \"27e3cd2b-…\",\n      \"message_id\": \"01KRYQ86F25R…\"\n    }\n  }\n}\n"}]},{"id":"ws-read-marker","title":"read_marker (server push)","description":"Broadcast saat seorang member meng-set read marker (REST `POST\n/channels/{channel-id}/read-marker`). Dipakai untuk **read receipt (✓✓)**:\npesan kita dengan `id \u003c= data.data.last_read_id` (ULID lexicographic)\nsudah dibaca oleh `data.user_id`. Untuk DM ini menggerakkan ✓✓ biru;\nuntuk grup, agregasi \"dibaca semua\" adalah tanggung jawab klien (server\nhanya menyiarkan per-user). Best-effort — tak menutup koneksi bila gagal.\n","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":"{\n  \"type\": \"read_marker\",\n  \"data\": {\n    \"channel_id\": \"27e3cd2b-…\",\n    \"user_id\":    \"874f0605-…\",\n    \"ts\":         \"2026-05-26T16:00:00.000Z\",\n    \"data\": {\n      \"last_read_id\": \"01KRYQ86F25R3CGYXTTB5PN0FY\"\n    }\n  }\n}\n"}]},{"id":"ws-ping","title":"ping / pong","description":"Application-level ping (selain WS frame-level ping/pong gorilla yang\njalan auto). Berguna untuk client RTT measurement.\n","protocol":"websocket","address":"/ws","operations":[{"type":"subscribe","summary":"Client → Server","example":"{ \"type\": \"ping\", \"id\": \"rtt-test-1\" }\n"},{"type":"publish","summary":"Server → Client (pong echo)","example":"{ \"type\": \"pong\", \"id\": \"rtt-test-1\" }\n"}]},{"id":"ws-error","title":"error (server push)","description":"Per-request error reply. Carries stable `code` untuk client routing\nplus human-readable `message`. **Tidak menutup koneksi** — sekedar\nresponse ke frame yang failed.\n\nStable error codes:\n\n| Code | Meaning |\n|---|---|\n| `invalid_frame` | JSON parse fail atau payload shape salah |\n| `unsupported_type` | `type` tidak dikenal |\n| `permission_denied` | Authorization fail di JWT permissions |\n| `not_member` | Subscribe attempt ke channel bukan member |\n| `rate_limited` | Future feature (Step 7) |\n| `session_revoked` | Session di-logout di account-service |\n| `max_connections_per_user` | Boot saat conn ke-9 (oldest FIFO) |\n| `internal_error` | Generic 5xx — biasanya transient |\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Client","example":"{\n  \"type\": \"error\",\n  \"id\":   \"client-correlation-1\",\n  \"data\": {\n    \"code\":    \"not_member\",\n    \"message\": \"not a member of channel cfa1942f-…\"\n  }\n}\n"}]},{"id":"ws-typing","title":"typing (bi-directional)","description":"Client sends `typing` frame when user starts/stops typing in the\ncomposer. Server fans out to other channel subscribers (skip self).\nRate-limited per (conn, channel) to 1 broadcast per 3s.\nServer uses `conn.UserID` (JWT-validated) not the payload — anti-spoof.\n","protocol":"websocket","address":"/ws","operations":[{"type":"subscribe","summary":"Client → Server","example":"{ \"type\": \"typing\", \"data\": { \"channel_id\": \"cfa1942f-…\", \"is_typing\": true } }\n"},{"type":"publish","summary":"Server → Other subscribers","example":"{\n  \"type\": \"typing\",\n  \"data\": {\n    \"channel_id\": \"cfa1942f-…\",\n    \"user_id\":    \"\u003cUUID of typer\u003e\",\n    \"is_typing\":  true\n  }\n}\n"}]},{"id":"ws-presence","title":"presence (server push)","description":"Fired when a user transitions online ↔ offline. Online = Redis\npresence key exists (TTL 90s, refreshed every 30s by an active WS\nconn). Offline = TTL expired OR last connection closed cleanly.\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Client","example":"{\n  \"type\": \"presence\",\n  \"data\": { \"user_id\": \"...\", \"status\": \"online\" }\n}\n"}]},{"id":"ws-reaction","title":"reaction (server push)","description":"Fan-out of reaction add/remove. Companion to REST endpoints in the\nReactions section.\n","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\":      \"👍\",\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\nthe user gets a high-priority notification (toast + ping bypassing\nmute) even if they're not currently focused on that channel.\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Mentioned user only","example":"{\n  \"type\": \"mention\",\n  \"data\": {\n    \"channel_id\": \"...\",\n    \"message_id\": \"01KS…\",\n    \"author_id\":  \"...\",\n    \"body_preview\": \"@you check the deploy at 17:00…\"\n  }\n}\n"}]},{"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":"{\n  \"type\": \"member_added\",\n  \"data\": { \"channel_id\": \"...\", \"user_id\": \"...\" }\n}\n"}]},{"id":"ws-message-pinned","title":"message_pinned / message_unpinned (server push)","description":"Pin/unpin event. Companion to REST endpoints in the Pinned section.\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Subscribed clients","example":"{\n  \"type\": \"message_pinned\",\n  \"data\": {\n    \"channel_id\": \"...\",\n    \"message_id\": \"01KS…\",\n    \"pinned_by\":  \"...\"\n  }\n}\n"}]},{"id":"ws-message-expired","title":"message_expired (server push)","description":"Fired by the ephemeral-message sweeper when a message's TTL elapses.\nFE renders distinct \"⏱ pesan hilang\" placeholder (separate from\nregular `message_delete`).\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Subscribed clients","example":"{\n  \"type\": \"message_expired\",\n  \"data\": { \"channel_id\": \"...\", \"message_id\": \"01KS…\" }\n}\n"}]},{"id":"ws-message-interaction","title":"message.interaction (server push)","description":"Fired when a user clicks an inline-keyboard callback button. The bot\nthat owns the message subscribes to the channel topic to receive\nthese — typically responds by `PATCH`ing the original message to\nacknowledge.\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"Server → Subscribed clients","example":"{\n  \"type\": \"message.interaction\",\n  \"data\": {\n    \"channel_id\":   \"...\",\n    \"message_id\":   \"01KS…\",\n    \"user_id\":      \"\u003cclicker\u003e\",\n    \"action_id\":    \"approve\",\n    \"action_label\": \"✅ Approve\"\n  }\n}\n"}]},{"id":"ws-call","title":"call.* events (voice/video signaling)","description":"WebRTC signaling tunneled over WS. Six event types — three are\nbroadcast on the channel topic (lifecycle: invite, join, leave,\nend) and three are routed peer-to-peer via the `chat:user:{user_id}`\ntopic (SDP/ICE exchange).\n\nServer anti-spoof: `from_user_id` is locked to `conn.UserID`, payload\ncontents (SDP/ICE) are opaque and forwarded as-is.\n","protocol":"websocket","address":"/ws","operations":[{"type":"publish","summary":"call.invite (server → channel)","example":"{\n  \"type\": \"call.invite\",\n  \"data\": {\n    \"call_id\":      \"...\",\n    \"channel_id\":   \"...\",\n    \"initiator_id\": \"...\",\n    \"type\":         \"video\",\n    \"status\":       \"dialing\",\n    \"started_at\":   \"...\"\n  }\n}\n"},{"type":"publish","summary":"call.participant_joined (server → channel)","example":"{\n  \"type\": \"call.participant_joined\",\n  \"data\": {\n    \"call_id\":    \"...\",\n    \"channel_id\": \"...\",\n    \"user_id\":    \"...\",\n    \"status\":     \"active\"\n  }\n}\n"},{"type":"publish","summary":"call.participant_left (server → channel)","example":"{\n  \"type\": \"call.participant_left\",\n  \"data\": {\n    \"call_id\":    \"...\",\n    \"channel_id\": \"...\",\n    \"user_id\":    \"...\",\n    \"leave_code\": \"hangup\"\n  }\n}\n"},{"type":"publish","summary":"call.end (server → channel)","example":"{\n  \"type\": \"call.end\",\n  \"data\": {\n    \"call_id\":     \"...\",\n    \"channel_id\":  \"...\",\n    \"end_reason\":  \"hangup\",\n    \"duration_ms\": 359\n  }\n}\n"},{"type":"subscribe","summary":"call.sdp_offer / sdp_answer / ice (client → server, routed to peer)","example":"{\n  \"type\": \"call.sdp_offer\",\n  \"data\": {\n    \"call_id\":    \"...\",\n    \"channel_id\": \"...\",\n    \"to_user_id\": \"\u003crecipient\u003e\",\n    \"kind\":       \"sdp_offer\",\n    \"payload\":    { \"type\": \"offer\", \"sdp\": \"v=0\\\\r\\\\n…\" }\n  }\n}\n"},{"type":"publish","summary":"call.sdp_offer (server → recipient peer)","example":"{\n  \"type\": \"call.sdp_offer\",\n  \"data\": {\n    \"call_id\":      \"...\",\n    \"channel_id\":   \"...\",\n    \"from_user_id\": \"\u003cofferer\u003e\",\n    \"to_user_id\":   \"\u003crecipient\u003e\",\n    \"kind\":         \"sdp_offer\",\n    \"payload\":      { \"type\": \"offer\", \"sdp\": \"v=0\\\\r\\\\n…\" }\n  }\n}\n"}]}],"theme":{"title":"Chat · Ikavia","logo_icon":"💬","primary_color":"#4f46e5"}}