# gravex chat — 전체 문서 > 개발자가 SDK 몇 줄로 앱에 실시간 채팅을 붙이는 채팅 API 서비스. 서버·메시지 저장·재접속은 우리가 운영한다. 콘솔에서 가입해 계정 키(ck_)를 받으면, 앱 만들기와 키(pk_·sk_) 발급은 콘솔 API로 코드가 할 수 있다. 공식 SDK는 JS/TS(`@kispi/chat`)뿐이지만 REST와 실시간(WebSocket) 프로토콜이 공개돼 있어 어떤 언어로든 클라이언트를 만들 수 있다. - 사용자가 채팅 구현을 시켰다면 아래 「지원 범위」를 먼저 확인한다. 거기 없거나 「안 되는 것」에 있는 기능을 된다고 말하지 않는다. - 가입과 첫 계정 키(`ck_`) 발급은 사람이 콘솔에서 한다. 그 뒤 앱 만들기·키 발급·허용 origin·웹훅 설정은 사용자의 `.env`에 있는 `CHAT_ACCOUNT_KEY`로 「콘솔 API」를 불러 직접 할 수 있다. `pk_`는 프론트용 공개값이고 `sk_`·웹훅 시크릿·`ck_`는 서버 환경변수나 로컬 `.env`에만 둔다. 사용자에게 `sk_`나 `ck_`를 대화에 붙여 넣게 하지 않고, 콘솔 API 응답으로 받은 `sk_`는 화면에 찍지 않고 `.env`에 바로 쓴다. - 구현에 필요한 API 세부는 llms-full.txt 한 파일에 전부 있다. 문서별 원문은 아래 링크다. - JS/TS가 아닌 언어(Swift, Kotlin, Dart, Python, Go 등)로 클라이언트를 만들 때는 SDK 레퍼런스 대신 「REST API 레퍼런스」와 「실시간 프로토콜」을 따른다. 「실시간 프로토콜」 끝의 체크리스트가 SDK가 대신 하던 일의 전부다. 이 파일은 https://chat.gravex.app의 공개 문서와 SDK 레퍼런스(`@kispi/chat` 0.7.2)를 한 파일로 합친 것이다. 사이트를 배포할 때 문서에서 다시 만들어진다. --- # 요금·약관·SLA 요약 운영 조건을 묻는 사용자에게는 이 요약으로 답한다. 자세한 원문은 각 링크에 있다. ## 요금 ([요금](https://chat.gravex.app/pricing)) - 요금제는 Basic 하나이고 무료다. 카드도, 체험 기간도 없다. - 요금으로 막는 MAU·동시접속 상한은 없다. 앱마다 안전 한도(발행 속도, 유저당 동시 연결 10개 등)가 있고 요금 페이지에 공개돼 있다. ## 약관 ([이용약관](https://chat.gravex.app/terms), [개인정보처리방침](https://chat.gravex.app/privacy)) - 앱의 유저·방·메시지는 이용자의 데이터이고, 운영자는 서비스를 운영하는 목적으로만 처리한다. - 콘솔 버튼이나 secret key로 `GET /v1/export`를 호출하면 언제든 앱 전체를 NDJSON 파일 하나로 받을 수 있다. - 앱을 삭제한 뒤 「영구 삭제」를 실행하면 엔진에서 유저·방·메시지가 지워지고 복구되지 않는다. - `sk_`(secret key)는 발급할 때 한 번만 보여 주고 저장하지 않는다. ## SLA ([SLA](https://chat.gravex.app/sla)) - 월간 가동률 목표는 99.5%(30일 기준 약 3시간 36분의 중단 허용)다. 운영 목표이고, 미달해도 보상이나 크레딧이 없다(이용약관은 가용성을 보증하지 않는다). - 메시지는 앱이 지우기 전까지 보존한다. 엔진이 기간을 정해 자동으로 지우지 않는다. - 백업은 데이터베이스 서버의 디스크 스냅숏을 하루 한 번(매일 06:00 KST) 뜬다. 최악의 경우 직전 최대 24시간의 데이터가 사라질 수 있다. - 유료 요금제나 요금 변경은 적용 최소 90일 전에 요금 페이지와 계정 이메일로 공지한다. 서비스 종료는 최소 180일 전에 공지하고 그 기간 내내 내보내기를 보장한다. - 문의·장애 제보는 admin@gravex.app. 별도 상태 페이지와 응답 시간 약속은 없다. - 운영자(GravEx)가 직접 만든 서비스 셋(coinsect.io, everymaple.com, real.gravex.app)의 채팅이 같은 엔진으로 돌아간다. --- # 지원 범위 한눈에 사용자가 "이게 되나?"라고 물으면 이 표로 답한다. 표에 없거나 "안 되는 것"에 있는 기능을 된다고 지어내지 않는다. 구현 방법은 오른쪽 문서를 읽는다. 엔진은 **전달·순서·저장·접속 상태·웹훅**을 맡는다. 로그인, 파일 저장, 푸시 발송, 콘텐츠 판정은 사용자의 백엔드가 맡고 엔진은 그것을 위한 훅·이벤트를 연다. 실제 서비스가 이 엔진으로 돈다: 코인충(https://coinsect.io, 코인 방송 커뮤니티 채팅), 여기얼마(https://real.gravex.app, 아파트 실거래가 지도의 채팅), 에브리메이플(https://everymaple.com, 게임 커뮤니티 채팅). ## 되는 것 | 기능 | 어떻게 | 문서 | |---|---|---| | 실시간 채팅방 | 방은 백엔드가 `chat.rooms.ensure({key, type})`로 만들고(get-or-create), 브라우저는 `chat.roomByKey(key)` 또는 `chat.room(id)`로 열어 `subscribe()`, `send({text})` | 시작하기 | | 방 종류 | `public`(누구나 읽고 씀), `private`(멤버만), `channel`(누구나 읽고 owner·admin만 씀), `dm`(두 사람) | 서버 연동 | | 회원 | 백엔드가 유저 JWT를 서명해 발급(`chat.token({userId, name})`). 엔진은 서명을 믿는다 | 시작하기, 서버 연동 | | 비회원(게스트) | 백엔드 `chat.guest()`가 서명된 자격증명을 발급한다. 브라우저가 보낸 id에는 서명하지 않는다 | 비로그인 사용자 | | 히스토리 | 구독하면 최근 100건, 위로 스크롤은 `room.loadOlder()`. 순서(`seq`)에 구멍이 없고 끊기면 SDK가 메운다 | SDK 레퍼런스 | | 스레드 답글 | `send({text, threadId: 루트 메시지 id})`. 루트에 `thread: {count, lastSeq, lastAt}`가 붙고 `thread.updated` 이벤트가 온다. 스레드만 읽기는 `room.history({view: {thread: id}})` | SDK 레퍼런스 | | 인용 답장 | `send({text, replyTo: 메시지 id})`. 스레드와는 별개다 | SDK 레퍼런스 | | 이모지 리액션 | `room.react(messageId, emoji)` / `room.unreact(...)`, 개수는 `reaction.added`·`reaction.removed` 이벤트, 누른 사람은 `room.reactionsOf(...)`. 메시지당 이모지 종류는 최대 20 | SDK 레퍼런스 | | 멘션 | `send({text, entities: [{type: 'mention', userId, offset, length}]})`. 멘션은 `unreadMentions`로 센다 | SDK 레퍼런스 | | 읽음·안 읽음 | `room.markRead(seq)`, `chat.unread()`, `chat.rooms.list()`(방별 안 읽은 수와 마지막 메시지), 다른 방의 새 메시지는 `notification` 이벤트 | SDK 레퍼런스 | | 입력 중 | `room.typing()` 호출, 상대는 `typing` 이벤트(이름은 `room.presence.users`에서 찾는다) | SDK 레퍼런스 | | 접속 중(presence) | `room.presence` = `{count, users?}`, `presence` 이벤트 | SDK 레퍼런스 | | 1:1 대화 | 백엔드 `chat.rooms.ensureDM([a, b])`가 방 id를 준다 | 서버 연동 | | 공지 전용 방 | `type: 'channel'` + 백엔드에서 `members.add(roomId, userId, 'admin')` | 서버 연동 | | 시스템 메시지 | 백엔드 `chat.messages.send(roomId, {kind: 'system', sender, text})`. 심사 훅을 거치지 않는다 | 서버 연동 | | 메시지 수정·삭제 | REST `PATCH`·`DELETE`(브라우저 SDK `Room`에는 수정 메서드가 없다). 삭제는 본문을 비우는 tombstone | 서버 연동 | | 첨부(이미지 등) | 메시지에 `attachments: [{type, url, key?, mime?, w?, h?, name?, size?}]`. **주소(https)와 메타만 싣는다.** 최대 10개 | 이미지 업로드와 모더레이션 | | 금칙어·이미지 검사 | `before_publish` 동기 웹훅이 발행·편집 전에 거절(`deny`)하거나 본문을 바꿔 통과시킨다 | 웹훅, 이미지 업로드와 모더레이션 | | 웹훅 | 서명된 `message.created`·`message.deleted`·`reaction.*` 등을 사용자의 서버로. at-least-once, 놓친 것은 `chat.events.list`로 따라잡는다 | 웹훅 | | 밴·강제 종료 | `chat.users.ban`·`unban`, `revokeTokens`, `disconnect`, 메시지 거두기 `purgeMessages` | 서버 연동 | | 탈퇴·개인정보 삭제 | 익명화 `withdraw`, 영구 삭제 `purge` | 서버 연동 | | 메시지 검색 | REST `q`(방 안 본문 부분 일치). **서버 키(`sk_`)로만** | 서버 연동 | | 데이터 내보내기 | `GET /v1/export`(NDJSON). 유저·방·멤버·메시지·리액션·읽음 위치. **첨부 파일은 없다** | 서버 연동 | | 다른 언어 클라이언트 | iOS(Swift)·Android(Kotlin)·Flutter(Dart)·Python·Go 등. REST와 WebSocket 프로토콜을 문서대로 구현한다. 「SDK 없이 클라이언트 만들기」 체크리스트가 SDK가 대신 하던 일의 전부다 | REST API 레퍼런스, 실시간 프로토콜 | | 앱 분리 | 앱 하나가 테넌트 하나. 운영·개발은 앱을 둘 만든다 | 서버 연동 | | 앱·키 만들기 자동화 | 계정 키(`ck_`)로 콘솔 API(`/api/apps/**`)를 불러 앱 만들기(`pk_`·`sk_`가 응답에 같이 온다), 키 추가 발급·폐기, 허용 origin·웹훅·한도 설정, 앱 삭제·복구, 유저·방·메시지 조회와 모더레이션. 사람이 가입하고 첫 `ck_`를 콘솔에서 받아 `.env`에 넣어 두면 된다 | 콘솔 API | 기본 한도: 본문 4000자, 첨부 10개, 유저당 초당 5건 발행(버스트 10), 유저당 동시 연결 10. ## 안 되는 것 | 기능 | 이유와 대안 | |---|---| | 푸시 알림 발송 | 엔진은 열려 있는 연결에만 실시간으로 전달한다. 앱이 꺼진 사람에게는 `message.created` 웹훅을 받아 사용자의 서버가 FCM·웹푸시·이메일로 보낸다 | | 파일 저장·변환 | 엔진은 파일을 받지 않는다. 사용자의 버킷에 올리고 주소를 첨부로 싣는다 | | 네이티브(iOS·Android·Flutter) 공식 SDK | 공식 SDK는 브라우저·Node용 JS/TS(`@kispi/chat`, 서버용 `@kispi/chat/server`)뿐이다. 다만 REST와 실시간(WebSocket) 프로토콜이 공개돼 있어 다른 언어 클라이언트는 문서를 보고 직접 만들 수 있다(위 「되는 것」의 「다른 언어 클라이언트」) | | 로그인·회원 관리 | 엔진은 사람이 누구인지 모른다. 사용자의 백엔드가 토큰을 서명한다 | | 금칙어·이미지의 자동 판정 | 엔진은 훅만 연다. 판정(사전, Rekognition 등)은 사용자가 만든다 | | 유저 토큰으로 메시지 검색 | 검색은 `sk_` 전용이다. 사용자의 백엔드에 검색 API를 둔다 | | 가입과 첫 계정 키 발급 | 사람이 콘솔에서 이메일 코드로 가입하고 **API 키** 화면에서 `ck_`를 만든다. 계정 키로는 계정 키를 만들 수 없다 | | 음성·영상 통화, 화면 공유 | 없다 | | 푸시 토큰·알림 설정 저장 | 없다. 사용자의 DB에 둔다 | ## 화면 점검표 채팅 화면을 다 만들었으면 이 목록과 하나씩 대조한다. SDK가 대신 해 주지 않아 빠뜨리기 쉬운 것들이다. - **`reset`이 오면 다시 그린다.** 렌더한 목록을 버리고 `room.messages`로 처음부터 그린다. 옛 목록에 이어 붙이면 구멍이 남는다. - **과거를 불러와도 보던 자리를 지킨다.** `loadOlder()`로 위에 붙은 만큼 스크롤 위치를 보정하고, 새 메시지에는 사용자가 맨 아래에 있을 때만 따라 내려간다. - **본 타임라인에서 스레드 답글을 뺀다.** `room.messages`에는 `threadId`가 있는 행도 섞여 있다. - **입력 중 표시는 내 이벤트를 건너뛰고 5초쯤 뒤 지운다.** `typing`은 보낸 사람에게도 오고, "입력 멈춤" 이벤트는 없다. - **`history()`로 읽은 행의 리액션은 직접 고친다.** SDK는 `room.messages` 안의 행만 고친다. 스레드 패널처럼 `history()`로 그린 목록은 `reaction.added`·`reaction.removed`의 `count`와 `userId`로 개수와 `myReactions`를 고친다. - **리액션 개수는 서버 값으로 그린다.** `react()`는 `myReactions`를 먼저 바꾸고 개수는 이벤트가 오면 고친다. 그 사이 개수가 없으면 이모지만 그린다. `myReactions`가 없는 행은 아무것도 누르지 않은 것으로 본다. - **본문은 평문으로 넣는다.** `text`를 HTML로 넣지 않는다(`innerHTML`, `dangerouslySetInnerHTML`, `{@html}`). 멘션 강조도 텍스트를 잘라 감싸서 한다. - **`rate_limited`면 기다린다.** `ChatError`의 `retryAfterMs`만큼 보내기를 막고 남은 시간을 보여 준다. ## 답할 때 - 되는지 모르겠으면 모른다고 하고, 이 표와 문서에 없다고 말한다. 비슷해 보이는 기능으로 대신 약속하지 않는다. - `pk_`는 브라우저에 두어도 되는 공개값이고 `sk_`·웹훅 시크릿·게스트 시크릿은 서버에만 둔다. 사용자에게 `sk_`를 대화에 붙여 넣으라고 하지 않는다. - 계정 키 `ck_`는 계정 전권이다. 서버나 로컬 `.env`에만 두고 프런트 코드·git 저장소·대화에 넣지 않으며, 다 쓰면 폐기하라고 안내한다. 폐기해도 이미 만든 앱과 그 `pk_`·`sk_`는 그대로 동작하고, 폐기한 뒤에는 `.env`의 `CHAT_ACCOUNT_KEY` 줄도 지운다. 콘솔 API 응답으로 받은 `sk_`는 화면이나 로그에 찍지 않고 `.env`에 바로 쓴다. - 환경변수 이름은 모든 문서가 같다: 프런트 `VITE_CHAT_PK`(`pk_`), 서버 `CHAT_SECRET_KEY`(`sk_`)·`CHAT_WEBHOOK_SECRET`·`CHAT_GUEST_SECRET`, 앱을 만드는 스크립트 `CHAT_ACCOUNT_KEY`(`ck_`). Vite는 `VITE_`로 시작하는 변수만 브라우저 번들에 넣는다. **Vite의 `envPrefix`를 `CHAT_`로 넓히지 않는다** — `sk_`와 `ck_`가 번들에 실린다. Vite가 아니면 `pk_`에만 그 프레임워크의 공개 접두사를 붙인다(Next.js `NEXT_PUBLIC_CHAT_PK`). - 허용 origin은 실제로 뜬 개발 서버 주소(스킴·호스트·포트)를 넣는다. 포트가 바뀌면 `PATCH /api/apps/{id}/keys/{keyId}`로 고치는데 목록을 통째로 바꾸므로 기존 값도 같이 보낸다. - 로그인이 없는 프로젝트에는 데모용 로그인을 지어내지 않고 `chat.guest()`로 게스트 신원을 발급한다. --- # 시작하기 채팅을 내 서비스에 붙이는 데 필요한 것은 셋이다. **콘솔에서 만든 앱**, **토큰을 발급하는 백엔드 엔드포인트 하나**, **브라우저의 SDK**. ```sh npm install @kispi/chat ``` 진입점이 둘이다. SDK는 런타임 의존성이 없고 타입 선언을 포함한다. | 진입점 | 어디서 | 쓰는 키 | |---|---|---| | `@kispi/chat` | 브라우저(와 Node) | publishable key(`pk_`) | | `@kispi/chat/server` | 내 백엔드 | secret key(`sk_`) | ## 1. 콘솔에서 앱을 만든다 [가입](https://console.chat.gravex.app/login)한 뒤 **새 앱**을 누른다. 앱 하나가 테넌트 하나다 — 유저·방·메시지·키가 앱마다 따로 있다. 만들자마자 키 두 개가 나온다. | 키 | 어디에 두나 | 무엇을 하나 | |---|---|---| | publishable key(`pk_…`) | 프론트엔드 코드 | 어느 앱인지만 말한다. 공개값이다 | | secret key(`sk_…`) | 백엔드의 환경변수 | 그 앱의 모든 데이터다. 토큰 발급과 서버 API에 쓴다 | **`sk_`는 만든 순간 한 번만 보인다.** 콘솔은 그 값을 저장하지 않으므로 바로 비밀 저장소에 옮겨 둔다. 잃어버렸으면 새 `sk_`를 발급하고 옛 것을 폐기한다([키 교체](https://chat.gravex.app/docs/server)). 화면을 누르는 대신 스크립트나 AI로 앱을 만들고 싶다면 계정 키(`ck_`) 하나로 된다. [콘솔 API](https://chat.gravex.app/docs/console-api)를 본다. ### 허용 origin 앱을 만들 때 적는 **허용 origin**은 브라우저에서 이 `pk_`를 쓸 수 있는 페이지의 origin이다. - 스킴·호스트·포트가 **정확히** 맞아야 한다. `https://example.com`과 `https://www.example.com`은 다른 origin이다. - `http://localhost:3000`과 `http://127.0.0.1:3000`도 다르다. 개발용 origin은 따로 넣는다. - 목록에 없는 페이지에서 쓰면 `unauthorized`로 거절된다. 목록은 **키** 화면의 **origin 수정**으로 바꾼다. ## 2. 백엔드가 토큰을 발급한다 엔진은 사람이 누구인지 모른다. 토큰의 서명을 검증하고 그 안의 유저 id를 **액면 그대로** 믿을 뿐이다. 누가 로그인했는지는 서비스가 아니까, 필요한 것은 그 사람에게 토큰을 발급하는 엔드포인트 하나다. ```ts TypeScript // src/routes/api/chat-token/+server.ts (SvelteKit) — 어떤 프레임워크든 모양은 같다 import { json } from '@sveltejs/kit' import { createChatServer } from '@kispi/chat/server' import { env } from '$env/dynamic/private' const chat = createChatServer({ secretKey: env.CHAT_SECRET_KEY // sk_… }) export const POST = async ({ locals }) => { const user = locals.user // 서비스의 세션 if (!user) return new Response(null, { status: 401 }) return json({ token: chat.token({ userId: String(user.id), // 이 사람의 id name: user.nickname, avatar: user.avatarUrl, // 선택 ttlSeconds: 3600 // 선택. 기본 1시간, 최대 24시간 }) }) } ``` ```python Python # chat_token()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다 @app.post("/api/chat-token") def issue_chat_token(): user = current_user() # 서비스의 세션 if user is None: return "", 401 return {"token": chat_token(SK, str(user.id), user.nickname, ttl=3600)} ``` ```go Go // chatToken()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다 func issueChatToken(w http.ResponseWriter, r *http.Request) { user, ok := currentUser(r) // 서비스의 세션 if !ok { http.Error(w, "", http.StatusUnauthorized) return } token, err := chatToken(sk, user.ID, user.Nickname, time.Hour) if err != nil { http.Error(w, "", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"token": token}) } ``` ```kotlin Kotlin // chatToken()은 "서버 연동" 쪽에 있다 — 여기서는 그것을 부르기만 한다 @PostMapping("/api/chat-token") fun issueChatToken(session: HttpSession): Map { val user = session.user ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED) return mapOf("token" to chatToken(sk, user.id.toString(), user.nickname)) } ``` `chat.token()`은 네트워크를 타지 않는 로컬 서명이라 요청마다 불러도 된다. 다른 언어도 같다 — 서명뿐이라 엔진을 부르지 않는다. `name`은 접속할 때마다 이 값으로 덮어쓴다. 토큰 규격 자체는 [서버 연동](https://chat.gravex.app/docs/server)에 있고, 로그인하지 않은 방문자는 [비로그인 사용자](https://chat.gravex.app/docs/guests)가 같은 엔드포인트에서 발급하는 법을 적는다. ## 3. 브라우저가 주고받는다 ```ts // chat.ts import { createChatClient } from '@kispi/chat' export const chat = createChatClient({ key: import.meta.env.VITE_CHAT_PK, // pk_… token: async () => { const res = await fetch('/api/chat-token', { method: 'POST' }) return (await res.json()).token } }) chat.on('state', (s) => setStatus(s)) // connecting | open | reconnecting | closed chat.connect().catch(() => {}) ``` 재접속과 토큰 갱신은 SDK가 한다. 방은 **백엔드가 만든다** — 키로 연다고 방이 생기지 않고, 없는 키를 구독하면 `not_found`다. `rooms.ensure`는 있으면 그대로 돌려주는 get-or-create다. ```ts // 백엔드. 2단계에서 만든 createChatServer 인스턴스다 await chat.rooms.ensure({ key: 'lobby', type: 'public' }) ``` 방 `key`는 영문 대소문자·숫자와 `_` `.` `:` `-`만 쓰고 1~128자다(`^[A-Za-z0-9_.:-]{1,128}$`). 한글이나 공백이 들어가면 `400 invalid`이므로, 사람이 읽는 이름은 `name`에 두고 `key`는 `order-1234` 같은 ASCII 별칭으로 정한다. 아래부터는 다시 브라우저, 3단계에서 만든 `createChatClient` 쪽이다. ```ts const room = chat.roomByKey('lobby') await room.subscribe() // 라이브 피드 + 최근 100건 room.on('messages', (all) => render(all)) // 목록이 바뀔 때마다 통째로 room.on('message.created', (m) => append(m)) // 또는 새 메시지 하나씩 const ack = await room.send({ text: '안녕' }) // { messageId, seq } await room.send({ text: '이거 봐', attachments: [{ type: 'image', url, key }] }) await room.react(ack.messageId, '👍') await room.markRead(room.lastSeq) room.typing() const { hasMore } = await room.loadOlder() // 스크롤을 올려 과거를 볼 때. 끝이면 false ``` `room.messages`는 언제나 seq 오름차순이고 구멍이 없다. 바뀔 때마다 **새 배열**이라 참조로 비교하는 상태(Svelte `$state.raw`, React `useState`, Vue `shallowRef`)에 그대로 넣는다. **파일은 엔진이 보관하지 않는다** — 쓰고 있는 저장소(S3 같은 것)에 올리고 그 주소를 `attachments`에 싣는다(`url`은 `https://`로 시작해야 한다). 두 이벤트는 꼭 처리한다. - **`reset`** — SDK가 목록을 버리고 다시 채웠다. 렌더한 것을 버리고 `room.messages`로 다시 그린다. - **`error`** — 히스토리를 읽는 데 실패했다. 방이 스스로 다시 구독하므로 알리기만 한다. | | 저장되나 | 재접속 뒤 | |---|---|---| | 메시지 | 된다 | SDK가 구멍을 메운다 | | 리액션 | 된다 | 히스토리에 실려 온다. 끊긴 동안의 변화는 `reload()` | | 읽음 커서(`markRead`) | 된다 | `rooms.list()`의 unread가 반영한다 | | presence | 안 된다 | 새로 받는다 | | typing | 안 된다 | 놓쳐도 된다. 한 사람당 많아야 3초에 한 번 오므로 마지막 것 뒤 5초쯤 보여 주고 지운다. 방마다 3초 창에 3명까지만 방송하므로 붐비는 방에서는 입력 중인데 알려지지 않는 사람이 있다 | `typing`은 **보낸 사람에게도 온다** — "X가 입력 중"을 그린다면 자기 `userId`는 건너뛴다. 실패는 `ChatError`로 던지고, `err.code`가 `rate_limited`면 `err.retryAfterMs`만큼 기다린다. ### 한도 속도와 크기의 한도는 앱마다 다르다. 기본값은 아래와 같고, 콘솔의 **한도** 탭에서 앱마다 보고 바꾼다. | 한도 | 기본값 | 세는 단위 | |---|---|---| | 메시지 보내기 | 초당 5, 한꺼번에 10 | 유저 | | 리액션 | 초당 10 | 유저 | | `typing` | 초당 20 | 유저 | | 메시지 수정 | 초당 2 | 유저 | | 접속 | 분당 10 | 유저 | | 동시 연결 | 10 | 유저 | | 접속 수립 | 분당 60 | `pk_` + IP | | 구독 | 100 | 연결 | | 메시지 길이 | 4000자 | 메시지 | | 첨부 | 10개 | 메시지 | | 리액션 종류 | 20개 | 메시지 | | 프레임 크기 | 64 KB | 연결 | | 저장 메시지 수 | 제한 없음 | 앱 | | 수정 가능 시간 | 제한 없음 | 메시지 | - 서버 API(`sk_`)로 보내는 메시지에는 속도 한도가 걸리지 않고, 수정 가능 시간도 적용되지 않는다. - **저장 메시지 수**는 삭제된 메시지(tombstone)도 센다. 한도를 정한 앱이 가득 차면 새 메시지는 `quota_exceeded`(REST 403)로 거절되고, 기다려도 풀리지 않으므로 재시도하지 않는다. - 속도와 크기는 그 앱의 상한까지 올릴 수 있다. 낮추는 것도 되지만 **프레임 크기는 기본 64 KB 아래로 내릴 수 없다.** 보내기 대기열(`sendQueueSize`)은 모든 앱에 같은 값으로 고정이다. - 붙어 있는 연결에는 30초 안에 닿는다. 접속에서 정해지는 값(접속 속도, 동시 연결, 구독 수, 프레임 크기)은 다음 접속부터다. 지금 걸린 값은 접속 때 `hello.limits`로도 온다. ## 4. 웹훅 (선택) 발행·수정·삭제를 백엔드가 들어야 할 때 쓴다 — 푸시 알림, 검색 색인, 감사 로그. 발행 **전에** 끼어들어 거절하거나 본문을 바꾸는 `before_publish`도 있다. 받는 쪽 코드와 서명 검증은 [웹훅](https://chat.gravex.app/docs/webhooks)에 있다. ## 다음 - [서버 연동](https://chat.gravex.app/docs/server) — 토큰 규격, 서버 API, 키 교체. - [SDK 레퍼런스](https://chat.gravex.app/docs/sdk) — 모든 메서드와 이벤트. - [아키텍처와 벤치마크](https://chat.gravex.app/docs/architecture) — 얼마나 버티는지, 어떻게 쟀고 무엇을 못 쟀는지. 원문: https://chat.gravex.app/docs --- # 서버 연동 백엔드가 할 일은 둘이다 — **유저 토큰에 서명하는 것**, **방·메시지·유저를 REST로 다루는 것**. TypeScript에는 이 둘을 감싼 `@kispi/chat/server`가 있고, 다른 언어는 같은 일을 직접 한다. 아래 탭에서 언어를 고른다. 둘 다 `sk_` 하나로 한다. ## 유저 토큰 규격 | | | |---|---| | 형식 | 표준 JWT. 서명 알고리즘은 **HS256 고정**이다. 다른 알고리즘과 `none`은 거절한다 | | 서명 키 | `sk_` **문자열 전체**를 UTF-8 바이트로 쓴다. 접두사를 떼거나 디코드하지 않는다 | | 헤더 | `{"alg":"HS256","typ":"JWT","kid":"…"}` — `kid`는 선택이다. `sk_<키 id>_<토큰>`의 **둘째 조각이 13자 Crockford Base32**(`[0-9A-HJKMNP-TV-Z]{13}`)일 때만 그 값을 달고, 아니면 달지 않는다. `kid`가 없으면 엔진이 앱의 폐기되지 않은 `sk_`를 모두 시도한다 | 클레임: | 클레임 | | | |---|---|---| | `sub` | **필수** | 서비스의 유저 id. UTF-8 128바이트 이하. `anon:`으로 시작하면 거절한다 | | `iat` | **필수** | 발급 시각(정수 초). `지금 + 60초`보다 미래면 거절한다. 반올림하거나 캐시하지 않는다 | | `exp` | **필수** | 만료(정수 초). `exp - iat`이 86400초(24시간)를 넘으면 **자르지 않고 거절한다** | | `name` | 권장 | 접속할 때마다 이 값으로 덮어쓴다. 256바이트에서 잘려 저장된다 | | `avatar` | 선택 | 2048바이트에서 잘려 저장된다 | | `meta` | 선택 | JSON 객체. 유저의 `meta.provider`에 병합된다 | `aud`·`iss`·`nbf`는 요구하지 않는다. **어느 앱인지는 토큰이 아니라** 브라우저가 접속할 때 함께 보내는 `pk_`가 정한다. 검증이 실패하면 이유를 가리지 않고 **401 `unauthorized`** 하나로 답한다 — 서명이 틀렸는지 만료됐는지 구별해 주지 않는다. Go는 `github.com/golang-jwt/jwt/v5`, Kotlin은 `com.auth0:java-jwt`를 쓴다. 나머지는 표준 라이브러리로 충분하다. ```ts TypeScript import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! }) const token = chat.token({ userId: 'u_42', name: '한나', ttlSeconds: 3600 }) ``` ```python Python import base64, hmac, hashlib, json, re, time KID = re.compile(r"[0-9A-HJKMNP-TV-Z]{13}") # Crockford Base32 13자 def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def chat_token(sk, user_id, name, ttl=3600, avatar=None, meta=None): parts = sk.split("_") header = {"alg": "HS256", "typ": "JWT"} if len(parts) >= 3 and KID.fullmatch(parts[1]): header["kid"] = parts[1] iat = int(time.time()) claims = {"sub": user_id, "name": name, "iat": iat, "exp": iat + ttl} if avatar: claims["avatar"] = avatar if meta: claims["meta"] = meta enc = lambda o: b64u(json.dumps(o, separators=(",", ":")).encode()) signing_input = f"{enc(header)}.{enc(claims)}" sig = hmac.new(sk.encode(), signing_input.encode(), hashlib.sha256).digest() return f"{signing_input}.{b64u(sig)}" ``` ```go Go var kidPattern = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{13}$`) // Crockford Base32 13자 func chatToken(sk, userID, name string, ttl time.Duration) (string, error) { now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": userID, "name": name, "iat": now.Unix(), "exp": now.Add(ttl).Unix(), }) if parts := strings.Split(sk, "_"); len(parts) >= 3 && kidPattern.MatchString(parts[1]) { token.Header["kid"] = parts[1] } return token.SignedString([]byte(sk)) } ``` ```kotlin Kotlin private val KID = Regex("^[0-9A-HJKMNP-TV-Z]{13}$") // Crockford Base32 13자 fun chatToken(sk: String, userId: String, name: String, ttlSeconds: Long = 3600): String { val now = Instant.now() val builder = JWT.create() .withSubject(userId) .withClaim("name", name) .withIssuedAt(now) .withExpiresAt(now.plusSeconds(ttlSeconds)) sk.split("_").let { if (it.size >= 3 && KID.matches(it[1])) builder.withKeyId(it[1]) } return builder.sign(Algorithm.HMAC256(sk)) } ``` ## 서버 API 방·메시지·유저를 다루는 창구는 REST 하나다. TypeScript SDK는 그것을 감싼 것이라, 아래 세 탭은 **같은 엔드포인트를 부르는 세 가지 방법**이다. 모든 라우트의 요청·응답·에러는 [REST API 레퍼런스](https://chat.gravex.app/docs/rest-api)에 있다. **TypeScript** `@kispi/chat/server`의 표면 전체다. 메시지 수정, 검색(`q`), 유저 목록 필터, 영구 삭제(`purge`)는 SDK에 메서드가 없으니 REST를 직접 부른다. | 그룹 | 메서드 | |---|---| | 최상위 | `token({userId, name, avatar?, meta?, ttlSeconds?})`, `guest({credential?, name, avatar?, meta?, ttlSeconds?})` — 둘 다 로컬 서명이다 | | `rooms` | `list({type?, cursor?, limit?})`, `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` | | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list(roomId, {before?, after?, limit?})`, `delete` | | `users` | `list({cursor?, limit?})`, `update(userId, {name?, avatar?, meta?})`, `withdraw(userId, {purgeMessages?})`, `purgeMessages(userId)`, `ban(userId, {until, reason?})`, `unban(userId)`, `revokeTokens(userId)`, `disconnect(userId, {connectionId?, reason?})` | | `events` | `list({after?, limit?})` — 놓친 웹훅을 따라잡는다 | | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` | ```ts const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! }) const room = await chat.rooms.ensure({ key: 'lobby', type: 'public' }) await chat.messages.send(room.id, { sender: { id: 'system', name: '공지' }, text: '점검 10분 전입니다' }) ``` **REST** 주소는 `https://api.chat.gravex.app`이다. 인증은 `Authorization: Bearer sk_…`이고, 바디가 있으면 `Content-Type: application/json`을 함께 보낸다. | 용도 | 라우트 | |---|---| | 방 get-or-create | `PUT /v1/rooms` `{key?, type, name?, meta?, members?}` — 201 생성, 200 기존 | | DM | `PUT /v1/rooms` `{type: "dm", members: [a, b]}` | | 방 목록 | `GET /v1/rooms?type=&cursor=&limit=` — `type`을 생략하면 `public`·`channel`이고, `sk_`는 `private`·`dm`도 쉼표로 이어 줄 수 있다(`type=private,dm`). 새 방부터, `limit` 기본 50·최대 100 | | 방 조회·수정·삭제 | `GET`·`PATCH`·`DELETE /v1/rooms/{roomId}`, `GET /v1/rooms/by-key/{key}` | | 방 영구 삭제 | `DELETE /v1/rooms/{roomId}?purge=true` — 202 | | 메시지 보내기 | `POST /v1/rooms/{roomId}/messages` `{clientMessageId, sender: {id, name, avatar?}, kind?, body: {text?, attachments?}, appMeta?, replyTo?, threadId?}` | | 히스토리·검색 | `GET /v1/rooms/{roomId}/messages?before=&after=&limit=&view=&q=` | | 메시지 수정 | `PATCH /v1/rooms/{roomId}/messages/{messageId}` `{body: {text?, attachments?}}` — 200 + 메시지 객체 | | 메시지 삭제 | `DELETE /v1/rooms/{roomId}/messages/{messageId}` — 204 | | 저장되지 않는 제어 신호 | `POST /v1/rooms/{roomId}/custom` `{payload}` | | 멤버 | `GET /v1/rooms/{roomId}/members`, `PUT`·`DELETE /v1/rooms/{roomId}/members/{userId}` `{role?}` | | presence | `GET /v1/rooms/{roomId}/presence?full=true` — 빈 방은 `{count: 0}` | | 유저 프로필 | `PUT /v1/users/{userId}` `{name?, avatar?, meta?}` | | 밴·해제 | `PUT /v1/users/{userId}/ban` `{until, reason?}`(`until`은 unix 밀리초, 필수), `DELETE …/ban` | | 메시지 거두기 | `DELETE /v1/users/{userId}/messages` | | 탈퇴(익명화) | `DELETE /v1/users/{userId}`, 메시지까지 거두려면 `?purge_messages=true` | | 유저 영구 삭제 | `DELETE /v1/users/{userId}?purge=true` — 탈퇴한 유저만, 202 | | 토큰 무효화 | `POST /v1/users/{userId}/revoke-tokens` | | 강제 종료 | `POST /v1/users/{userId}/disconnect` | | 유저 목록 | `GET /v1/users?cursor=&limit=&q=&status=&last_seen_after=&last_seen_before=&created_after=&created_before=&bots=` | | 놓친 이벤트 | `GET /v1/events?after=&limit=` | | 앱 전체 내려받기 | `GET /v1/export` — NDJSON 스트림. 유저·방·멤버·메시지·리액션·읽음 위치가 들어 있고 첨부 파일은 없다. 마지막 줄이 `{"type":"end",…}`가 아니면 불완전한 파일이다. 앱당 동시에 하나(409), 서버가 바쁘면 429. `sk_` 전용 | **curl** ```bash curl # 방 get-or-create. 있으면 200, 새로 만들면 201이고 몸통은 같다 curl -X PUT https://api.chat.gravex.app/v1/rooms \ -H "Authorization: Bearer $CHAT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"key": "lobby", "type": "public"}' # 시스템 메시지 보내기. sender와 clientMessageId가 필수다 curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \ -H "Authorization: Bearer $CHAT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"clientMessageId": "c_1", "sender": {"id": "system", "name": "공지"}, "body": {"text": "점검 10분 전입니다"}}' ``` `sender`와 `clientMessageId`는 서버에서 메시지를 보낼 때 필수다. 쿼리 파라미터는 스네이크 케이스(`purge_messages`)이고 JSON 필드는 카멜 케이스다. `users.*`는 그 사람이 **한 번이라도 접속한 뒤에만** 통한다. 유저는 접속으로 생긴다. ### 히스토리 조회와 검색 - 응답은 seq 오름차순이고 `limit`은 최대 100이다. `before`·`after`는 seq다. - `view`를 생략하면 스레드 답글까지 방 전체이고, `main`은 본 타임라인만, `thread:<루트 메시지 id>`는 그 스레드만 준다. 그 밖의 값은 400이다. - `q`는 그 방 안의 본문(`body.text`) 검색이다. 부분 일치이고 대소문자를 가리지 않으며 삭제된 메시지는 걸리지 않는다. `q`는 `sk_`만 쓰고 유저 토큰으로 주면 403이며, 공백뿐인 `q`는 400이다. **검색 결과의 `seq`는 이어지지 않는다** — 걸리지 않은 메시지가 빠진 것이므로 누락 감지에 쓰지 않는다. ### 메시지 수정과 삭제 `PATCH`는 메시지의 `body`를 통째로 바꾸고 `editedAt`을 찍는다. `seq`는 움직이지 않고 방에는 `message.updated`가 나간다. `body`가 없으면 400이고, 이미 삭제된 메시지는 404다. - `sk_`로 부르면 누가 쓴 메시지든(시스템 메시지 포함) 고치고, 수정 가능 시간도 적용되지 않는다. - 유저 토큰으로 부르면 **본인이 쓴 메시지만**, 앱의 **수정 가능 시간**(`editWindowMs`, 기본은 제한 없음) 안에서만 고친다. 방 owner·admin도 남의 메시지는 고치지 못하고 지우기만 한다. 밴된 유저는 `banned`다. - 속도는 유저마다 초당 2회(`editPerSecond`)다. `sk_` 호출은 세지 않는다. 브라우저 SDK의 `Room`에는 수정·삭제 메서드가 없고 서버 SDK에는 `messages.delete`만 있다. 수정은 REST를 직접 부른다. 시스템 메시지나 모더레이션 수정은 백엔드에서 `sk_`로 부르고, 유저가 자기 메시지를 고치는 화면은 브라우저가 유저 토큰으로 직접 부르면 된다(`Authorization: Bearer <유저 토큰>`과 `X-Chat-Key: pk_…`를 함께 보낸다). 작성자와 수정 가능 시간은 엔진이 검사한다. `DELETE`는 행을 남기고 본문을 비우는 tombstone이다. `seq`는 그대로이고 방에는 `message.deleted`가 나간다. 이미 지워진 메시지를 다시 지워도 204다. 유저 토큰으로는 본인 메시지와, 방 owner·admin이 남의 메시지를 지우는 모더레이션만 된다. ### 삭제의 종류 | 하려는 일 | 호출 | 남는 것 | |---|---|---| | 메시지 하나 지우기 | `DELETE /v1/rooms/{roomId}/messages/{messageId}` | 행과 `seq`, 보낸 사람 이름. 본문만 비운다 | | 한 사람의 메시지 전부 거두기 | `DELETE /v1/users/{userId}/messages` | 위와 같다. 계정은 그대로다 | | 탈퇴 | `DELETE /v1/users/{userId}` | 이름은 "탈퇴한 사용자", 아바타와 `meta`는 비워진다. 유저 행과 외부 id는 남고 메시지도 그대로다 | | 탈퇴 + 메시지 거두기 | `DELETE /v1/users/{userId}?purge_messages=true` | 계정은 탈퇴 상태로 남고, 메시지 본문과 메시지에 찍힌 이름이 지워진다 | | 유저 영구 삭제 | `DELETE /v1/users/{userId}?purge=true` | 유저 행·리액션·읽음 커서·멤버십·밴이 사라지고 메시지의 이름도 비워진다. 메시지 행과 `sender.id`는 남는다 | | 방 삭제 | `DELETE /v1/rooms/{roomId}` | 방이 보이지 않게 되고 `key`는 비워져 같은 `key`로 다시 만들 수 있다. 메시지 행은 남는다 | | 방 영구 삭제 | `DELETE /v1/rooms/{roomId}?purge=true` | 방과 그 안의 모든 메시지가 사라진다 | - **탈퇴(`withdraw`)는 익명화일 뿐 삭제가 아니다.** 유저 행이 남고, 탈퇴만으로는 메시지 본문과 그 메시지에 찍힌 이름도 남는다. SDK의 `users.withdraw(userId, {purgeMessages})`가 이것이다. - **개인정보 삭제 요청에는 `purge`까지 간다.** 순서는 ① `DELETE /v1/users/{userId}?purge_messages=true`를 응답의 `purgeCapped`가 `false`가 될 때까지 부르고, ② `DELETE /v1/users/{userId}?purge=true`를 부른다. ②는 탈퇴하지 않은 유저에게 409다. - `purge`는 **되돌릴 수 없다.** 요청은 202로 접수되고 엔진이 배치로 지운다. `sk_` 전용이며 유저 토큰은 403이다. 같은 대상에 진행 중인 작업이 있으면 같은 작업으로 접수된다. 유저 `purge` 뒤에는 그 외부 id를 다시 쓸 수 있게 되는데, 남은 옛 메시지의 `sender.id`가 같은 문자열을 들고 있으므로 **새 사람에게 옛 id를 재사용하지 않는다.** - 유저 `purge`는 SDK에 메서드가 없으니 REST를 직접 부른다. - `purge`가 `true`나 `false`가 아니면 400이다. ### 유저 목록 필터 `GET /v1/users`는 아래 조건을 선택으로 받는다. 해석되지 않는 값은 400이고, 필터를 바꾸면 `cursor`를 버리고 첫 페이지부터 다시 받는다. | 파라미터 | 값 | |---|---| | `q` | `externalId` 접두어 일치 또는 `name` 부분 일치. 대소문자를 가리지 않는다 | | `status` | `live`, `withdrawn`, `all`(기본) | | `last_seen_after`, `last_seen_before` | RFC3339. 마지막 접속 시각의 범위 | | `created_after`, `created_before` | RFC3339. 가입 시각의 범위 | | `bots` | `include`(기본), `exclude`, `only` | 시각 범위는 `*_after`는 포함, `*_before`는 제외다. 탈퇴한 유저도 목록에 나오고 `deletedAt`이 그 표시다. 방을 만들 때 고르는 타입: | 타입 | 누가 읽나 | 누가 쓰나 | 찾는 법 | |---|---|---|---| | `public` | 누구나 | 누구나 | 키 또는 id. 탐색에 나온다 | | `private` | 멤버만 | 멤버만 | 키 또는 id | | `channel` | 누구나 | owner·admin만 | 키 또는 id. 탐색에 나온다 | | `dm` | 두 사람만 | 두 사람만 | 두 사람의 조합. 키가 없다 | 방의 정본은 `id`다. `key`는 사람이 붙이는 별칭(`lobby`, `order-1234`)이고 브라우저는 `chat.roomByKey(key)`로 연다. `key`는 영문 대소문자·숫자와 `_` `.` `:` `-`만 쓰는 1~128자 (`^[A-Za-z0-9_.:-]{1,128}$`)여야 한다. 한글이나 공백이 들어가면 `PUT /v1/rooms`와 `PATCH`가 400을 돌려주므로, 사람이 읽는 이름은 `name`에 둔다. `dm` 방에는 `key`가 없다. `channel`을 서버에서 만들면 owner가 없으므로, 유저 토큰으로 공지를 올릴 사람이 있어야 한다면 만든 뒤 멤버 API로 owner나 admin을 지정한다. ## 키 교체 `sk_`는 **새 키 발급 → 백엔드 배포 → 확인 → 옛 키 폐기** 순서로 바꾼다. 1. 콘솔 **키** 화면에서 **새 secret key**를 누른다. 전체 값은 이 창에서만 보인다. 2. 백엔드의 `sk_`를 새 값으로 바꾸고 **배포한다.** 이 시점에 두 키가 모두 살아 있다. 3. 새 키로 서명한 토큰으로 접속이 되는지, 서버 API가 도는지 확인한다. 4. 콘솔에서 옛 키를 **폐기**한다. **폐기를 먼저 하지 않는다.** 옛 키를 먼저 끄면 그 키로 서명하던 백엔드가 그 순간 멈춘다. 배포한 뒤에도 브라우저에는 옛 키로 서명된 토큰이 남아 있다. 토큰의 `kid`가 가리키는 키를 엔진이 확인하므로, **옛 키를 폐기하지 않는 동안 그 토큰들은 그대로 동작한다.** 토큰 수명이 한 바퀴 돌면(기본 1시간) 나가 있는 토큰이 전부 새 키로 갈린다 — 그때 폐기하면 아무도 튕기지 않는다. 더 빨리 폐기해도 옛 토큰은 다음 호출에서 새 토큰으로 갈린다. `pk_`도 모양은 같다. 한 가지가 더 있다 — **허용 origin은 앱이 아니라 키마다 붙어 있다.** 목록이 빈 `pk_`는 어떤 브라우저 페이지도 받지 못하므로, 새 키를 발급할 때 지금 키가 가진 origin을 그대로 넣는다. origin만 바꾸면 되는 경우에는 키를 바꾸지 않고 **키** 화면의 **origin 수정**으로 같은 키의 목록을 고친다. 폐기는 즉시 효력이 있고 되돌릴 수 없다. 키를 잃어도 데이터는 잃지 않는다 — 유저·방·메시지는 앱에 달려 있다. 운영과 개발은 키가 아니라 **앱으로 가른다.** 앱이 테넌트이므로 데이터의 경계도 앱이다. 앱을 둘 만들고 콘솔에서 **프로덕션**·**개발** 라벨을 붙인다. 원문: https://chat.gravex.app/docs/server --- # 비로그인 사용자 (게스트) 로그인하지 않은 방문자도 채팅방에서 말할 수 있게 하는 법. 커뮤니티 오픈채팅, 라이브 방송 채팅, 고객 상담 위젯처럼 **가입 없이 바로 대화**가 중요한 서비스에 쓴다. > 이 문서의 요점은 하나다: **게스트의 신원은 id가 아니라 서명된 자격증명으로 들고 다닌다.** > 채팅 엔진은 백엔드가 서명한 토큰을 믿을 뿐이라, 누가 누구인지 판단하는 것은 > 백엔드다. `@kispi/chat/server`의 `chat.guest()`가 그 판단을 안전하게 하는 도구다. ## 흔한 실수 — 브라우저가 준 id에 서명하기 ```ts // ❌ 이렇게 하지 않는다 export const POST = async ({ request }) => { const { guestId } = await request.json() // 브라우저가 localStorage에서 꺼내 보낸 값 const userId = isValidGuestId(guestId) ? guestId : `g_${crypto.randomUUID()}` return json({ userId, token: chat.token({ userId, name: '손님' }) }) } ``` 게스트 id는 비밀이 아니다. 모든 메시지에 `sender.id`로 실려 방의 모든 사람에게 간다. 그러니 위 엔드포인트는 남의 메시지에서 id를 복사해 보낸 사람에게 그 게스트의 토큰을 서명해 준다 — 사칭, 남의 메시지 수정·삭제, 밴 회피가 전부 열린다. `g_` + UUID 같은 **형식 검사는 이것을 막지 못한다.** ## 안전한 방법 — `chat.guest()` 브라우저에 id 대신 **자격증명**을 들려 보낸다. 자격증명은 `g1..<서명>` 모양이고 서명은 백엔드만 아는 `guestSecret`으로 만든다. 복사한 id로는 만들 수 없다. ### 1. 비밀 하나를 만든다 ```bash openssl rand -base64 48 # → CHAT_GUEST_SECRET (32자 이상) ``` 백엔드 환경변수에만 둔다. **`sk_`와 다른 값이어야 한다** — `sk_`를 교체할 때마다 모든 게스트가 새 사람이 되면 안 되기 때문이다. ### 2. 토큰 엔드포인트에서 로그인 유저와 게스트를 가른다 ```ts TypeScript import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: env.CHAT_SECRET_KEY, guestSecret: env.CHAT_GUEST_SECRET, }) // SvelteKit 예시. Express·Nuxt(Nitro)·Next도 모양은 같다. export const POST = async ({ locals, cookies }) => { const user = locals.user if (user) { return json({ token: chat.token({ userId: String(user.id), name: user.nickname }) }) } const guest = chat.guest({ credential: cookies.get('chat_guest'), // 없거나 위조면 새 게스트가 만들어진다 name: '손님', }) // httpOnly: 페이지 스크립트(XSS)가 읽지 못하게 cookies.set('chat_guest', guest.credential, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 400, }) return json({ token: guest.token }) } ``` ```python Python import base64, hmac, hashlib, os, secrets SECRET = os.environ["CHAT_GUEST_SECRET"].encode() LABEL = b"kispi-chat/guest/v1\n" # SDK와 같은 도메인 라벨. 빼면 TS 백엔드와 서로의 자격증명을 못 읽는다 def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def mac(guest_id): return b64u(hmac.new(SECRET, LABEL + guest_id.encode(), hashlib.sha256).digest()) def verified_id(cookie): parts = (cookie or "").split(".") if len(parts) != 3 or parts[0] != "g1" or not parts[1].startswith("g_"): return None # compare_digest: 서명을 앞자리부터 맞혀 나가는 시간차 공격을 막는다 return parts[1] if hmac.compare_digest(mac(parts[1]), parts[2]) else None # 쿠키가 서명을 통과하면 그 게스트를 잇고, 아니면 새로 만든다 def guest(cookie): guest_id = verified_id(cookie) created = guest_id is None if created: guest_id = "g_" + b64u(secrets.token_bytes(16)) # 22자 # 쿠키는 httpOnly secure로 내려보내고, 토큰은 /docs/server의 chat_token()으로 서명한다 return guest_id, f"g1.{guest_id}.{mac(guest_id)}", created ``` `g1..<서명>`이라는 **형식은 이 백엔드가 정한 것이고 엔진과의 계약이 아니다.** 엔진은 서명된 토큰의 유저 id만 본다. `guest()`는 `{ userId, credential, token, created }`를 돌려준다. 네트워크도 DB도 쓰지 않는다. `created`가 `true`면 이번에 처음 만든 게스트다(환영 문구를 띄우는 데 쓸 수 있다). ### 3. 브라우저는 아무것도 보관하지 않는다 클라이언트 코드는 로그인 유저일 때와 같다. 쿠키는 브라우저가 알아서 실어 보낸다. ```ts const chat = createChatClient({ key: import.meta.env.VITE_CHAT_PK, // pk_… token: async () => (await fetch('/api/chat-token', { method: 'POST' }).then(r => r.json())).token, }) ``` `localStorage`에 게스트 id를 넣던 코드는 지운다. ### 다른 언어에서 위 Python 탭이 그 전부다. 세션 테이블이든 서명된 쿠키든 원하는 방식대로 해도 되고, 지킬 것은 둘이다 — **브라우저가 돌려준 userId를 그대로 서명하지 않는다**, 그리고 **이 엔드포인트에 IP당 호출 제한을 건다.** ## 지켜야 할 것 | | | |---|---| | **id 공간을 나눈다** | 게스트 id는 `g_`로 시작한다. 로그인 유저의 id가 `g_`로 시작하지 않게 하면 두 공간이 겹치지 않는다 | | **호출 제한** | 게스트 발급은 인증 없이 열린 문이다. 토큰 엔드포인트에 IP당 호출 제한을 건다. 엔진은 누가 게스트를 몇 명 만드는지 모른다 | | **비밀 교체** | `guestSecret: [새것, 옛것]`처럼 배열로 준다. 새것으로 서명하고 전부로 검증하며, 옛것으로 검증된 자격증명은 같은 id로 새로 서명해 돌려준다. 방문이 한 바퀴 돌고 나면 옛것을 뺀다 | | **밴** | 게스트 밴은 그 `userId`에 걸린다. 쿠키를 지우면 새 게스트가 되므로, 게스트 남용이 문제라면 IP 기반 제한이나 "게스트는 읽기만" 정책을 함께 쓴다 | ## CDN이나 프록시 뒤라면 토큰 엔드포인트(`/api/chat-token` 같은 것) 앞에 CloudFront·Cloudflare·nginx가 있으면 세 가지를 확인한다. 셋 다 엔진이 아니라 **인프라 쪽 설정**이고, 틀리면 에러 없이 조용히 틀린다. | | 증상 | 할 일 | |---|---|---| | **쿠키 전달** | 새로고침할 때마다 새 게스트가 된다 | CDN이 오리진에 쿠키를 넘기는지 본다. CloudFront는 기본 설정이 쿠키를 넘기지 않는다 — 토큰 경로에만 쿠키를 전달하는 캐시·오리진 요청 정책을 붙인다 | | **캐시 끄기** | 서로 다른 방문자가 같은 게스트가 된다 | 토큰 응답은 사람마다 다르다. 토큰 경로는 캐시하지 않고, 응답에 `Cache-Control: no-store`를 둔다 | | **방문자 IP** | IP당 제한이 CDN 엣지 몇 대에 걸려 모두를 막거나 아무도 못 막는다 | 요청의 IP는 방문자가 아니라 바로 앞 프록시다. **직접 둔 프록시가 넣은 헤더만** 믿는다(CloudFront는 `CloudFront-Viewer-Address`, nginx는 `real_ip_header` + `set_real_ip_from`으로 신뢰할 대역을 좁힌다) | 방문자 IP 헤더는 **오리진에 직접 붙으면 누구나 위조할 수 있다.** 오리진을 CDN에서만 받게 막거나 (보안 그룹을 CDN 대역으로, 또는 CDN이 붙이는 비밀 헤더를 오리진이 검사), 그럴 수 없다면 그 헤더를 보지 않고 직접 받은 IP로 제한한다. 브라우저가 엔진에 붙는 경로(`api.chat.gravex.app`)는 이와 무관하다 — 거기에는 프록시를 둘 일이 없다. 원문: https://chat.gravex.app/docs/guests --- # 웹훅 앱의 이벤트(발행·수정·삭제·리액션…)를 백엔드가 HTTP로 받는다. 푸시 알림, 검색 색인, 감사 로그, 외부 시스템 동기화가 여기 붙는다. > **웹훅 주소는 콘솔의 웹훅 화면에서 설정한다.** 앱 개요 옆 "웹훅" 탭에서 받을 주소(와 > 필요하면 `before_publish`)를 넣으면, 서명 시크릿을 그 화면이 한 번 보여 준다 — 콘솔은 > 이 값을 저장하지 않으니 그 자리에서 엔드포인트의 검증 코드에 옮겨 적는다. ## 받는 코드 ```ts TypeScript import express from 'express' import { createChatServer } from '@kispi/chat/server' const app = express() const seen = new Set() // 실제로는 Redis나 DB처럼 프로세스 밖에 둔다 const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, webhookSecret: process.env.CHAT_WEBHOOK_SECRET // 콘솔의 웹훅 화면이 한 번 보여준 서명 시크릿 }) app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { let payload try { payload = chat.webhooks.verify(req.headers, req.body.toString('utf8')) } catch { return res.sendStatus(400) // 서명이 맞지 않는다. 아무것도 처리하지 않는다 } const { event, delivery, data } = payload if (seen.has(delivery)) return res.sendStatus(200) // at-least-once다 seen.add(delivery) res.sendStatus(200) // 10초 안에 먼저 답하고 void handle(event, data) // 처리는 비동기로 }) ``` ```python Python import hmac, hashlib, json, time # raw_body는 받은 바이트 그대로다. 파싱한 뒤 다시 직렬화하면 서명이 맞지 않는다. # 서명이 맞으면 파싱한 바디를, 아니면 None(→ 400)을 돌려준다. def verify(headers, raw_body, secret): event = headers.get("X-Chat-Event", "") delivery = headers.get("X-Chat-Delivery", "") # before_publish에는 이 헤더가 없다 timestamp, macs = None, [] for item in headers.get("X-Chat-Signature", "").split(","): name, _, value = item.strip().partition("=") if name == "t": timestamp = value elif name == "v1": macs.append(value) # 여러 개일 수 있고 하나라도 맞으면 통과다 if timestamp is None or not macs: return None if abs(time.time() - int(timestamp)) > 300: # 양방향 5분. 미래 시각도 거절이다 return None signed = f"{timestamp}\n{event}\n{delivery}\n".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if not any(hmac.compare_digest(expected, mac) for mac in macs): return None return json.loads(raw_body) ``` ## 받는 이벤트 요청 바디(`data`)가 이벤트의 내용이고, 이벤트 이름은 `X-Chat-Event` 헤더에, 배달 id는 `X-Chat-Delivery` 헤더에 있다. `chat.webhooks.verify()`는 이 셋을 `{ event, delivery, data }`로 묶어 준다. id는 13자 문자열이고 시각은 unix 밀리초다. | 이벤트 | 언제 | `data` | |---|---|---| | `message.created` | 메시지가 저장될 때(스레드 답글 포함) | 메시지 객체(아래) | | `message.updated` | 본문이 수정될 때 | 메시지 객체. `editedAt`이 찍혀 있다 | | `message.deleted` | 메시지가 지워질 때(본인 삭제, 모더레이션, 유저의 메시지 거두기 포함) | `{id, seq}` | | `reaction.added` | 리액션이 눌릴 때 | `{messageId, emoji, count, userId}` — `count`는 그 이모지의 현재 개수 | | `reaction.removed` | 리액션이 취소될 때 | 위와 같다 | | `thread.updated` | 스레드 루트에 답글이 달리거나 지워질 때 | `{rootId, count, lastSeq, lastAt}` | | `read` | 멤버의 읽음 커서가 앞으로 움직일 때 | `{userId, seq, threadId?}` | | `member.joined` | 멤버가 방에 들어올 때 | `{userId, role}` | | `member.left` | 멤버가 방에서 나갈 때 | `{userId}` | | `room.updated` | 방의 `key`·`name`·`meta`가 바뀔 때 | 방 객체 `{id, appId, key?, type, name?, meta, lastSeq, minSeq, createdAt, updatedAt}` | | `room.deleted` | 방이 삭제될 때 | `{roomId}` | ```json { "roomId": "0K3M9X2T5Q8RA", "id": "0K3M9X4B7N1HC", "appId": "0K3M8Y1P6D5WE", "seq": 42, "sender": { "id": "u_42", "name": "한나" }, "kind": "text", "body": { "text": "안녕" }, "clientMsgId": "c_1", "createdAt": 1760000000000 } ``` 메시지 객체에는 해당할 때만 `replyTo`, `threadId`, `mentions`(멘션된 유저의 외부 id 배열), `meta`, `appMeta`, `editedAt`, `deletedAt`이 더 있고, `body`에는 `attachments`와 `entities`가 실릴 수 있다. 리액션 집계와 스레드 집계는 싣지 않는다 — 각각 `reaction.*`와 `thread.updated`로 따로 온다. - 방 id는 `message.created`·`message.updated`의 `roomId`, `room.deleted`의 `roomId`, `room.updated`의 `id`에만 있다. 나머지 이벤트의 페이로드에는 방 id가 없다. - **콘솔에는 받을 이벤트를 고르는 설정이 없어서 위 이벤트가 전부 온다.** 특히 `read`는 멤버의 읽음 커서가 움직일 때마다 한 건씩 오는, 가장 자주 오는 이벤트다. 필요하지 않은 이벤트는 `X-Chat-Event`(또는 `event`)로 가려 200만 답하고 버린다. - `typing`·`presence`·`custom`·`notification.message`·`user.presence`는 저장되지 않는 이벤트라 웹훅으로 오지 않는다. ## 지킬 것 넷 1. **바디는 raw 그대로 검증한다.** 서명은 받은 **바이트**에 대한 것이라, JSON으로 파싱했다가 다시 직렬화한 문자열로는 맞지 않는다. 프레임워크의 JSON 파서가 먼저 바디를 먹지 않게 이 라우트만 raw로 받는다(Express는 `express.raw`, SvelteKit은 `await request.text()`). 2. **배달은 at-least-once다.** 같은 배달이 두 번 올 수 있다. 모든 배달에는 `delivery` id가 있고 **재시도해도 바뀌지 않는다.** 처리한 것을 기억해 두고 두 번째는 200만 답한다. 3. **10초 안에 2xx로 답한다.** 넘기거나 2xx가 아니면 실패로 치고 백오프가 시작된다(1초 → 5초 → 25초 → 2분 → 10분). 실패하면 그 앱의 배달은 **그 이벤트에서 기다리고** 뒤의 것을 먼저 보내지 않는다. 1시간 넘게 이어지면 배달이 멈춘다(아래 「배달이 멈췄을 때」). 무거운 일은 큐에 넣고 먼저 답한다. 4. **순서는 방 안에서만 보장된다.** 한 메시지에 대한 이벤트(`message.created` → `reaction.added` → `message.deleted`)는 뒤집히지 않지만, 다른 방의 이벤트끼리는 뒤집힐 수 있다. ## 배달이 멈췄을 때 엔드포인트가 **처음 실패한 때부터 1시간 넘게** 계속 실패하면 엔진이 그 앱의 배달을 멈춘다. 이 멈춤은 **저절로 풀리지 않는다** — 엔드포인트가 다시 살아나도 사람이 재개하기 전에는 이벤트가 한 건도 가지 않는다. 쌓인 이벤트는 건너뛰지 않고 큐에 그대로 남는다. 1. 콘솔의 앱 **웹훅** 화면에 "이 앱의 웹훅 전송이 멈췄습니다" 안내가 뜬다. 멈추기 전에는 "웹훅 전송이 밀려 있습니다" 경고가 먼저 보이고, 미전송 건수와 가장 오래된 이벤트의 나이도 같은 화면에 있다. 2. 엔드포인트가 2xx로 답하는지 먼저 고친다. 3. 안내의 **다시 시작**을 누르고 확인 창에서 **다시 시작**을 한 번 더 누른다. 멈춘 지점부터 순서대로 배달된다. ## 놓친 것 따라잡기 엔드포인트가 한동안 죽어 있었으면 `chat.events.list({ after, limit })`로 지난 이벤트를 읽어 따라잡는다. 각 항목은 `{ delivery, event, createdAt, data }`이고 `data`는 웹훅이 POST하는 바디와 같다. 마지막으로 처리한 `delivery`를 `after`로 주고, 빈 `cursor`가 오면 그게 전부였다는 뜻이다. 여기서도 `delivery`로 중복을 거른다 — 같은 이벤트가 웹훅과 이 목록 양쪽에서 올 수 있다. 배달이 끝난 이벤트는 7일까지만 남으니 따라잡기를 그보다 미루지 않는다. 이 목록은 **웹훅 URL이 설정된 앱에서만 쌓인다.** URL이 없는 동안 일어난 일은 나중에 URL을 설정해도 목록에 없다. ## `before_publish` — 발행 전에 끼어들기 켜 두면 **발행과 편집이 엔드포인트를 동기로 지나간다.** 거절하거나 본문을 바꿔 돌려줄 수 있는 유일한 웹훅이다. 금칙어 치환, 이미지 모더레이션, 스팸 차단이 여기 산다. ``` → { "event": "before_publish", "op": "create" | "update", "messageId"?: "…", "room": "…", "sender": {…}, "kind": "text", "body": {…}, "meta"?: {…} } ← { "action": "allow" | "deny", "body"?: {…}, "appMeta"?: {…}, "code"?: "…", "message"?: "…" } ``` - 검증하는 방법은 같다. 다만 질문이라 `delivery`가 없고 중복 제거 대상이 아니다. - `deny`면 보낸 사람의 `send`가 `moderation_denied`로 실패하고, 엔드포인트가 준 `code`가 클라이언트의 `err.appCode`에, `message`가 에러 문구에 실린다. - `body`를 돌려주면 **그것으로 바꿔 저장하고 방송한다.** 바뀐 본문도 크기 검사를 다시 받는다. - `action`이 `allow`/`deny`가 아니면 실패로 친다. 오타가 모더레이션 우회가 되지 않게. - 엔드포인트가 타임아웃되거나 에러면 기본은 **`allow`**다. `deny`로 바꿀 수도 있지만, 그러면 엔드포인트가 죽었을 때 그 앱의 발행이 전부 멈춘다. - 서버 API(`sk_`)로 보내는 메시지는 심사하지 않는다. 빨라야 한다. 이 호출이 끝날 때까지 보낸 사람의 메시지가 기다리고, 제한 시간은 1.5초다. ## 로컬에서 받기 웹훅 주소는 **https**여야 하고 엔진이 인터넷으로 닿을 수 있어야 한다. `localhost`·사설 IP는 받지 않는다. 로컬 개발 서버로 받으려면 cloudflared(`cloudflared tunnel --url http://localhost:3000`)나 ngrok 같은 터널을 띄우고, 터널이 준 https 주소를 웹훅 화면이나 [콘솔 API](https://chat.gravex.app/docs/console-api)의 `PATCH /api/apps/{id}/webhook`에 넣는다. 터널 주소는 다시 띄울 때마다 바뀔 수 있으니 그때마다 고친다. 터널은 왕복마다 수백 ms를 더한다. `before_publish`를 켰다면 제한 시간 1.5초를 넘기기 쉽고, 넘기면 기본값대로 **`allow`**로 지나간다 — 로컬에서 심사가 안 먹는 것처럼 보이면 터널 지연부터 의심한다. 시험하는 동안 `beforePublishOnError`를 `deny`로 두면 시간 초과가 `moderation_denied`로 드러난다. ## 다른 언어로 검증하기 요청에 헤더 셋이 온다. | 헤더 | | |---|---| | `X-Chat-Event` | 이벤트 이름. `before_publish`면 그 값이 온다 | | `X-Chat-Delivery` | 배달 id. **`before_publish`에는 이 헤더가 없다** | | `X-Chat-Signature` | `t=,v1=`. 엔진은 지금 `v1`을 하나 보낸다. 검증 코드는 `v1`이 여러 개 와도 읽고 **하나라도 맞으면 통과**시키도록 짠다 | 서명 입력은 네 조각을 줄바꿈(`\n`)으로 이은 것이다. ``` <받은 바디 바이트 그대로> ``` `v1`은 `hex(HMAC-SHA256(웹훅 시크릿, 서명 입력))`이다. 시크릿은 `sk_`가 아니라 콘솔의 웹훅 화면이 따로 보여준 **웹훅 시크릿**이다. `지금 - t`의 절대값이 **5분**을 넘으면 거절한다(양방향 — 미래 시각도 거절이다). 재시도는 `t`만 갱신하고 `X-Chat-Delivery`는 유지하므로, 중복 제거는 그 값으로 한다. ## 시크릿 교체 콘솔의 앱 **웹훅** 화면에서 **시크릿 교체**를 누르고 확인하면 새 웹훅 시크릿이 그 자리에서 한 번 보인다. **옛 시크릿과 새 시크릿이 겹치는 기간은 없다.** 서명은 늘 시크릿 하나(`v1` 하나)로 하므로, 교체한 순간부터 모든 배달이 새 시크릿으로 서명된다. 밀려 있던 이벤트와 재시도 중인 이벤트도 마찬가지고, 옛 시크릿으로는 더 이상 검증되지 않는다. 1. **시크릿 교체**를 누르고 새 시크릿을 복사한다. 2. 엔드포인트의 웹훅 시크릿(예: `CHAT_WEBHOOK_SECRET`)을 새 값으로 바꿔 **바로 배포한다.** 3. 배포가 끝나기 전에 온 배달은 서명이 맞지 않아 실패로 기록되고 백오프로 다시 온다. 1시간 안에 배포하면 빠지는 이벤트가 없다. 그보다 길어져 배달이 멈췄다면 **웹훅** 화면에서 **다시 시작**을 누른다. 원문: https://chat.gravex.app/docs/webhooks --- # 이미지 업로드와 모더레이션 채팅에 사진을 올리게 하는 법. **엔진은 파일을 받지 않는다.** 메시지에는 주소와 메타데이터만 실리고, 파일을 받아 저장하고 검사하는 일은 내 백엔드와 내 버킷이 한다. 이 글은 AWS(S3 + Rekognition) 기준의 권장 구조다. > 요점은 하나다: **검사를 통과하기 전의 파일은 비공개 접두사에만 있고, 통과한 파일만 공개 접두사로 > 옮겨진다.** 그러면 `before_publish` 훅이 할 일은 "이 주소가 내 공개 접두사의 이 사람 파일인가"를 > 문자열로 확인하는 것 하나뿐이다. ## 엔진이 하는 일과 안 하는 일 | | | |---|---| | 하는 일 | `body.attachments[]`를 저장하고 방에 전달한다. 한 메시지에 기본 10개까지, `url`은 `https://`여야 한다 | | 안 하는 일 | 파일을 받지 않는다. 주소가 가리키는 곳을 열어 보지 않고, 형식을 바꾸거나 검사하거나 지우지 않는다 | | 첨부 모양 | `{ type: "image", url, key?, mime?, w?, h?, name?, size? }` — 엔진이 읽는 것은 `url`뿐이고 나머지는 화면이 쓴다 | | 내보내기 | `GET /v1/export`에는 이 메타데이터만 들어가고 **파일은 없다.** 파일은 내 버킷에서 따로 가져간다 | ## 흐름 ```text 1 브라우저 ──→ 내 백엔드 업로드 요청 → presigned POST 발급(타입·크기·만료가 서명에 묶임) 2 브라우저 ──→ S3 incoming/ 파일을 S3로 직접 POST (비공개 접두사, 내 서버를 거치지 않는다) 3 브라우저 ──→ 내 백엔드 "다 올렸다" 알림 4 내 백엔드 ←→ S3, Rekognition 읽기 → 재인코딩 → DetectModerationLabels 통과: public/ 에 저장하고 incoming/ 삭제 / 실패: incoming/ 삭제, 422 5 브라우저 ──→ 채팅 엔진 room.send({ attachments: [4단계가 돌려준 첨부] }) 6 채팅 엔진 ──→ 내 백엔드 before_publish: 주소가 내 CDN의 public/{보낸 사람}/ 아래인가 ``` 각 선택의 이유: | 선택 | 이유 | |---|---| | 접두사를 둘로 가른다 | `incoming/`은 CDN에 물리지 않아 검사 전에는 아무도 볼 수 없다. `public/`은 백엔드만 쓴다 | | 타입·크기를 서명 조건으로 묶는다 | 브라우저가 정책을 어기면 S3가 업로드 자체를 거절한다. presigned **PUT**은 크기를 못 묶으니 POST 정책을 쓴다 | | 키에 사용자 id를 넣는다 | 훅이 "내 파일만 첨부"를 문자열로 확인할 수 있고, 탈퇴 때 `public/{userId}/`를 통째로 지울 수 있다 | | 서버가 S3에서 바이트를 직접 읽는다 | 브라우저가 준 URL을 서버가 받아 오는 방식은 내부 주소를 찌르는 SSRF 통로가 된다 | | 재인코딩한 뒤 검사한다 | EXIF(촬영 위치 등)가 빠지고, 이미지로 위장한 파일이 걸러지고, **검사한 바이트가 곧 서빙하는 바이트**가 된다 | | 훅은 AWS를 부르지 않는다 | 훅은 발행을 막고 기다리는 동기 호출이다(기본 1.5초, 최대 5초). 느린 검사는 업로드 쪽에서 끝낸다 | ## 1. 버킷과 권한 버킷 `my-chat-uploads`는 **퍼블릭 액세스를 전부 차단**한다. 브라우저가 S3에 직접 POST하므로 CORS가 필요하다. ```json [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["POST"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3000 } ] ``` 백엔드 역할(role)의 최소 권한이다. 키는 코드에 넣지 않고 실행 환경(EC2·ECS·Lambda)의 IAM 역할로 준다. ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "Incoming", "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::my-chat-uploads/incoming/*" }, { "Sid": "Public", "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::my-chat-uploads/public/*" }, { "Sid": "ListPublicForCleanup", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::my-chat-uploads", "Condition": { "StringLike": { "s3:prefix": ["public/*"] } } }, { "Sid": "Moderation", "Effect": "Allow", "Action": "rekognition:DetectModerationLabels", "Resource": "*" } ] } ``` 검사 전 파일은 `incoming/`에만 올라가므로, 검사에 실패하거나 사용자가 3단계를 건너뛴 파일은 수명 규칙으로 치운다. ```json { "Rules": [ { "ID": "expire-incoming", "Status": "Enabled", "Filter": { "Prefix": "incoming/" }, "Expiration": { "Days": 1 } } ] } ``` ## 2. 백엔드: 업로드 허가증 발급 로그인한 사용자에게만 발급한다. 정책에 **정확한 키·콘텐츠 타입·크기 범위·만료**를 넣고, 키는 서버가 정한다. ```sh npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-rekognition sharp ``` ```ts // uploads.ts import { randomUUID } from 'node:crypto' import { S3Client } from '@aws-sdk/client-s3' import { createPresignedPost } from '@aws-sdk/s3-presigned-post' export const s3 = new S3Client({}) // 리전·자격 증명은 실행 환경에서 온다 export const BUCKET = process.env.UPLOAD_BUCKET! // my-chat-uploads export const MAX_BYTES = 5_000_000 const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) // svg는 받지 않는다(스크립트를 품을 수 있다) // userId는 내 서비스의 유저 id다. 키에 들어가므로 [A-Za-z0-9_-]만 쓰는 값이어야 한다. export async function issueUpload(userId: string, contentType: string) { if (!TYPES.has(contentType)) throw new HttpError(400, 'invalid_content_type') const id = randomUUID() const { url, fields } = await createPresignedPost(s3, { Bucket: BUCKET, Key: `incoming/${userId}/${id}`, Fields: { 'Content-Type': contentType }, Conditions: [ ['eq', '$Content-Type', contentType], // 이 타입 하나만 ['content-length-range', 1, MAX_BYTES] // 1바이트 이상 MAX_BYTES 이하 ], Expires: 60 // 초. 짧게 }) return { id, url, fields } } ``` ```ts // 라우트 예시(Express). 인증은 서비스의 세션을 쓴다 app.post('/api/uploads', requireLogin, async (req, res) => { res.json(await issueUpload(req.user.id, String(req.body.contentType))) }) ``` `HttpError`는 서비스의 에러 타입으로 바꿔 쓴다. 사용자별 발급 횟수 제한도 여기 건다(아래 「지킬 것」). ## 3. 브라우저: 올리고, 알리고, 보내기 S3 폼은 **`file` 필드가 맨 마지막**이어야 한다. 앞쪽 필드를 먼저 채우고 파일을 마지막에 붙인다. ```ts import { ChatError } from '@kispi/chat' import { chat } from './chat' // 시작하기의 createChatClient const room = chat.roomByKey('lobby') await room.subscribe() async function sendImage(file: File, text?: string) { // 1. 허가증 const ticket = await api('/api/uploads', { contentType: file.type }) // 2. S3로 직접 업로드 const form = new FormData() for (const [name, value] of Object.entries(ticket.fields)) form.append(name, value as string) form.append('file', file) // 맨 마지막 const up = await fetch(ticket.url, { method: 'POST', body: form }) if (!up.ok) throw new Error('upload_failed') // 3. 검사와 공개 전환(백엔드). 통과하면 첨부 객체를 돌려준다 const attachment = await api(`/api/uploads/${ticket.id}/complete`) // 4. 채팅 엔진에 발행. 서버가 돌려준 객체를 그대로 싣는다 try { await room.send({ text, attachments: [attachment] }) } catch (err) { if (err instanceof ChatError && err.code === 'moderation_denied') { showError(err.message) // 훅이 거절했다. err.appCode로 분기할 수 있다 } else throw err } } // POST JSON을 보내고 JSON을 받는 작은 헬퍼. 2xx가 아니면 던진다 async function api(path: string, body?: unknown) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) }) if (!res.ok) throw new Error((await res.json()).code ?? 'request_failed') return res.json() } ``` ## 4. 백엔드: 검사하고 공개로 옮기기 3단계가 부르는 엔드포인트다. 하는 일은 **읽기 → 재인코딩 → 검사 → 공개 저장 → 원본 삭제**, 이 순서다. ```ts // uploads.ts (이어서) import { GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import { RekognitionClient, DetectModerationLabelsCommand } from '@aws-sdk/client-rekognition' import sharp from 'sharp' const rekognition = new RekognitionClient({}) export const CDN = process.env.CDN_ORIGIN! // https://cdn.example.com export const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' const MIN_CONFIDENCE = 80 // 이 신뢰도 미만의 라벨은 Rekognition이 돌려주지 않는다 // 서비스 정책으로 고른다. 이름은 AWS 문서의 모더레이션 라벨 목록(L1·L2)에서 가져온다 const BLOCKED = new Set([ 'Explicit', 'Graphic Violence', 'Visually Disturbing', 'Hate Symbols' ]) const drop = (Key: string) => s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key })) export async function completeUpload(userId: string, id: string) { if (!new RegExp(`^${UUID}$`).test(id)) throw new HttpError(400, 'invalid_id') const inKey = `incoming/${userId}/${id}` // 남의 키는 만들어질 수 없다 // 1) 읽기. 정책이 크기를 막았지만 서버가 한 번 더 본다 const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: inKey })) if ((obj.ContentLength ?? 0) > MAX_BYTES) { await drop(inKey) throw new HttpError(413, 'too_large') } const raw = Buffer.from(await obj.Body!.transformToByteArray()) // 2) 재인코딩. 디코딩이 안 되면 이미지가 아니다. sharp는 출력에서 EXIF·GPS를 뺀다 let out: { data: Buffer; info: sharp.OutputInfo } try { out = await sharp(raw, { limitInputPixels: 40_000_000 }) .rotate() // EXIF 방향을 픽셀에 반영해 둔다(메타데이터는 지워지므로) .resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true }) .flatten({ background: '#ffffff' }) // 투명 PNG를 JPEG로 바꿀 때 배경 .jpeg({ quality: 85 }) .toBuffer({ resolveWithObject: true }) } catch { await drop(inKey) throw new HttpError(422, 'invalid_image') } // 3) 검사. 서빙할 바이트(재인코딩 결과)를 그대로 보낸다 let labels try { const res = await rekognition.send( new DetectModerationLabelsCommand({ Image: { Bytes: out.data }, MinConfidence: MIN_CONFIDENCE }) ) labels = res.ModerationLabels ?? [] } catch { // 검사를 못 했으면 통과시키지 않는다. incoming/은 남겨 두므로 같은 요청을 다시 부르면 된다 throw new HttpError(502, 'check_failed') } const hit = labels.find( (l) => BLOCKED.has(l.Name ?? '') || BLOCKED.has(l.ParentName ?? '') ) if (hit) { await drop(inKey) console.warn('image rejected', { userId, label: hit.Name, confidence: hit.Confidence }) throw new HttpError(422, 'image_rejected') } // 4) 공개 저장. 이 접두사는 이 함수만 쓴다 const key = `public/${userId}/${id}.jpg` await s3.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: out.data, ContentType: 'image/jpeg', CacheControl: 'public, max-age=31536000, immutable' // 키가 유일하니 영구 캐시해도 된다 }) ) await drop(inKey) return { type: 'image', url: `${CDN}/${key}`, key, mime: 'image/jpeg', w: out.info.width, h: out.info.height, size: out.info.size } } ``` ```ts app.post('/api/uploads/:id/complete', requireLogin, async (req, res) => { res.json(await completeUpload(req.user.id, req.params.id)) }) ``` - Rekognition은 **JPEG와 PNG만** 받는다. 위처럼 먼저 JPEG로 바꾸면 WebP도 받을 수 있다. - 라벨은 계층이다. 하위 라벨(L2)이 걸리면 상위(L1)도 함께 오므로, 이름은 `Name`과 `ParentName` 양쪽에서 찾는다. AWS는 L1·L2로 거르기를 권한다. `MIN_CONFIDENCE`가 낮을수록 더 많이 걸리고(오탐 증가) 높을수록 덜 걸린다 (미탐 증가) — 서비스의 사정에 맞게 정하고, 기본값은 50이다. - 거절 사유는 사용자에게 라벨 이름 대신 일반 문구로 보여 준다. ## 5. 훅: 첨부가 내 공개 파일인지 확인 엔진은 `https://`인지만 본다. 그러므로 **훅이 없으면 클라이언트가 아무 주소나 첨부로 보낼 수 있다**(검사하지 않은 외부 이미지, 추적 픽셀). 콘솔의 웹훅 화면에서 `before_publish`를 켜고, 이미 웹훅을 받는 엔드포인트가 있으면 같은 라우트에서 `event`로 가른다. ```ts import express from 'express' import { createChatServer } from '@kispi/chat/server' import { CDN, UUID } from './uploads' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, webhookSecret: process.env.CHAT_WEBHOOK_SECRET }) type BeforePublish = { op: 'create' | 'update' sender: { id: string; name: string } body: { text?: string; attachments?: { type?: string; url?: unknown }[] } } // 보낸 사람 본인의 공개 접두사 아래, 우리가 만든 파일 이름 모양이어야 한다 const fileName = new RegExp(`^${UUID}\\.jpg$`) const isMine = (url: unknown, senderId: string) => { const prefix = `${CDN}/public/${senderId}/` return typeof url === 'string' && url.startsWith(prefix) && fileName.test(url.slice(prefix.length)) } function decide({ sender, body }: BeforePublish) { for (const a of body.attachments ?? []) { if (a.type !== 'image' || !isMine(a.url, sender.id)) { return { action: 'deny', code: 'attachment_not_allowed', message: '올릴 수 없는 첨부예요' } } } return { action: 'allow' } } app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { let hook try { hook = chat.webhooks.verify(req.headers, req.body.toString('utf8')) } catch { return res.sendStatus(400) // 서명이 틀렸다 } if (hook.event === 'before_publish') return res.json(decide(hook.data as BeforePublish)) // 그 밖의 이벤트(message.created 등)는 웹훅 문서의 받는 코드대로 처리한다 res.sendStatus(200) }) ``` - 훅은 **발행과 편집** 모두에 걸린다. 편집으로 남의 주소를 끼워 넣는 길도 같은 코드로 막힌다. - 훅의 응답에 쓰는 `code`는 클라이언트의 `err.appCode`로, `message`는 에러 문구로 간다. - 화면이 **본문 텍스트 안의 이미지 주소**도 그림으로 바꿔 보여 준다면 훅이 `body.text`의 주소도 검사해야 한다. 첨부만 그리도록 만드는 편이 쉽다. - 서버 키(`sk_`)로 보내는 메시지는 훅을 거치지 않는다. 내 백엔드가 직접 올릴 때는 4단계의 결과를 그대로 쓰면 된다. ## 6. CloudFront로 서빙 (선택이지만 권장) 버킷을 공개하지 않고 CloudFront가 읽게 한다. 배포의 원본(origin)을 S3 버킷으로 하고 **원본 접근 제어(OAC)** 를 켠 뒤, 동작(behavior)의 경로 패턴을 `public/*` 하나로만 연다. `incoming/`은 CDN에서 닿지 않는다. ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "CloudFrontReadPublicOnly", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-chat-uploads/public/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXXXXXXXXXXXXX" } } } ] } ``` 응답 헤더 정책에 `X-Content-Type-Options: nosniff`를 더하고, 내 도메인(`cdn.example.com`)을 붙이면 4단계의 `CDN_ORIGIN`이 된다. 이 주소가 첨부 `url`의 앞부분이므로 훅의 `CDN`과 같은 값이어야 한다. ## 텍스트 모더레이션 글자는 같은 훅에서 거른다. 엔진은 본문을 이스케이프하지 않는다 — 금칙어는 훅의 몫이고, 화면에 그릴 때의 이스케이프는 그리는 쪽의 몫이다. ```ts const BAD = /욕설1|욕설2/g // 서비스의 금칙어 목록 // decide()에서 첨부 검사를 통과한 뒤 이어서 부른다 async function decideText(body: BeforePublish['body']) { const text = body.text ?? '' const masked = text.replace(BAD, (m) => '*'.repeat(m.length)) if (masked !== text) return { action: 'allow', body: { ...body, text: masked } } // 바꿔서 통과 return { action: 'allow' } } ``` - 돌려주는 `body`는 **본문 전체**다. 일부만 보내면 나머지가 사라진다. - 외부 모더레이션 서비스를 부른다면 **훅의 제한 시간보다 짧은 타임아웃**을 직접 건다(훅 기본 1.5초, 최대 5초). `AbortSignal.timeout(1000)` 같은 식으로 끊고, 그때 허용할지 거절할지를 코드에서 정한다. - 훅 자체가 타임아웃되거나 에러면 엔진은 `before_publish_on_error`를 따르고 기본은 **`allow`**다. 첨부 검증을 훅에 기대고 있다면 이 설정이 곧 "훅이 죽었을 때 검증도 꺼진다"는 뜻이다. `deny`로 바꾸면 안전하지만 훅이 죽는 동안 그 앱의 모든 발행이 멈춘다 — 훅은 가볍게 만들고 모니터링한다. ## 지킬 것 - **크기.** 서명 조건(`content-length-range`) + 서버의 재확인 + 픽셀 상한(`limitInputPixels`) 세 겹으로 건다. Rekognition에 보낼 수 있는 이미지 크기에도 한도가 있으니 AWS의 한도 문서(Guidelines and quotas)를 확인하고 `MAX_BYTES`를 그 안에 둔다. - **형식.** JPEG·PNG·WebP만 받는다. SVG는 스크립트를 품을 수 있어 받지 않는다. 움직이는 GIF는 첫 프레임만 검사하면 우회되므로 받지 않는 편이 안전하다. - **EXIF.** 재인코딩이 지운다. 원본을 그대로 서빙해야 한다면 EXIF의 GPS를 지우는 단계를 따로 넣는다. - **호출 제한.** 허가증 발급과 `complete`는 사용자마다 횟수를 제한한다. `complete`가 부를 때마다 Rekognition 비용이 든다. - **비용.** Rekognition은 이미지 한 장마다 과금된다. 단가는 바뀔 수 있으니 AWS의 Amazon Rekognition 가격 페이지에서 Image Moderation 항목을 보고, 예상 업로드 수를 곱해 본다. S3 저장과 CloudFront 전송도 따로 든다. - **한계.** Rekognition은 불법 촬영물(CSAM 등) 여부를 판정하지 않는다고 AWS가 밝힌다. 신고 버튼, 사람의 검토, 서비스가 지는 법적 의무(신고·삭제 절차)를 따로 갖춘다. - **삭제.** 메시지를 지워도(`message.deleted`) **엔진은 내 버킷의 파일을 지우지 않는다.** 파일까지 지우려면 `message.created` 웹훅의 `body.attachments[].key`를 메시지 `id`와 함께 내 DB에 적어 두었다가, `message.deleted` (`{id, seq}`)가 오면 그 키의 객체를 지운다. 신고로 거두는 경우는 `chat.messages.delete`와 객체 삭제, 필요하면 밴을 한 흐름으로 묶는다. 탈퇴·개인정보 삭제 요청에는 `public/{userId}/` 아래를 목록 조회해 모두 지운다. - **내보내기.** 앱 전체 내보내기(`GET /v1/export`)에는 첨부 파일이 들어가지 않는다. 파일의 백업과 이전은 버킷 쪽(복제·`aws s3 sync`)에서 한다. - **키 관리.** `sk_`·웹훅 시크릿·AWS 자격 증명은 브라우저와 저장소에 두지 않는다. AWS는 실행 환경의 IAM 역할로 받는다. 원문: https://chat.gravex.app/docs/uploads --- # REST API 레퍼런스 엔진의 REST 라우트 전부다. 어떤 언어로든 HTTP와 JSON만 되면 부를 수 있다. 실시간 이벤트는 REST가 아니라 [실시간 프로토콜](https://chat.gravex.app/docs/realtime)(WebSocket)이 담당하고, 이 문서는 **요청해서 받아 오는 쪽**이다. - 기준 주소: `https://api.chat.gravex.app` (모든 경로가 `/v1`로 시작한다) - 본문은 JSON(UTF-8)이다. 본문이 있는 요청에는 `Content-Type: application/json`을 보낸다. - 시각은 모두 **UTC 에포크 밀리초 정수**다(`createdAt: 1792861844890`). - 방·메시지의 id는 서버가 만드는 **13자 문자열**(Crockford Base32, 예 `0AAS4P1MM0000`)이다. 속뜻을 읽지 말고 문자열로만 다룬다. - 유저 id는 서비스가 정한 문자열(토큰의 `sub`)이다. 경로에 넣을 때는 URL 인코딩한다. - 모르는 응답 필드는 무시한다. 필드는 더해지기만 하고 이름이 바뀌거나 없어지지 않는다. ## 인증 호출자는 둘이다. 라우트마다 어느 쪽이 되는지는 아래 표의 **권한** 칸에 있다. | 호출자 | 헤더 | 용도 | |---|---|---| | **서버 키** (`sk`) | `Authorization: Bearer sk_…` | 백엔드. 서비스의 대리인이라 방 멤버 검사를 건너뛴다 | | **유저 토큰** (`유저`) | `Authorization: Bearer ` 와 `X-Chat-Key: pk_…` | 앱·브라우저. 한 유저로서 동작하고 WebSocket과 같은 접근 규칙을 받는다 | - 유저 토큰은 백엔드가 `sk_`로 서명한 JWT다. 만드는 법은 [서버 연동](https://chat.gravex.app/docs/server)의 「유저 토큰 규격」에 있다. - 두 헤더가 함께 오면 `sk_`가 이긴다. `Bearer`의 대소문자는 가리지 않는다. - `sk_`는 서버에만 둔다. 앱이나 브라우저에 넣지 않는다. - 브라우저 요청은 `Origin`이 `pk_`의 **허용 origin**에 있어야 한다(스킴·호스트·포트가 정확히 같아야 한다). 목록에 없으면 401이고 메시지에 이유가 적힌다. **`Origin` 헤더가 없는 요청(iOS·Android·Flutter·서버)은 이 검사를 받지 않는다.** - 인증 실패는 401 `unauthorized`, 인증은 됐지만 그 일을 못 하는 호출자는 403 `forbidden`이다. - 다른 앱의 방 id는 403이 아니라 **404**다. - 토큰 서명이 틀렸든, 만료됐든, 무효화됐든 이유를 가리지 않고 401 하나다. 원인은 응답이 아니라 토큰 규격과 대조해서 찾는다. - 유저 토큰이 만료되면 401이 온다. 백엔드에서 새 토큰을 받아 **한 번만** 다시 시도한다. 두 번째 401은 만료가 아니다. ## 공통 규칙 ### 에러 형식 실패는 HTTP 상태 코드와 함께 이 모양의 JSON이다. ```json { "error": { "code": "rate_limited", "message": "rate limit exceeded", "retryAfterMs": 800 } } ``` - 분기는 `code`로 한다. `message`는 사람이 읽는 영어 문장이고 바뀔 수 있다. - `retryAfterMs`는 `rate_limited`에만 있다. `appCode`는 `moderation_denied`에만 있다. - 본문이 없는 성공은 204다. | `code` | HTTP | 뜻 | 재시도 | |---|---|---|---| | `unauthorized` | 401 | 키·토큰이 없거나 틀렸다 | 토큰이면 새로 받아 한 번 | | `forbidden` | 403 | 이 호출자는 이 일을 못 한다 | 아니오 | | `banned` | 403 | 밴된 유저의 발행·리액션 | 밴이 풀린 뒤 | | `moderation_denied` | 403 | 서비스의 `before_publish` 훅이 거절했다. `message`는 서비스가 정한 문구이고 `appCode`는 서비스가 정한 기계 판독 코드다 | 아니오 | | `quota_exceeded` | 403 | 앱의 저장 쿼터가 찼다 | 아니오 | | `not_found` | 404 | 없는 자원이거나 지워졌거나 다른 앱의 것이다 | 아니오 | | `invalid` | 400 | 요청이 틀렸다(형식, 필수 필드, 허용되지 않는 값). 본문이 1 MB를 넘거나 JSON이 아닐 때도 400이다 | 고친 뒤 | | `invalid` | 409 | 방 `key`가 이미 쓰이고 있다 | 다른 `key`로 | | `too_large` | 413 | 메시지 본문이 `bodyTextMaxLen`을 넘는다 | 줄인 뒤 | | `cursor_too_old` | 410 | 요청한 `after`가 보존 범위보다 오래됐다 | 아래 「히스토리」 | | `rate_limited` | 429 | 한도 초과. `Retry-After`(초)와 `retryAfterMs`가 온다 | `retryAfterMs` 뒤 | | `conflict` | 409 | 지금 상태와 부딪힌다(이미 도는 내보내기, 탈퇴하지 않은 유저의 영구 삭제, 이미 도는 영구 삭제) | 상태를 바꾼 뒤 | | `internal` | 500 | 서버 오류 | 백오프로 | **HTTP 409는 `code`가 무엇이든 "지금 상태와 부딪힘"으로 다룬다.** 5xx와 네트워크 오류는 지수 백오프로 다시 시도한다. ### 한도 - 메시지 발행(`POST …/messages`)은 유저마다 초당 5건, 순간 10건이다. 편집(`PATCH …/messages/…`)은 초당 2건이다. 유저 토큰으로 방을 만드는 일은 하루 50개다. - **서버 키(`sk_`) 호출은 이 한도를 세지 않는다.** - 걸리면 429 `rate_limited`다. 응답 헤더 `Retry-After`는 초 단위, 본문의 `retryAfterMs`는 밀리초이고 같은 시간이다. 그만큼 기다렸다가 같은 요청을 다시 보낸다. - 한도 값은 앱마다 다를 수 있다. 실효값은 WebSocket `hello`의 `limits`에 있다([실시간 프로토콜](https://chat.gravex.app/docs/realtime#한도)). - 크기 한도: 메시지 본문 4000자, 첨부 10개, `entities` 100개, `appMeta` 4 KB, 요청 본문 1 MB. ### 페이지네이션 | 방식 | 쓰는 곳 | 규칙 | |---|---|---| | **cursor** | 방 목록, 멤버, 내 방, 유저, 읽음, 리액션, 이벤트 | `?cursor=&limit=`. `limit` 기본 50, 최대 100. 응답의 `cursor`를 **그대로** 다음 요청에 넣는다. 빈 문자열(`""`)이 마지막 페이지다. 커서는 불투명 문자열이니 해석하지 않는다. 해석되지 않는 커서는 400이다 | | **seq 창** | 메시지 히스토리 | `?before=&after=&limit=`. 아래 「히스토리」 | | 없음 | `GET /v1/me/unread`, `GET …/presence` | 상한에서 자르고 `roomsCapped`·`capped`로 알린다 | cursor 방식에서 `cursor`가 있으면 **다음 페이지가 반드시 있다.** 페이지 사이에 새 항목이 생겨도 이미 지나간 페이지는 밀리지 않는다. ### 브라우저에서 부를 때 (CORS) - 모든 `/v1` 응답에 CORS 헤더가 붙는다(에러 응답에도). `Access-Control-Allow-Origin`은 요청의 `Origin`을 그대로 돌려준다. - 프리플라이트(`OPTIONS`)는 204다. 허용 메서드는 `GET, POST, PUT, PATCH, DELETE, OPTIONS`, 허용 헤더는 `Authorization, Content-Type, X-Chat-Key`다. - `Retry-After`는 브라우저에서 읽을 수 있게 노출된다. - WebSocket은 CORS 대상이 아니다. 네이티브 앱과 서버는 CORS와 무관하다. ## 라우트 한눈에 권한 칸의 `sk`는 서버 키, `유저`는 유저 토큰이다. 괄호는 추가 조건이다. | # | 메서드·경로 | 권한 | 하는 일 | |---|---|---|---| | 1 | `PUT /v1/rooms` | sk, 유저(앱 설정) | 방 get-or-create | | 2 | `PUT /v1/rooms/{key}` | sk, 유저(앱 설정) | 1번의 별칭(경로에 key) | | 3 | `GET /v1/rooms` | sk, 유저 | 방 목록 | | 4 | `GET /v1/rooms/by-key/{key}` | sk, 유저 | 별칭으로 방 조회 | | 5 | `GET /v1/rooms/{roomId}` | sk, 유저 | 방 조회 | | 6 | `PATCH /v1/rooms/{roomId}` | sk, 유저(owner·admin) | 방 수정 | | 7 | `DELETE /v1/rooms/{roomId}` | sk, 유저(owner) | 방 삭제, `?purge=true`는 영구 삭제 | | 8 | `POST /v1/rooms/{roomId}/join` | 유저 | 공개 방 참여 | | 9 | `GET /v1/rooms/{roomId}/members` | sk, 유저 | 멤버 목록 | | 10 | `PUT /v1/rooms/{roomId}/members/{userId}` | sk, 유저(owner·admin) | 멤버 추가·역할 변경 | | 11 | `DELETE /v1/rooms/{roomId}/members/me` | 유저 | 스스로 나가기 | | 12 | `DELETE /v1/rooms/{roomId}/members/{userId}` | sk, 유저(owner·admin) | 멤버 내보내기 | | 13 | `GET /v1/rooms/{roomId}/messages` | sk, 유저(`q`는 sk만) | 히스토리·검색 | | 14 | `POST /v1/rooms/{roomId}/messages` | sk, 유저 | 메시지 발행 | | 15 | `PATCH /v1/rooms/{roomId}/messages/{messageId}` | sk, 유저(본인) | 메시지 수정 | | 16 | `DELETE /v1/rooms/{roomId}/messages/{messageId}` | sk, 유저(본인·owner·admin) | 메시지 삭제 | | 17 | `GET /v1/rooms/{roomId}/messages/{messageId}/reactions` | sk, 유저 | 리액션 누른 사람 | | 18 | `GET /v1/rooms/{roomId}/reads` | sk, 유저 | 읽음 위치 목록 | | 19 | `GET /v1/rooms/{roomId}/presence` | sk, 유저 | 지금 방에 있는 사람 | | 20 | `POST /v1/rooms/{roomId}/custom` | sk | 저장되지 않는 제어 신호 | | 21 | `PATCH /v1/me` | 유저 | 내 `meta` 수정 | | 22 | `GET /v1/me/rooms` | 유저 | 내 방 목록(안 읽은 수 포함) | | 23 | `GET /v1/me/unread` | 유저 | 안 읽은 수 총계 | | 24 | `GET /v1/users` | sk | 유저 목록 | | 25 | `PUT /v1/users/{userId}` | sk | 유저 프로필 수정 | | 26 | `DELETE /v1/users/{userId}` | sk | 탈퇴(익명화), `?purge=true`는 영구 삭제 | | 27 | `DELETE /v1/users/{userId}/messages` | sk | 그 유저의 메시지 전부 거두기 | | 28 | `PUT /v1/users/{userId}/ban` | sk | 밴 | | 29 | `DELETE /v1/users/{userId}/ban` | sk | 밴 해제 | | 30 | `POST /v1/users/{userId}/disconnect` | sk | 연결 강제 종료 | | 31 | `POST /v1/users/{userId}/revoke-tokens` | sk | 발급된 토큰 전부 무효화 | | 32 | `GET /v1/events` | sk | 놓친 이벤트 따라잡기 | | 33 | `GET /v1/export` | sk | 앱 전체 데이터 내려받기 | (9·13·18·19번은 `GET /v1/rooms/{roomId}/{members|messages|reads|presence}` 네 경로다. 이 넷 말고 다른 이름은 404다.) **리액션 누르기, 읽음 위치 쓰기, 입력 중 알림은 REST 라우트가 없다.** 셋 다 WebSocket 프레임(`react`·`read`·`typing`)으로만 한다. 읽는 쪽(17·18번, 안 읽은 수)만 REST다. ## 객체 ### 방 ```json { "id": "0AAS4P1MM0000", "appId": "0AAS4P1HW0000", "key": "general", "type": "public", "name": "일반", "meta": {}, "lastSeq": 12, "minSeq": 1, "createdAt": 1792861844890, "updatedAt": 1792861844949 } ``` - `type`은 `public`, `private`, `channel`, `dm`이다. `key`는 `dm`에 없다. `name`은 설정했을 때만 온다. - `lastSeq`는 방에 마지막으로 발행된 메시지의 seq, `minSeq`는 히스토리로 읽을 수 있는 가장 오래된 seq다(그보다 한참 오래된 `after`는 410이다). - 삭제된 방은 `deletedAt`이 찍히지만 조회 라우트는 지워진 방을 404로 돌려준다. ### 메시지 ```json { "id": "0AAS4P1P40800", "seq": 12, "kind": "text", "sender": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" }, "body": { "text": "안녕 @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] }, "replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "mentions": ["u-2"], "meta": {}, "appMeta": {}, "reactions": [{ "emoji": "👍", "count": 2 }], "thread": { "count": 3, "lastSeq": 20, "lastAt": 1792861900000 }, "myReactions": ["👍"], "createdAt": 1792861844902, "editedAt": 1792861850000 } ``` | 필드 | 설명 | |---|---| | `id`, `seq` | `seq`는 **방 안에서 1씩 늘고 구멍이 없는** 번호다. 스레드 답글도 seq를 쓴다 | | `kind` | `text` 또는 `system`(서버 키로만 보낼 수 있다) | | `sender` | `{id, name, avatar?}`. `id`는 서비스의 유저 id다 | | `body` | `{text?, attachments?, entities?}`. 첨부는 `{type, url, key?, mime?, w?, h?, name?, size?}`이고 `type`은 `image`·`video`·`audio`·`file`, `url`은 `https`만 된다. 엔진은 파일을 저장하지 않는다 | | `replyTo` | 인용한 메시지 id | | `threadId` | 스레드 답글이면 **루트** 메시지 id. 본 타임라인 메시지에는 없다 | | `mentions` | 멘션된 유저 id(서비스의 id) 배열 | | `meta` | 보낸 사람이 붙인 JSON 객체 | | `appMeta` | 서버 키로만 붙일 수 있는 JSON 객체(4 KB 이하) | | `reactions` | `[{emoji, count}]` 집계 | | `thread` | 답글이 있는 루트에만 `{count, lastSeq, lastAt}` | | `myReactions` | **유저 토큰으로 읽을 때만** 온다. 내가 누른 이모지. 빈 배열은 "아무것도 안 눌렀다", 필드가 없으면 "보는 유저가 없다"(서버 키) | | `editedAt`, `deletedAt` | 있으면 수정됨/삭제됨 | - **삭제된 메시지(tombstone)는 행이 남는다.** `deletedAt`이 있고 `body`는 비고 `reactions`·`myReactions`는 빈 배열이다. `seq`는 그대로다. - 이 객체는 WebSocket의 `message.created`·`message.updated`와 **같은 모양**이다. 차이는 채워지는 필드뿐이다. - 메시지 객체에 `roomId`는 없다. 어느 방인지는 요청 경로로 안다. ### 유저 (내 정보) `PATCH /v1/me`와 `PUT /v1/users/{userId}`의 응답이다. ```json { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png", "meta": { "plan": "pro" } } ``` `GET /v1/users`의 항목은 더 많다: `externalId`, `name`, `avatarUrl?`, `meta`, `createdAt`, `updatedAt`, `lastSeenAt?`, `deletedAt?`(탈퇴한 유저), `bannedUntil?`(살아 있는 밴만), `isBot`, `lastUserAgent`. ## 방 ### 1 · 2. `PUT /v1/rooms`, `PUT /v1/rooms/{key}` — 방 get-or-create 요청 본문: `{key?, type?, name?, meta?, members?}` | 필드 | 설명 | |---|---| | `type` | `public`(기본), `private`, `channel`, `dm` | | `key` | `public`·`private`·`channel`은 **필수**. `^[A-Za-z0-9_.:-]{1,128}$`. `dm`에 주면 400 | | `members` | `dm`에서 **필수**. 서비스의 유저 id **정확히 둘**. 둘 다 한 번은 접속한 유저여야 한다(없으면 404) | | `name`, `meta` | 새로 만들 때만 쓰인다 | - 새로 만들면 **201**, 이미 있으면 **200**이고 둘 다 방 객체다. 이미 있는 방의 `name`·`meta`는 바뀌지 않는다(바꾸려면 6번). - `public`·`private`·`channel`은 `key`로, `dm`은 두 사람의 조합으로 같은 방을 찾는다. - `PUT /v1/rooms/{key}`는 `key`를 경로에 넣는 같은 호출이다. 본문의 `key`와 경로의 `key`가 다르면 400이다. 새 코드는 `PUT /v1/rooms`를 쓴다. - **유저 토큰**은 앱이 클라이언트의 방 생성을 켜 둔 경우에만 만들 수 있다(꺼져 있으면 403). `dm`에는 호출자가 들어 있어야 한다. 유저 토큰으로 만든 방은 만든 유저가 owner가 된다. - **서버 키로 만든 `channel`에는 owner가 없다.** 글을 쓸 사람을 10번으로 owner나 admin으로 지정한다. ```bash curl -X PUT https://api.chat.gravex.app/v1/rooms \ -H "Authorization: Bearer $CHAT_SECRET_KEY" -H "Content-Type: application/json" \ -d '{"key":"lobby","type":"public","name":"로비"}' ``` ### 3. `GET /v1/rooms` — 방 목록 쿼리: `type`(쉼표로 여럿), `cursor`, `limit`. - `type`을 생략하면 `public`·`channel`이다. **유저 토큰은 `public`·`channel`만** 받는다(공개 방 탐색). **서버 키는 `private`·`dm`도** 받는다. 그 밖의 값은 400이다. - 새 방부터 준다. 응답: `{"rooms": [방…], "cursor": ""}` ### 4 · 5. `GET /v1/rooms/by-key/{key}`, `GET /v1/rooms/{roomId}` — 방 조회 방 객체를 돌려준다. 방을 가리키는 정본은 `id`다. `key`는 바꿀 수 있는 별칭이라 `by-key`만 별칭으로 찾는다. 유저 토큰은 그 방을 읽을 수 있어야 한다(아래 「접근 규칙」). ### 6. `PATCH /v1/rooms/{roomId}` — 방 수정 요청 본문: `{key?, name?, meta?}`. 주지 않은 필드는 건드리지 않는다. 응답은 방 객체이고 방에는 `room.updated`가 나간다. - 유저 토큰은 방의 **owner 또는 admin**이어야 한다. - 이미 쓰이는 `key`는 409다. `dm`에 `key`를 주면 400이다. ### 7. `DELETE /v1/rooms/{roomId}` — 방 삭제 - 기본은 소프트 삭제다. 204를 돌려주고 방에는 `room.deleted`가 나간다. 메시지는 남고, 방은 조회·참여·발행 모두 404가 되며 `key`가 비워져 같은 `key`로 다시 만들 수 있다. 이미 지워진 방은 404다. 유저 토큰은 **owner**만 된다. - **`?purge=true`는 영구 삭제**를 접수한다. **서버 키만** 되고 유저 토큰은 403이다(방 주인도 안 된다). 살아 있는 방이면 먼저 소프트 삭제한다. 응답은 **202** `{"job": {…}}`이고, 같은 방에 진행 중인 작업이 있으면 같은 작업으로 202다. 되돌릴 수 없다. - `purge`가 `true`·`false`가 아니면 400이다. ## 멤버 방 종류별로 읽고 쓰는 규칙은 아래 「접근 규칙」이다. 멤버십은 "내 방 목록에 들어가고 알림을 받는다"는 뜻이다. ### 8. `POST /v1/rooms/{roomId}/join` — 공개 방 참여 유저 토큰 전용이다. `public`·`channel` 방의 멤버가 된다. 이미 멤버여도 200이고 `{"roomId": "…", "role": "member"}`를 돌려준다. `private`·`dm`은 403이다. 새로 들어갔을 때만 방에 `member.joined`가 나간다. ### 9. `GET /v1/rooms/{roomId}/members` — 멤버 목록 쿼리: `cursor`, `limit`. 응답: ```json { "members": [{ "userId": "u-1", "name": "alice", "role": "owner", "joinedAt": 1792861844892 }], "cursor": "" } ``` 탈퇴한 유저의 행은 `{"role": "member", "joinedAt": …, "withdrawn": true}`로 남는다(`userId` 없음, `name`은 익명 라벨). ### 10. `PUT /v1/rooms/{roomId}/members/{userId}` — 멤버 추가·역할 변경 요청 본문: `{role?}` — `owner`, `admin`, `member`(기본). 없으면 넣고 있으면 역할을 바꾼다. 204. 그 유저는 한 번은 접속한 적이 있어야 한다(없으면 404). - 유저 토큰은 owner 또는 admin이어야 한다. **owner의 역할은 owner만 바꾼다.** 방의 마지막 owner는 강등할 수 없다(400). - 실제로 바뀌었을 때만 `member.joined`가 나간다. ### 11 · 12. `DELETE /v1/rooms/{roomId}/members/me`, `…/members/{userId}` — 나가기·내보내기 둘 다 204다. 실제로 지워졌을 때만 `member.left`가 나간다. - `members/me`는 **유저 토큰 전용**이고 호출자 자신이 나간다. 서버 키로는 403이다. - `members/{userId}`는 서버 키 또는 owner·admin이다. - **서비스의 유저 id를 `me`로 만들지 않는다.** `members/me`가 항상 이긴다. ## 메시지 ### 13. `GET /v1/rooms/{roomId}/messages` — 히스토리·검색 쿼리: `before`, `after`, `limit`(기본·최대 100), `view`, `q`. 응답: `{"messages": [메시지…]}` — **항상 seq 오름차순**이다. | 호출 | 결과 | |---|---| | 아무것도 없이 | 가장 최근 `limit`개 | | `before=N` | seq가 N보다 작은 것 중 가장 최근 `limit`개(위로 스크롤) | | `after=N` | seq가 N보다 큰 것 중 **가장 오래된** `limit`개(구멍 메우기) | | `after=N&before=M` | N과 M 사이 | - `view`: 생략하면 스레드 답글까지 방 전체(`all`), `main`은 본 타임라인만, `thread:<루트 메시지 id>`는 그 스레드만이다. 그 밖의 값은 400이다. **`main`·`thread:`는 보여 주기용이다. 동기화 위치로 쓰지 않는다.** - `q`(서버 키 전용)는 `body.text` 부분 일치 검색이다. 대소문자를 가리지 않고 삭제된 메시지는 걸리지 않는다. 유저 토큰이 `q`를 주면 403, 공백뿐이면 400이다. **검색 결과의 `seq`는 이어지지 않는다**(걸리지 않은 메시지가 빠진 것이다). - `after`가 방의 보존 하한보다 오래됐으면 **410 `cursor_too_old`**다. 이어 붙일 수 없으니 최근 히스토리부터 다시 시작한다. - 한 페이지의 필드(`sender` 이름, `reactions`, `thread`, `myReactions`)는 서버가 한꺼번에 채워서 준다. ### 14. `POST /v1/rooms/{roomId}/messages` — 발행 요청 본문: | 필드 | 필수 | 설명 | |---|---|---| | `clientMessageId` | **예** | 중복 방지 키. 없으면 400. 아래 설명 | | `body` | 예 | `{text?, attachments?, entities?}`. `text` 4000자, 첨부 10개, `entities` 100개 이하 | | `kind` | | `text`(기본). `system`은 서버 키만 | | `meta` | | JSON 객체 | | `replyTo`, `threadId` | | 같은 방의 메시지 id. 아래 설명 | | `sender` | 서버 키는 **예** | `{id, name}`. 서버 키에는 돌려줄 유저가 없어 `sender.id`가 없거나 비면 400. 유저 토큰이 주면 403 | | `appMeta` | | 서버 키만(4 KB 이하 JSON 객체). 유저 토큰이 주면 403 | 응답은 **201** `{"messageId": "…", "seq": 13, "createdAt": 1792861844902}`다. - **`clientMessageId`가 멱등키다.** 같은 보낸 사람이 같은 값으로 다시 보내면(24시간 안) 새 메시지를 만들지 않고 처음 저장된 메시지의 `messageId`·`seq`를 돌려준다. 응답을 못 받았으면 **같은 값으로 다시 보낸다.** 새 값을 쓰면 메시지가 둘이 된다. UUIDv4를 권장한다. - `threadId`는 스레드 답글이라는 뜻이다. 답글의 id를 주면 그 **루트**로 바꿔 저장한다. `replyTo`는 인용이고 스레드를 열지 않는다. 인용 대상이 스레드 안에 있으면 그 스레드의 답글이 된다. - `entities`의 `{type:"mention", userId, offset, length}`는 `userId`가 실존할 때만 멘션으로 센다. 모르는 id는 거절이 아니라 제외다. - 앱에 `before_publish` 훅이 있으면 유저 토큰의 발행은 훅을 거치고 403 `moderation_denied`가 올 수 있다. 서버 키는 거치지 않는다. 훅이 본문을 바꾸면 **바뀐 본문이 저장된다.** - 밴된 유저는 403 `banned`, 저장 쿼터가 차면 403 `quota_exceeded`다. ```bash curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \ -H "Authorization: Bearer $USER_TOKEN" -H "X-Chat-Key: $VITE_CHAT_PK" \ -H "Content-Type: application/json" \ -d '{"clientMessageId":"cm-6f1c","body":{"text":"안녕"}}' ``` ### 15. `PATCH /v1/rooms/{roomId}/messages/{messageId}` — 수정 요청 본문: `{body}` — 필수이고 **본문 전체를 통째로 바꾼다.** 없으면 400이다. 응답은 200과 메시지 객체이고 방에는 `message.updated`가 나간다. `editedAt`이 찍히고 `seq`는 움직이지 않는다. - 유저 토큰은 **본인 메시지만** 고친다. 방 owner·admin도 남의 메시지는 못 고친다(지우기만 한다). 앱의 수정 가능 시간(`editWindowMs`, 기본은 제한 없음) 안에서만 된다. - 서버 키는 누구 메시지든 고친다. - 이미 삭제된 메시지는 404다. 유저당 초당 2건 한도가 있다(서버 키는 제외). ### 16. `DELETE /v1/rooms/{roomId}/messages/{messageId}` — 삭제 204다. 행을 지우지 않고 본문을 비우는 tombstone이다(`seq` 그대로). 방에는 `message.deleted`가 나간다. 두 번 지워도 204다. 유저 토큰은 본인 메시지와, owner·admin이 남의 메시지를 지우는 경우만 된다. 다른 방의 메시지 id는 404다. ### 17. `GET /v1/rooms/{roomId}/messages/{messageId}/reactions` — 리액션 누른 사람 쿼리: `emoji`(그 이모지만), `cursor`, `limit`. 응답: `{"reactions": [{"userId": "u-1", "emoji": "👍"}], "cursor": ""}`. 한 행은 (유저, 이모지) 쌍이라 한 사람이 여러 줄일 수 있다. 탈퇴한 유저는 `userId` 없이 `withdrawn: true`로 남는다. 삭제된 메시지는 404가 아니라 빈 페이지다. ### 18. `GET /v1/rooms/{roomId}/reads` — 읽음 위치 쿼리: `userId`(그 유저의 행만), `cursor`, `limit`. 응답: `{"reads": [{"userId": "u-1", "seq": 12, "threadId": "…"}], "cursor": ""}`. 한 행은 (유저, 범위) 쌍이다. 본 타임라인은 `threadId`가 없고 스레드마다 한 줄이 따로 있다. 탈퇴한 유저는 `userId` 없이 `withdrawn: true`다. 없는 유저의 `userId`는 404가 아니라 빈 페이지다. **내 읽음 위치를 되찾으려면** `userId`에 내 id를 넣는다. ### 19. `GET /v1/rooms/{roomId}/presence` — 지금 방에 있는 사람 쿼리: `full=true`. 기본은 `{"count": 3}`이고 `full=true`면 `{"count": 3, "users": [{"id": "u-1", "name": "alice", "avatar": "…"}]}`다. `users`는 1000명에서 잘리고 잘렸으면 `capped: true`다. 빈 방은 404가 아니라 `{"count": 0}`이다. ### 20. `POST /v1/rooms/{roomId}/custom` — 저장되지 않는 제어 신호 서버 키 전용이다(유저 토큰은 403). 요청 본문: `{"payload": {…}}` — JSON **객체**이고 16 KiB 이하다(아니면 400). 방을 보고 있는 연결에 `custom` 이벤트로 `payload`를 그대로 뿌리고 204를 돌려준다. - 저장되지 않는다. 웹훅·`GET /v1/events`로도 나가지 않고, 놓친 연결에 다시 보내지도 않는다. 204는 "뿌렸다"이지 "받았다"가 아니다. 저장해야 하는 신호는 `kind:"system"` 메시지로 보낸다. ## 내 정보 (유저 토큰 전용) 서버 키로 부르면 403이다. ### 21. `PATCH /v1/me` — 내 `meta` 수정 요청 본문: `{meta}`. `meta`는 **최상위 키만 얕게 병합**하고, 값이 `null`인 키는 지운다. 중첩 객체는 합쳐지지 않고 통째로 바뀐다. 응답은 유저 객체(`{id, name, avatar, meta}`)다. - `name`·`avatar`는 400이다. 이름과 아바타는 백엔드가 서명하는 토큰에서 오거나 25번으로 바꾼다. - 최상위 `meta.provider`는 백엔드가 보증하는 칸이라 400이다. - 병합한 **뒤의** `meta`가 4 KB 또는 최상위 키 64개를 넘으면 400이고 아무것도 쓰이지 않는다. ### 22. `GET /v1/me/rooms` — 내 방 목록 쿼리: `cursor`, `limit`. **마지막 활동 순**이다(메시지가 없는 방은 맨 아래). 응답: ```json { "rooms": [{ "room": { "id": "0AAS4P1MM0000", "type": "public", "key": "general", "lastSeq": 12, "minSeq": 1, "meta": {}, "createdAt": 1, "updatedAt": 2 }, "lastSeq": 12, "readSeq": 10, "unread": 2, "unreadMentions": 1, "lastMessage": { "id": "0AAS4P1P40800", "seq": 12, "sender": { "id": "u-1", "name": "alice" }, "kind": "text", "body": { "text": "안녕" }, "createdAt": 1792861844902 } }], "cursor": "" } ``` - `unread`는 `lastSeq - readSeq`가 아니라 **서버가 센 값**이다(본 타임라인 메시지만, 내 메시지·삭제된 메시지 제외). 1000에서 자르고 잘렸으면 `unreadCapped: true`다. - `readSeq`는 본 타임라인의 읽음 위치다. `lastMessage`는 메시지가 있을 때만 온다. ### 23. `GET /v1/me/unread` — 안 읽은 수 총계 응답: `{"rooms": [{"roomId": "…", "unread": 2, "unreadMentions": 1}], "unread": 5, "unreadMentions": 1, "roomsCapped": false}`. 내가 멤버인 방별 숫자와 그 합이다. 방이 500개를 넘으면 500개까지만 세고 `roomsCapped: true`다(총계는 일부의 합이다). WebSocket `hello.unread`와 같은 계산이다. ## 유저 관리 (서버 키 전용) 유저는 **한 번이라도 접속한 뒤에** 생긴다. 접속한 적 없는 유저 id는 404다. 유저 토큰으로 부르면 모두 403이다. ### 24. `GET /v1/users` — 유저 목록 쿼리: `cursor`, `limit`과 선택 필터. **유저 id 역순**(새로 생긴 유저부터)이고 탈퇴한 유저도 나온다. 해석되지 않는 값은 400이다. 필터를 바꾸면 `cursor`를 버리고 처음부터 받는다. | 파라미터 | 값 | |---|---| | `q` | `externalId` 접두어 또는 `name` 부분 일치. 대소문자 무시 | | `status` | `live`, `withdrawn`, `all`(기본) | | `last_seen_after`, `last_seen_before` | RFC3339. 마지막 접속 시각 범위(`after` 포함, `before` 제외) | | `created_after`, `created_before` | RFC3339. 가입 시각 범위 | | `bots` | `include`(기본), `exclude`, `only` | 응답: `{"users": [유저…], "cursor": ""}` ### 25. `PUT /v1/users/{userId}` — 프로필 수정 요청 본문: `{name?, avatar?, meta?}`. 응답은 유저 객체다. - `name`은 256바이트, `avatar`는 2048바이트까지다(넘으면 400이고 아무것도 쓰이지 않는다). `meta`는 21번과 같은 **얕은 병합**이고 상한도 같다. - 탈퇴한 유저는 404다. 접속 중이면 그 연결의 이름이 바로 바뀐다. - 다음에 접속할 때 토큰의 `name`·`avatar`가 이 값을 다시 덮는다. ### 26. `DELETE /v1/users/{userId}` — 탈퇴·영구 삭제 | 쿼리 | 결과 | |---|---| | 없음 | **탈퇴 = 익명화.** 이름은 "탈퇴한 사용자", 아바타·`meta`는 비워진다. 유저 행과 id는 남고 메시지도 그대로다. 그 유저의 연결을 끊는다 | | `purge_messages=true` | 탈퇴에 더해 그 유저의 메시지를 모두 tombstone으로 만들고 메시지에 찍힌 이름도 지운다 | | `purge=true` | 탈퇴한 유저의 행을 **영구 삭제**하는 작업을 접수한다(되돌릴 수 없다) | - 탈퇴와 `purge_messages`의 응답은 **200** `{"userId": "…", "anonymized": true, "purgedMessages": 40, "scrubbedSenders": 2, "purgeCapped": false}`다. `anonymized`는 "이번 호출이 익명화했는가"다. - **`purgeCapped: true`면 한 번에 다 못 지운 것이다.** `false`가 될 때까지 같은 호출을 되풀이한다. - 이미 탈퇴한 유저는 404다. 다만 `purge_messages=true`는 이미 탈퇴한 유저에게도 받는다(메시지 거두기를 이어 한다). - `purge=true`는 **202** `{"job": {…}}`이다. 탈퇴하지 않은 유저는 **409**다. 같은 유저에 진행 중인 작업이 있으면 같은 작업으로 202다. 그 뒤에는 그 유저 id를 다시 쓸 수 있게 되지만, 옛 메시지의 `sender.id`가 같은 문자열을 들고 있으므로 새 사람에게 옛 id를 재사용하지 않는다. - 두 쿼리의 값이 `true`·`false`가 아니면 400이다. ### 27. `DELETE /v1/users/{userId}/messages` — 메시지 거두기 그 유저가 보낸 살아 있는 메시지를 모두 tombstone으로 바꾼다. **탈퇴시키지 않는다.** 응답은 **200** `{"userId": "…", "purgedMessages": 40, "purgeCapped": false}`다. `purgeCapped: true`면 다시 부른다(이미 지운 것은 건너뛰어 멱등이고, 끝나면 `purgedMessages: 0`이다). 메시지마다 `message.deleted`가 나간다. 보낸 사람 이름은 남는다. 새 메시지가 계속 올라오지 않게 **밴을 먼저** 한다. 없는 유저와 탈퇴한 유저는 404다. ### 28 · 29. `PUT`·`DELETE /v1/users/{userId}/ban` — 밴·해제 - 밴 요청 본문: `{until, reason?}`. `until`은 **필수**(UTC 에포크 밀리초)이고 없으면 400이다. 무기한은 먼 미래 시각을 준다. 204. - 밴된 유저의 발행·리액션은 `banned`로 거절된다. 읽기는 막지 않는다. - 해제는 204다. 밴이 없어도 204다. ### 30. `POST /v1/users/{userId}/disconnect` — 연결 강제 종료 요청 본문(선택): `{connectionId?, reason?}`. `connectionId`가 있으면 그 연결 하나만, 없으면 그 유저의 연결 전부를 끊는다(`connectionId`는 `hello`에서 받는다. 형식이 틀리면 400). `reason`은 WebSocket close 사유로 나간다(기본 `disconnected`). **204는 "지시했다"이지 "몇 개 끊었다"가 아니다.** 놓친 연결이 있으면 다시 부른다(멱등). 없는 유저는 404다. 탈퇴한 유저에게도 동작한다. ### 31. `POST /v1/users/{userId}/revoke-tokens` — 토큰 무효화 그 유저의 **지금까지 발급된 토큰을 전부** 무효화하고 연결도 끊는다(close 사유 `tokens_revoked`). 204다. 무효화 시각은 초 단위로 내림하므로 **1초 뒤에 발급한 토큰**부터 확실히 통한다. 탈퇴한 유저는 404다. ## 이벤트와 내보내기 (서버 키 전용) ### 32. `GET /v1/events` — 놓친 이벤트 따라잡기 쿼리: `after`(이벤트 id), `limit`(기본 50, 최대 100). 응답: ```json { "events": [{ "delivery": "0AAS4P1QQ0000", "event": "message.created", "createdAt": 1792861844902, "data": { } }], "cursor": "" } ``` - `delivery`·`event`·`data`는 웹훅이 보내는 헤더·본문과 같다. 웹훅 수신과 서명은 [웹훅](https://chat.gravex.app/docs/webhooks)에 있다. - `after`를 해석할 수 없으면 400이다. `cursor`는 페이지가 꽉 찼을 때만 온다. - 따라잡기가 끝났다고 판단하기 전에 **마지막 페이지를 한 번 더 읽거나** 조금 앞선 `after`로 다시 읽는다. 중복은 `delivery`로 거른다. - 앱이 구독하지 않은 이벤트는 여기에도 없다. ### 33. `GET /v1/export` — 앱 전체 내려받기 쿼리가 없다. 유저·방·멤버·메시지·리액션·읽음 위치를 **NDJSON 한 파일**로 스트리밍한다(`Content-Type: application/x-ndjson`). 첨부 파일 자체는 없다. ```bash curl -sS -H "Authorization: Bearer $CHAT_SECRET_KEY" https://api.chat.gravex.app/v1/export -o export.ndjson tail -n 1 export.ndjson # {"type":"end",…} 이어야 완전한 파일이다 ``` - 한 줄이 JSON 하나이고 줄마다 `type`이 있다. 순서: `export` → `user`(전부) → 방마다 `room`, `member`, `message`(seq 오름차순), `reaction`, `read` → `end`(`counts` 포함). - **마지막 줄이 `end`가 아니면 불완전한 파일이다.** 상태 코드는 이미 200이라 중간 실패를 알릴 수 없다. 처음부터 다시 받는다(이어받기 없음). - 앱당 동시에 하나다. 이미 돌고 있으면 409, 서버가 바쁘면 429(`Retry-After`)다. - 스냅숏이 아니다. 받는 동안의 쓰기는 들어갈 수도 안 들어갈 수도 있다. ## 접근 규칙 유저 토큰 호출은 WebSocket과 **같은 규칙**을 받는다. 서버 키는 이 검사를 건너뛴다. | 방 종류 | 읽기 | 쓰기 | 목록 탐색 | |---|---|---|---| | `public` | 누구나 | 누구나 | 나온다 | | `channel` | 누구나 | 멤버 중 owner·admin만 | 나온다 | | `private` | 멤버만 | 멤버만 | 안 나온다 | | `dm` | 두 사람만 | 두 사람만 | 안 나온다 | - `public`·`channel`은 멤버가 아니어도 읽을 수 있지만, 8번으로 멤버가 되어야 **내 방 목록과 알림**에 들어간다. - 읽을 수 없는 방은 403 `forbidden`이다. 다른 앱의 방 id나 지워진 방은 404다. | 역할 | 읽기 | 쓰기 | 멤버 관리 | 방 수정 | 방 삭제 | |---|---|---|---|---|---| | `owner` | O | O | O | O | O | | `admin` | O | O | O | O | X | | `member` | O | 방 종류에 따름 | X | X | X | ## 앱이 쓰는 라우트 모바일·프론트 앱은 백엔드에서 유저 토큰을 받아 유저 토큰으로만 부른다. 보통 쓰는 라우트는 3~5번, 8~9번, 11번, 13~19번, 21~23번이다. 나머지(방 만들기, 멤버 관리, 유저 관리, 이벤트, 내보내기)는 백엔드가 서버 키로 한다. 실시간 연결과 재접속, 구멍 메우기는 [실시간 프로토콜](https://chat.gravex.app/docs/realtime)을 따른다. 원문: https://chat.gravex.app/docs/rest-api --- # 실시간 프로토콜 (WebSocket) JS SDK 없이 실시간 채팅 클라이언트를 만드는 법이다. iOS(Swift), Android(Kotlin), Flutter(Dart), Python, Go처럼 **WebSocket과 JSON, HTTP만 되는 언어면 무엇이든** 같은 일을 할 수 있다. 이 문서의 규칙을 구현하면 공식 SDK와 똑같이 동작한다. 마지막의 「SDK 없이 클라이언트 만들기」가 그 목록이다. - 서버가 보내는 이벤트(새 메시지, 리액션, 입력 중, 접속 중…)는 **WebSocket**으로 온다. - 히스토리, 끊겼던 동안의 구멍 메우기, 안 읽은 수는 **[REST](https://chat.gravex.app/docs/rest-api)**로 가져온다. 둘은 서로를 대신하지 못한다. 구독은 지난 일을 다시 보내 주지 않고, REST는 새 일을 밀어 주지 않는다. - 메시지 보내기는 둘 다 된다. 리액션, 읽음 위치, 입력 중 알림은 **WebSocket으로만** 된다. ## 한눈에 1. 백엔드에서 **유저 토큰**(JWT)을 받는다. `sk_`로 서명하는 일은 백엔드 몫이다([서버 연동](https://chat.gravex.app/docs/server)). 2. `wss://api.chat.gravex.app/v1/ws?v=1`로 WebSocket을 연다. 3. **첫 프레임으로 `auth`**를 보낸다. 5초 안에 못 보내면 끊긴다. 4. `hello`를 받으면 준비가 끝난 것이다. 25초마다 `ping`을 보낸다. 5. 보고 있는 방마다 `subscribe`를 보내고, 받은 `lastSeq`와 내가 가진 마지막 `seq`의 차이를 REST 히스토리로 채운다. 6. `push` 프레임으로 오는 이벤트를 화면 상태에 반영한다. 7. 끊기면 백오프로 다시 연결하고 5번부터 되풀이한다. 모든 프레임은 **JSON 텍스트 프레임 하나**다. 바이너리 프레임은 쓰지 않는다. ## 접속 ```text wss://api.chat.gravex.app/v1/ws?v=1 ``` - `?v=1`은 프로토콜 버전이다. 없거나 다르면 HTTP 400으로 핸드셰이크가 거절된다. - 키와 토큰은 **URL이나 HTTP 헤더가 아니라 첫 프레임**에 싣는다(URL은 로그에 남는다). - 브라우저는 `Origin`이 `pk_`의 허용 origin에 있어야 한다. **`Origin` 헤더를 보내지 않는 네이티브 클라이언트는 이 검사를 받지 않는다.** - 서버가 점검 중이면 핸드셰이크가 HTTP 503이다. 백오프로 다시 시도한다. - 압축 확장, 서브프로토콜은 쓰지 않는다. ## 프레임 형식 모든 프레임은 이 봉투다. `id`와 `data`는 없을 수 있다. ```json { "type": "publish", "id": "c7", "data": { } } ``` **클라이언트가 보내는 프레임** - `type`은 아래 8가지 중 하나다. 다른 값은 `invalid`다. - 항상 `id`를 붙인다. 연결 안에서 유일한 아무 문자열이면 된다(`"c1"`, `"c2"`…). - 서버는 같은 `id`로 **`ack` 아니면 `error`를 정확히 하나** 돌려준다. 이것이 요청과 응답을 잇는 방법이다. 여러 프레임을 연달아 보내도 되고 답이 순서대로 온다는 보장은 없다. - **`id`는 멱등키가 아니다.** 중복 방지는 `publish`의 `clientMessageId` 하나뿐이다. - 모르는 필드는 서버가 무시한다. **서버가 보내는 프레임** | `type` | 모양 | 뜻 | |---|---|---| | `hello` | `{data: {…}}` | 인증 성공 | | `ack` | `{id, data}` | 그 `id`의 요청이 성공했다. `data`는 요청별로 다르다 | | `error` | `{id?, data: {code, message, retryAfterMs?, appCode?}}` | 그 `id`의 요청이 실패했다. **`id`가 없으면 연결 수준 오류**다(프레임이 너무 크거나 JSON이 아니어서 `id`를 못 읽었다) | | `push` | `{data: {roomId?, event, data}}` | 서버가 먼저 보내는 이벤트. 방 이벤트는 `roomId`가 있고 유저 이벤트는 없다 | | `pong` | `{}` | `ping`의 답 | | `reconnect` | `{data: {reason, delayMs}}` | 서버가 곧 내려간다. `delayMs` 뒤에 다시 접속한다 | - 서버의 프로토콜은 필드를 **더하기만** 한다. 모르는 필드와 모르는 `push` 이벤트는 무시한다(오류로 취급하지 않는다). - 프레임 하나는 기본 64 KB까지다. 넘으면 `too_large`이고 연결은 유지된다. `hello` 뒤에는 `hello.limits.frameSizeBytes`가 상한이다. - 에러 `code`는 REST와 같다: `unauthorized`, `forbidden`, `not_found`, `invalid`, `too_large`, `rate_limited`, `banned`, `moderation_denied`, `cursor_too_old`, `quota_exceeded`, `internal`. 뜻은 [REST API](https://chat.gravex.app/docs/rest-api#에러-형식)의 표와 같다. 프레임 하나가 실패해도 **연결은 끊기지 않는다**(인증 단계만 예외다). ## 인증: `auth` → `hello` 연결을 열면 5초 안에 첫 프레임으로 보낸다. ```json { "type": "auth", "id": "auth", "data": { "key": "pk_…", "token": "<유저 토큰 JWT>" } } ``` 성공하면 같은 연결로 `hello`가 온다. ```json { "type": "hello", "data": { "connectionId": "0AAS4P1MM0001", "user": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" }, "mode": "token", "serverTime": 1792861844000, "heartbeatMs": 25000, "limits": { "frameSizeBytes": 65536, "bodyTextMaxLen": 4000, "publishPerSecond": 5, "…": 0 }, "unread": { "total": 3, "mentions": 1 }, "warnings": [] } } ``` | 필드 | 설명 | |---|---| | `connectionId` | 이 연결의 id. 지원 문의나 [강제 종료](https://chat.gravex.app/docs/rest-api#30-post-v1usersuseriddisconnect-연결-강제-종료)의 대상 지정에 쓴다 | | `user` | 서버가 보는 나. `id`는 토큰의 `sub`다. **메시지의 `sender`가 이 값으로 나간다** | | `mode` | 늘 `"token"` | | `serverTime` | 서버 시각(에포크 밀리초). 기기 시계가 틀렸을 때 보정에 쓴다 | | `heartbeatMs` | `ping`을 보낼 간격(밀리초) | | `limits` | 이 앱의 한도 실효값. [한도](#한도) | | `unread` | 내가 멤버인 방 전체의 **안 읽은 총계**: `{total, mentions, roomsCapped?}`. 방별 목록이 아니다. **필드가 없을 수 있고, 없는 것은 0이 아니다** — 없으면 `GET /v1/me/unread`로 묻는다. 0이라고 믿고 배지를 지우지 않는다 | | `warnings` | 문자열 배열. 지금은 보통 비어 있다 | ### 인증이 실패하면 `error`가 먼저 오고(그 `id`는 `auth` 프레임의 `id`다) 서버가 연결을 닫는다. 닫힘 사유(close reason)는 에러 `code`와 같다. | `code` | 원인 | 다음 동작 | |---|---|---| | `unauthorized` | 키·토큰이 틀렸다, 만료·무효화됐다, `Origin` 불허, 첫 프레임이 `auth`가 아니다, 5초 안에 `auth`가 없다 | **같은 토큰으로 다시 하지 않는다.** 백엔드에서 새 토큰을 받을 수 있으면 받아서 한 번 더, 안 되면 멈추고 사용자에게 알린다 | | `forbidden` | 이 키로는 허용되지 않는다 | 멈춘다 | | `invalid` | `auth` 데이터가 JSON이 아니다 | 코드를 고친다 | | `rate_limited` | 접속 한도(유저당 분당 10, 키+IP당 분당 60) 또는 동시 연결 한도 | `retryAfterMs`가 있으면 그만큼, 없으면 백오프로 기다렸다 재접속 | | `internal` | 서버 오류 | 백오프로 재접속 | 토큰은 **접속할 때마다** 백엔드에서 새로 받는다. 한 번 받아 캐시해 두고 재사용하지 않는다. 토큰은 24시간 이내로 만료되고, 이미 열린 연결은 만료와 상관없이 유지된다. ## 연결 유지 - **`heartbeatMs`(25초)마다 `{"type":"ping","id":"p1"}`을 보낸다.** 서버는 `{"type":"pong"}`으로 답한다. 서버는 **60초 동안 클라이언트 프레임이 없으면 연결을 끊는다.** WebSocket 라이브러리 자체의 ping 제어 프레임이 아니라 이 JSON `ping`을 보내야 한다. - 모바일에서 앱이 백그라운드에서 돌아왔거나 네트워크가 바뀌면 바로 `ping`을 보내 본다. 답이 없으면 소켓이 죽은 것이니 즉시 재접속한다. - 서버가 배포 때문에 내려갈 때는 `{"type":"reconnect","data":{"reason":"shutdown","delayMs":3200}}`를 먼저 보낸다. **`delayMs`만큼 기다렸다가** 새로 접속한다(모든 클라이언트가 한꺼번에 몰리지 않게 서버가 나눠 준 시간이다). - 보내는 이벤트를 너무 느리게 읽으면 서버가 연결을 닫는다(사유 `slow_consumer`). 읽기 루프에서 오래 걸리는 일을 하지 않는다. 다시 접속하고 구멍을 메우면 된다. ### 서버가 닫을 때의 사유 서버가 닫는 연결은 WebSocket close 코드 1008(드물게 1000)에 아래 사유 문자열이 붙는다. | 사유 | 의미 | 클라이언트 | |---|---|---| | `unauthorized`, `forbidden`, `invalid`, `rate_limited`, `internal`, `auth timeout` | 인증 단계에서 거절 | 위 표 | | `disconnected` (또는 서비스가 정한 문구) | 서비스가 이 유저의 연결을 강제로 끊었다 | 가진 토큰으로 재접속해도 된다 | | `tokens_revoked` | 이 유저의 토큰이 전부 무효화됐다 | 백엔드에서 **새 토큰**을 받아야 한다. 같은 토큰으로 재접속하면 `unauthorized`로 되돌아온다 | | `app_deleted` | 앱이 삭제됐다 | 재접속은 `unauthorized`가 된다. 멈춘다 | | `slow_consumer` | 이벤트를 너무 느리게 읽었다 | 재접속하고 구멍을 메운다 | 사유를 못 읽어도(라이브러리마다 다르다) 규칙은 같다. 닫히면 재접속하고, 인증 단계에서 `unauthorized`/`forbidden`/`invalid`가 오면 멈춘다. ## 방 보기: `subscribe` / `unsubscribe` **watch(구독)**는 "이 연결이 그 방의 모든 이벤트를 받는다"는 뜻이다. 지금 화면에 열려 있는 방만 구독한다(연결당 `maxSubscriptions`, 기본 100개). ```json { "type": "subscribe", "id": "c2", "data": { "key": "lobby", "since": 12 } } ``` | 필드 | 설명 | |---|---| | `roomId` 또는 `key` | **정확히 하나**. 방을 `key`로 열어도 이후 모든 `push`는 `roomId`로만 온다. ack의 `roomId`를 기억해 둔다 | | `since` | 내가 가진 메시지 중 **끊김 없이 이어진 맨 위 `seq`**. 처음 보는 방이면 생략한다 | 응답: ```json { "type": "ack", "id": "c2", "data": { "roomId": "0AAS4P1MM0000", "lastSeq": 15, "minSeq": 1, "presence": { "count": 2, "users": [{ "id": "u-1", "name": "alice" }, { "id": "u-2", "name": "bob" }] } } } ``` | ack 필드 | 설명 | |---|---| | `lastSeq` | 방의 마지막 `seq`. 내가 가진 것이 이보다 작으면 구멍이다 | | `minSeq` | 히스토리로 읽을 수 있는 가장 오래된 `seq` | | `presence` | 지금 방에 있는 사람 `{count, users?, capped?}`. `users`는 방이 **100명 이하일 때만** 있다. 조회가 실패하면 필드째 빠진다(0명과 다르다) | - 구독해도 **지난 이벤트는 오지 않는다.** 히스토리는 REST로 가져온다([재접속과 구멍 메우기](#재접속과-구멍-메우기)). - `since`가 `minSeq - 1`보다 작으면 **`cursor_too_old`** 에러다. 이어 붙일 수 없으니 가진 목록을 버리고 `since` 없이 다시 `subscribe`한 뒤 최근 히스토리를 받는다. - 이미 구독한 방에 다시 `subscribe`하면 아무것도 바뀌지 않고 같은 ack가 온다. - 구독은 **연결에 붙어 있다.** 소켓이 끊기면 모두 사라지므로 `hello` 뒤에 다시 보낸다. - 구독하는 순간 이 유저가 방의 접속 중(presence) 목록에 들어가고, `unsubscribe`하거나 연결이 끊기면 빠진다. - 구독할 수 없는 방은 에러다: 없거나 다른 앱의 방 `not_found`, 읽을 수 없는 방 `forbidden`, 한도 초과 `too_large`, 탈퇴한 유저 `unauthorized`. - `publish`·`react`·`read`·`typing`은 구독하지 않은 방에도 보낼 수 있다(권한만 있으면 된다). 그 방의 이벤트는 구독해야 받는다. ```json { "type": "unsubscribe", "id": "c3", "data": { "roomId": "0AAS4P1MM0000" } } ``` `unsubscribe`는 구독하지 않은 방에도 항상 빈 `ack`(`{}`)로 성공한다. **구독하지 않은 방의 새 메시지**는 내가 멤버일 때 유저 이벤트 `notification.message`로 온다(안 읽은 배지용). 그 방의 `message.created`는 오지 않는다. 한 연결에는 둘 중 하나만 온다. ## 메시지 보내기: `publish` ```json { "type": "publish", "id": "c4", "data": { "roomId": "0AAS4P1MM0000", "clientMessageId": "cm-6f1c9a1e", "body": { "text": "안녕 @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] }, "replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "meta": { "client": "ios" } } } ``` | 필드 | 필수 | 설명 | |---|---|---| | `roomId` | 예 | | | `clientMessageId` | **예** | 아래 「멱등성」 | | `body` | 예 | `{text?, attachments?, entities?}`. 첨부는 `{type, url, key?, mime?, w?, h?, name?, size?}`이고 `url`은 `https`만 된다 | | `replyTo` | | 인용할 메시지 id. 같은 방이어야 한다 | | `threadId` | | 스레드 답글이면 원 메시지 id. 답글의 id를 주면 그 **루트**로 바꿔 저장한다 | | `meta` | | JSON 객체 | | `kind` | | 생략한다(`text`). `system`은 서버 키로 [REST](https://chat.gravex.app/docs/rest-api#14-post-v1roomsroomidmessages-발행)에서만 보낸다 | ack: `{"messageId": "0AAS4P1P40800", "seq": 16, "createdAt": 1792861844902}` - **ack를 받은 메시지는 저장된 것이다.** 방을 구독하고 있으면 내가 보낸 메시지도 `message.created`로 다시 온다. ack와 `message.created`가 오는 순서는 정해져 있지 않으니 `id`나 `seq`로 중복을 걸러 한 번만 그린다. - 보내는 순간에 미리 화면에 그리는(낙관적 표시) 것은 권장하지 않는다. 앱의 `before_publish` 훅이 본문을 바꾸거나 거절할 수 있어서, 저장된 모양은 ack 뒤에야 확정된다. - 실패 `code`: `forbidden`(쓸 수 없는 방, `channel`의 일반 멤버), `banned`, `moderation_denied`(`message`와 `data.appCode`에 서비스가 정한 값이 실린다), `rate_limited`(`retryAfterMs`), `too_large`(본문이 `bodyTextMaxLen`을 넘는다), `invalid`(`clientMessageId` 없음, 첨부 `url`이 https가 아님, `entities` 100개 초과), `quota_exceeded`(앱의 저장 쿼터), `not_found`. - 본문 텍스트는 엔진이 이스케이프하지 않는다. HTML·마크다운으로 렌더하는 클라이언트는 렌더 단계에서 이스케이프한다. 표시 이름도 같다. - `entities`의 멘션은 `userId`가 실제 유저일 때만 멘션으로 센다. 모르는 id는 거절이 아니라 제외이고, 본문의 entity는 그대로 남는다. ### 멱등성: `clientMessageId` - **메시지마다 새 값을 만든다**(UUIDv4 권장, 예 `"cm-" + uuid`). - 같은 보낸 사람이 **24시간 안에** 같은 값으로 다시 보내면 새 메시지를 만들지 않고 **처음 저장된 메시지의 `messageId`·`seq`로 ack**한다. - ack 전에 연결이 끊겨 저장 여부를 모를 때는 **같은 `clientMessageId`로 다시 보낸다.** 새 값을 쓰면 같은 말이 두 번 올라간다. - 사용자가 "다시 보내기"를 누르면 값을 그대로 둔다. 새 내용은 새 값이다. ## 리액션: `react` ```json { "type": "react", "id": "c5", "data": { "roomId": "0AAS4P1MM0000", "messageId": "0AAS4P1P40800", "emoji": "👍", "op": "add" } } ``` - `op`는 `"add"` 또는 `"remove"`. **기본값이 없다.** 없거나 다른 값이면 `invalid`다. - 멱등이다. 이미 누른 이모지를 또 `add`해도 오류가 아니다. - `emoji`는 앱이 정하는 불투명한 문자열이고 **64바이트 이하**다(글자 수가 아니라 바이트). - 한 메시지에 서로 다른 이모지는 `maxDistinctEmojiPerMessage`(기본 20)개까지다. **이미 달린 이모지를 누르는 것은 이 한도와 무관하다.** 넘기면 `invalid`다. 화면에서 미리 막으려면 `hello`의 `limits`와 행의 `reactions` 개수를 본다. - ack: `{"messageId": "…", "emoji": "👍", "count": 3, "changed": true}`. `count`는 누른 뒤의 값이고 `changed: false`면 아무것도 바뀌지 않았다. 브로드캐스트를 놓쳤어도 이 `count`로 맞춘다. - 지워진 메시지에는 `not_found`다. 밴된 유저는 `banned`다. ## 읽음 위치: `read` ```json { "type": "read", "id": "c6", "data": { "roomId": "0AAS4P1MM0000", "seq": 16 } } ``` - `seq`는 1 이상이다. 내가 본 마지막 메시지의 `seq`를 보낸다. - **위치는 앞으로만 간다.** 지금보다 작은 값은 오류 없이 무시된다. 방의 `lastSeq`를 넘는 값은 `lastSeq`로 잘린다. - ack: `{"roomId": "…", "seq": 16, "advanced": true}`. 서버가 실제로 쓴 값이다. `advanced: false`면 무시되거나(뒤로 가는 값) 저장할 것이 없었다(메시지 없는 방). - `threadId`를 주면 그 스레드의 위치다(답글 id를 줘도 루트로 바뀐다). ack의 `threadId`도 바뀐 루트다. 안 주면 본 타임라인이고, **스레드를 읽었다고 방 배지가 지워지지 않는다.** - 내 위치를 새로 불러오려면 `GET /v1/rooms/{roomId}/reads?userId=<내 id>`를 쓴다. - 안 읽은 수는 서버가 센다. `lastSeq - readSeq`로 계산하지 않는다(스레드 답글, 내 메시지, 삭제된 메시지 때문에 틀린다). ## 입력 중: `typing` ```json { "type": "typing", "id": "c7", "data": { "roomId": "0AAS4P1MM0000", "threadId": "0AAS4P1P40000" } } ``` `threadId`는 스레드 답글을 쓰는 중일 때만 준다. 보내는 쪽: - 입력 이벤트마다 부르되 **같은 방에는 1초에 한 번 이하로** 보낸다(클라이언트에서 줄인다). 한 번도 안 줄이면 프레임이 낭비된다. - 서버는 한 유저의 방 하나에서 **3초에 한 번만** 방송한다. 줄여진 프레임에도 `ack`는 정상으로 오고 `data`가 비어 있다. - 방 전체로도 한 창(3초)에 알리는 사람 수에 상한이 있어 붐비는 방에서는 일부가 알려지지 않는다. - 쓰기 권한이 필요하다. `channel`의 일반 멤버는 읽을 수 있어도 `forbidden`이다. 밴·탈퇴한 유저의 `typing`은 조용히 무시된다. - `typing`은 ack를 기다리지 않고 보내도 된다. 실패해도 입력을 막지 않는다. 받는 쪽 (`typing` 이벤트): - **"입력 멈춤" 이벤트는 없다.** 마지막 `typing` 이벤트를 받은 뒤 **5초 이상** 표시하고 지운다. - **내가 보낸 `typing`도 나에게 온다.** 이벤트의 `userId`가 내 id면 건너뛴다. - 이름은 이벤트에 없다. 방의 접속 중 목록(`presence.users`)이나 최근 메시지의 `sender`에서 찾는다. - `threadId`가 있으면 그 스레드(루트 메시지 id)의 입력 중이고, 없으면 본 타임라인이다. ## 서버가 보내는 이벤트 (`push`) ```json { "type": "push", "data": { "roomId": "0AAS4P1MM0000", "event": "message.created", "data": { } } } ``` 방을 **구독하고 있을 때** 그 방의 이벤트가 오고, 유저 이벤트(`notification.message`, `user.presence`)는 구독과 상관없이 온다. 방 이벤트는 `roomId`로 어느 방인지 안다. | `event` | 범위 | `data` | |---|---|---| | `message.created` | 방 | [메시지 객체](https://chat.gravex.app/docs/rest-api#메시지). 스레드 답글도 이 이벤트다. **`thread`는 없고 `reactions`는 빈 배열이며 `myReactions`는 없다**(갓 만든 메시지다) | | `message.updated` | 방 | 메시지 객체. 수정된 메시지의 **`reactions`·`thread`는 실제 값**이다. `myReactions`는 없다 | | `message.deleted` | 방 | `{id, seq}`. `seq`는 그 메시지가 발행될 때의 값이다 | | `reaction.added`, `reaction.removed` | 방 | `{messageId, emoji, count, userId}`. `count`는 변경 후의 값이다 | | `thread.updated` | 방 | `{rootId, count, lastSeq, lastAt}`. 답글이 달리거나 지워질 때. 지워질 때는 `message.deleted` **다음에** 온다 | | `presence` | 방 | `{count, joined?, left?, users?}`. 아래 「접속 중」 | | `typing` | 방 | `{userId, threadId?}`. 위 「입력 중」 | | `read` | 방 | `{userId, seq, threadId?}`. 누군가의 읽음 위치가 앞으로 갔다 | | `member.joined`, `member.left` | 방 | `{userId, role?}` | | `room.updated` | 방 | [방 객체](https://chat.gravex.app/docs/rest-api#방) | | `room.deleted` | 방 | `{roomId}`. 이 프레임 뒤로 그 방의 구독이 풀린다. 연결은 유지된다 | | `custom` | 방 | 서비스 백엔드가 [`POST …/custom`](https://chat.gravex.app/docs/rest-api#20-post-v1roomsroomidcustom-저장되지-않는-제어-신호)으로 보낸 JSON 객체 그대로 | | `notification.message` | 유저 | `{roomId, seq, unread?, unreadMentions?, unreadCapped?, preview?}`. 내가 멤버인데 **구독하지 않은** 방에 새 메시지가 왔다. `unread`는 이 메시지를 포함한 내 안 읽은 수이고, 계산이 실패하면 필드가 빠진다(0이 아니다). `preview`는 `{sender: {id, name, avatar?}, text, createdAt}` | | `user.presence` | 유저 | `{userId, status: "online" \| "offline", lastSeenAt?}`. 내가 멤버인 `dm`·`private` 방의 상대 상태다. 그 유저의 연결 수가 0에서 1이 되거나 1에서 0이 될 때만 온다. `lastSeenAt`은 offline일 때만 있다 | - 메시지 이벤트(`message.*`)는 놓쳐도 재접속 뒤 **REST 히스토리로 복구**된다. 휘발 이벤트(`presence`, `typing`, `custom`, `notification.message`, `user.presence`)는 놓쳐도 되고 복구되지 않는다. 리액션·스레드 집계·읽음·멤버 변동을 놓친 것은 히스토리에 실린 메시지의 집계로만 보정된다. - `notification.message`를 놓쳤으면 `GET /v1/me/rooms`로 방별 안 읽은 수를 다시 받는다. ### 이벤트를 화면 상태에 반영하는 규칙 | 이벤트 | 규칙 | |---|---| | `message.created` | `seq` 자리에 끼워 넣는다. 그 `seq`가 이미 있으면 덮어쓴다(단, 내가 삭제됨으로 표시한 것은 되살리지 않는다). **`seq`가 내가 가진 맨 위 +1이 아니면 구멍이다** → [구멍 메우기](#재접속과-구멍-메우기) | | `message.updated` | 같은 `id`의 필드를 새 값으로 바꾼다. 이미 삭제된 행이면 무시한다 | | `message.deleted` | 행을 지우지 말고 **본문을 비우고 삭제됨으로 표시**한다(`seq` 자리는 유지). 아직 그 행이 없으면 `id`를 기억해 두었다가 행이 들어올 때 비운다 | | `reaction.added`, `reaction.removed` | 그 이모지의 개수를 `count`로 **맞춘다**(더하거나 빼지 않는다). `count`가 0 이하면 그 이모지를 뺀다. `userId`가 내 id면 내 `myReactions`에 넣거나 뺀다 | | `thread.updated` | 루트 메시지의 `thread`를 `{count, lastSeq}`로 바꾼다 | | `room.deleted` | 그 방을 닫는다 | - 본 타임라인에는 `threadId`가 없는 메시지만 그린다. 스레드 답글은 스레드 화면에 그린다. - **동기화 위치(`seq`)는 방 전체 스트림 기준이다.** 본 타임라인만 골라 그리더라도 위치 계산에는 스레드 답글을 포함한 모든 메시지를 쓴다. ## 접속 중 (presence) 방의 접속 중은 **그 방을 구독한 연결**의 유저다. 처음 값은 `subscribe` ack의 `presence`에서 받고, 이후는 `presence` 이벤트로 갱신한다. - `presence` 이벤트의 `joined`·`left`는 **증분**이다. 가진 목록에 `joined`를 더하고(같은 `id`가 있으면 교체) `left`(유저 id 배열)를 뺀다. - `users`가 오면 **목록 전체를 그것으로 바꾼다**(교체). - `joined`/`left`와 `users`는 한 프레임에 같이 오지 않는다. - 방이 **100명 이하**면 사람이 들어오고 나갈 때마다 즉시 `joined`/`left`가 온다. 그보다 크면 주기적으로(5초마다) **`count`만** 오고 `joined`·`users`는 오지 않는다. 100명에서 올라가고 90명에서 내려온다. - 이벤트가 `count`만 싣고 `joined`·`left`·`users`가 모두 없으면 **가진 목록은 이제 믿을 수 없다.** 목록을 버리고 `count`만 쓴다. 전체 목록이 필요하면 `GET /v1/rooms/{roomId}/presence?full=true`로 가져온다(1000명에서 잘린다). - 작은 방으로 돌아오면 `{count, users: 전체}` 스냅샷이 한 번 온다. 이것이 낡은 목록을 고친다. - 가진 목록이 없을 때는 `joined`만으로 목록을 만들지 않는다(나중에 온 사람만 보이게 된다). - 한 사람이 연결을 여럿 열어도 목록에는 한 줄이다. `joined`는 그 유저의 첫 구독에서, `left`는 마지막 구독이 풀릴 때 온다. ## 재접속과 구멍 메우기 끊김은 정상이다. 지하철, 백그라운드, 배포가 모두 끊김이다. **어떤 이벤트를 놓쳤더라도 `seq`로 알 수 있다.** 방의 `seq`는 1부터 **구멍 없이** 늘기 때문에, 내가 가진 번호가 이어지지 않으면 그 사이를 REST로 받으면 된다. ### 재접속 루프 ```text attempt = 0 loop: token = 백엔드에서 새 토큰 받기 # 접속마다. 실패하면 백오프 뒤 다시 ws 열기 → auth 보내기 hello 받으면: attempt = 0, ping 타이머 시작, 아래 resubscribe 실행 ws가 닫히면(또는 ack 없이 죽으면): 대기 중이던 요청은 모두 실패 처리(아래 「재전송」) 인증 단계 거절(unauthorized/forbidden/invalid)이면 멈춘다 대기 시간 = 서버가 정해 준 값(reconnect.delayMs, rate_limited의 retryAfterMs)이 있으면 그 값 없으면 500ms + random() × (min(30000, 500ms × 2^attempt) − 500ms) # 지수 백오프 + 지터 attempt += 1, 대기 후 loop ``` - 지터를 빼지 않는다. 서버가 내려가면 모든 클라이언트가 같은 순간에 끊겨 같은 순간에 돌아온다. - 사용자가 직접 누르는 전송은 백오프를 건너뛰고 즉시 재접속을 시도해도 된다. 단 서버가 정해 준 대기는 건너뛰지 않는다. - `hello`의 `user.id`가 이전 접속과 다르면(로그아웃 후 다른 계정) **방 상태를 전부 버리고** 새로 받는다. 이전 유저의 `myReactions`가 새 유저 화면에 남지 않게 한다. ### resubscribe (hello를 받을 때마다, 처음에도) 방마다 한다. 방 상태는 `messages`(`seq` 순으로 정렬된 목록)와 `contiguousSeq`다. ```text contiguousSeq = 목록의 첫 메시지부터 seq가 +1(또는 같게) 이어지는 맨 위 값. 목록이 비면 0 subscribe 보내기 { roomId 또는 key, since: contiguousSeq (0이면 생략) } ├─ cursor_too_old 에러 → 목록을 버리고 since 없이 다시 subscribe → 최근 히스토리 받기(아래 A) → 화면에 "목록이 새로 만들어졌다" 알림 └─ ack {lastSeq, minSeq} ├─ since 있음(내가 가진 게 있다) → catchUp(lastSeq) (아래 B) └─ since 없음 → lastSeq > 0이면 최근 히스토리 받기(A) ``` **A. 최근 히스토리**: `GET /v1/rooms/{roomId}/messages?limit=100` 한 번. 받은 페이지로 목록을 바꾸되, 그 요청이 오가는 동안 push로 이미 들어온 메시지 중 페이지의 맨 위 `seq`보다 큰 것은 지우지 않고 남긴다. **B. catchUp(upTo)**: 내 `contiguousSeq`부터 `upTo`까지를 채운다. ```text fill(after, before): # after는 포함하지 않고 before도 포함하지 않는 구간 cursor = after while cursor < before - 1: page = GET /v1/rooms/{roomId}/messages?after={cursor}&limit=100 # view는 생략(방 전체) if page가 비었으면 종료 page의 각 메시지를 seq 자리에 끼워 넣는다 if page의 맨 위 seq <= cursor 이면 종료 cursor = page의 맨 위 seq catchUp(upTo): fill(contiguousSeq, upTo + 1) ``` `after=N`은 `seq > N` 중 **오래된 것부터** 최대 100개를 준다. 한 번에 다 안 오면 마지막 `seq`를 `after`로 넣어 이어 받는다. 이미 이어진 상태면(`upTo <= contiguousSeq`) REST를 부르지 않는다. ### 실시간 중의 구멍 `message.created`의 `seq`가 가진 맨 위 `seq`+1보다 크면(예: 15까지 가졌는데 18이 왔다) 먼저 18을 끼워 넣고 `fill(15, 18)`로 16·17을 받는다. 목록이 비어 있으면 구멍 검사를 하지 않는다. ### 위로 스크롤 (더 오래된 히스토리) 가진 맨 위 `seq`가 `minSeq`보다 크면 더 오래된 것이 있다. `GET …/messages?before={가진 맨 위 seq}&limit=100`으로 받아 앞에 붙인다. 응답이 `limit`보다 적으면 더 없다. ### 이 규칙의 함정 - **`view=main`·`thread:…`·`q`(검색)의 결과는 `seq`가 이어지지 않는다.** 그 결과의 마지막 `seq`를 동기화 위치로 쓰지 않는다. 동기화는 늘 `view` 없이(방 전체) 한다. - 삭제된 메시지도 `seq`를 가진 행으로 남아 있다(tombstone). `seq`는 삭제로 비지 않는다. - REST가 401이면 토큰이 만료된 것일 수 있다. 새 토큰으로 **한 번만** 다시 시도한다. 소켓은 접속 때 한 번만 인증하므로 소켓은 멀쩡한데 REST만 401인 일이 생긴다. - 반응·스레드 개수·읽음 위치·멤버 변동은 히스토리 페이지에 들어 있는 메시지에 대해서만 복구된다. 오래 떨어져 있었다면 화면에 보이는 구간의 최근 히스토리를 다시 받는 것이 가장 정직하다. ### 재전송 ack를 받기 전에 연결이 끊긴 요청은 서버가 받았는지 모른다. 이렇게 다룬다. | 요청 | 다시 보내도 되나 | |---|---| | `publish` | **같은 `clientMessageId`로** 다시 보낸다. 중복되지 않는다 | | `react` | 다시 보낸다(멱등) | | `read` | 다시 보낸다(멱등) | | `typing` | 다시 보내지 않는다(휘발) | | `subscribe` | 새 연결에서 resubscribe가 대신한다 | 재전송은 한 번이면 충분하다. 연결이 안 돌아오면 시간 초과(권장 15초)로 사용자에게 실패를 알린다. ## 한도 한도는 **앱마다** 다르고 `hello.limits`에 이 앱의 **실효값**이 실린다. 아래 숫자를 코드에 박지 말고 `hello.limits`를 읽는다. 키가 없으면 기본값이다. | `limits` 키 | 기본값 | 걸리면 | |---|---|---| | `frameSizeBytes` | 65536 | `too_large` (프레임) | | `bodyTextMaxLen` | 4000 | `too_large` (본문 글자 수) | | `maxAttachments` | 10 | `invalid` | | `publishPerSecond` / `publishBurst` | 5 / 10 | `rate_limited` (유저당, 순간 10건 + 10초 평균 5건) | | `reactPerSecond` | 10 | `rate_limited` | | `typingPerSecond` | 20 | `rate_limited` | | `editPerSecond` | 2 | `rate_limited` (REST 수정) | | `connectPerMinute` | 10 | 접속 시 `rate_limited` (유저당) | | `connectionEstablishPerMinute` | 60 | 접속 시 `rate_limited` (같은 `pk_`와 같은 IP) | | `maxConcurrentConnections` | 10 | 접속 시 `rate_limited`. **`retryAfterMs`가 없다**(언제 자리가 날지 모른다) | | `maxSubscriptions` | 100 | `subscribe`가 `too_large` | | `roomCreatePerDay` | 50 | `rate_limited` (유저 토큰으로 방 만들기) | | `maxDistinctEmojiPerMessage` | 20 | `invalid` | | `notificationMemberCap` | 1000 | 멤버가 이보다 많은 방은 `notification.message`가 가지 않는다. `GET /v1/me/rooms`를 주기적으로 읽는다 | | `editWindowMs` | 0 (제한 없음) | 수정 가능 시간 | | `maxStoredMessages` | 없음(무제한) | 앱의 저장 쿼터. 차면 `quota_exceeded` | - `hello.limits`에 이 표에 없는 키가 있을 수 있다. 모르는 키는 무시한다. - `hello.limits`는 **접속한 순간의 값**이다. 한도가 바뀌어도 열린 연결에는 다시 오지 않는다. 다음 접속의 `hello`에 실린다. - 한도는 **고정 창**으로 센다(슬라이딩이 아니다). 창 경계에서 잠깐 두 배가 나갈 수 있다. - `rate_limited` 에러의 **`retryAfterMs`만큼 기다렸다가** 같은 요청을 다시 보낸다. 짧게 줄여 재시도하지 않는다. - 서버 키(`sk_`)의 REST 호출은 발행·수정 한도를 세지 않는다. ## SDK 없이 클라이언트 만들기 공식 SDK(JS/TS)가 대신 해 주는 일의 전체 목록이다. **이 목록을 구현하면 SDK와 같은 클라이언트다.** 위에서 아래로 만들면 된다. **연결** 1. 백엔드에서 유저 토큰을 받는 함수를 둔다. **접속할 때마다, 그리고 REST가 401일 때** 이 함수를 부른다. 2. `wss://api.chat.gravex.app/v1/ws?v=1`로 연결하고 열리면 `auth`를 보낸다. `hello`를 받으면 `open` 상태다. 상태는 `connecting` → `open` → `reconnecting` → (`closed`)로 갖는다. 3. 프레임 `id`를 만들고, `id`별로 응답을 기다리는 표(대기 중 요청)를 둔다. `ack`면 성공으로, `error`면 실패로 잇는다. 시간 초과(15초)와 연결 끊김은 표 전체를 실패시킨다. 4. `heartbeatMs`마다 `ping`을 보낸다. 앱이 포그라운드로 돌아오거나 네트워크가 돌아오면 바로 `ping`을 보내 보고 안 되면 재접속한다. 5. 끊기면 지수 백오프(500ms~30s)와 지터로 재접속한다. `reconnect.delayMs`와 `rate_limited.retryAfterMs`가 있으면 그 값을 우선한다. 인증 단계의 `unauthorized`·`forbidden`·`invalid`면 멈추고 사용자에게 알린다. 6. `hello.user.id`가 바뀌면 방 상태를 전부 버린다. **방** 7. 방마다 `messages`(seq 순 목록)와 `contiguousSeq`를 둔다. 8. `hello`를 받을 때마다 구독 중인 방 전부에 `subscribe`(`since = contiguousSeq`)를 다시 보낸다. 9. `cursor_too_old`면 목록을 버리고 `since` 없이 다시 구독해 최근 히스토리를 받는다. 10. ack의 `lastSeq`까지 REST `GET …/messages?after=`로 구멍을 메운다. 실시간 `message.created`가 `seq` 구멍을 만들어도 같은 방법으로 메운다. 11. 위로 스크롤은 `before=`로 받는다. 12. 이벤트를 위의 「반영하는 규칙」대로 목록에 반영한다. 삭제는 행을 지우지 않고 비운다. 리액션 개수는 서버 `count`로 맞춘다. 13. `presence`를 증분(`joined`/`left`)과 교체(`users`)로 합치고, `count`만 오면 목록을 버린다. **보내기** 14. `publish`마다 새 `clientMessageId`를 만든다. ack 전에 끊기면 **같은 값으로** 한 번 다시 보낸다. 15. `react`·`read`는 ack 전에 끊기면 다시 보낸다. `typing`은 다시 보내지 않는다. 16. `typing`은 방마다 1초에 한 번 이하로 보내고, 받는 쪽은 마지막 이벤트 뒤 5초 이상 보여 준 뒤 지운다. 내 `userId`는 건너뛴다. 17. `read`는 화면에 실제로 보인 마지막 `seq`를 앞으로만 보낸다. 너무 자주 보내지 않는다. **안 읽은 수와 알림** 18. 접속 직후 `hello.unread`가 있으면 배지에 쓰고, **없으면** `GET /v1/me/unread`로 묻는다. 19. 구독하지 않은 방은 `notification.message`의 `unread`로 방 목록 배지를 올리고, 놓쳤을 수 있으면 `GET /v1/me/rooms`로 다시 받는다. **마무리** 20. 에러는 `code`로 분기한다(`rate_limited`는 대기, `banned`·`moderation_denied`는 사용자에게 표시, `quota_exceeded`·`forbidden`은 재시도하지 않는다). 21. 본문 텍스트와 이름을 화면에 그릴 때 이스케이프한다. 엔진은 하지 않는다. 22. (선택) 리액션을 누를 때 내 `myReactions`를 먼저 바꾸고 ack가 실패하면 되돌린다. **개수는 서버 값만 쓴다.** 푸시 알림은 이 연결의 일이 아니다. 앱이 꺼져 있는 사람에게는 `message.created` [웹훅](https://chat.gravex.app/docs/webhooks)을 받은 서비스 서버가 FCM·APNs로 보낸다. ## 프레임 대화 예시 `→`는 클라이언트가 보낸 것, `←`는 서버가 보낸 것이다. 한 줄이 프레임 하나다. ```text → {"type":"auth","id":"auth","data":{"key":"pk_…","token":"eyJ…"}} ← {"type":"hello","data":{"connectionId":"0AAS4P1MM0001","user":{"id":"u-1","name":"alice"},"mode":"token","serverTime":1792861844000,"heartbeatMs":25000,"limits":{"frameSizeBytes":65536,"bodyTextMaxLen":4000},"unread":{"total":0,"mentions":0},"warnings":[]}} → {"type":"subscribe","id":"c1","data":{"key":"lobby"}} ← {"type":"ack","id":"c1","data":{"roomId":"0AAS4P1MM0000","lastSeq":15,"minSeq":1,"presence":{"count":1,"users":[{"id":"u-1","name":"alice"}]}}} (이어서 REST: GET /v1/rooms/0AAS4P1MM0000/messages?limit=100) → {"type":"publish","id":"c2","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-6f1c9a1e","body":{"text":"안녕"}}} ← {"type":"ack","id":"c2","data":{"messageId":"0AAS4P1P40800","seq":16,"createdAt":1792861844902}} ← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"message.created","data":{"id":"0AAS4P1P40800","seq":16,"kind":"text","sender":{"id":"u-1","name":"alice"},"body":{"text":"안녕"},"mentions":[],"reactions":[],"createdAt":1792861844902}}} → {"type":"react","id":"c3","data":{"roomId":"0AAS4P1MM0000","messageId":"0AAS4P1P40800","emoji":"👍","op":"add"}} ← {"type":"ack","id":"c3","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"changed":true}} ← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"reaction.added","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"userId":"u-1"}}} → {"type":"publish","id":"c4","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-77aa","body":{"text":"빠르게"}}} ← {"type":"error","id":"c4","data":{"code":"rate_limited","message":"rate limit exceeded","retryAfterMs":640}} → {"type":"ping","id":"p1"} ← {"type":"pong"} ``` 원문: https://chat.gravex.app/docs/realtime --- # 콘솔 API (계정 키) 콘솔 화면에서 손으로 하는 일 — 앱 만들기, 키 발급, 허용 origin·웹훅·한도 설정, 앱 삭제와 복구 — 을 HTTP로 한다. **계정 키(`ck_…`) 하나**면 스크립트와 AI가 가입 뒤의 나머지를 대신할 수 있다. - 기준 주소: `https://console.chat.gravex.app` (모든 경로가 `/api/apps`로 시작한다. 문서 사이트가 아니라 **콘솔 주소**다) - 본문은 JSON(UTF-8)이고 `Content-Type: application/json`을 보낸다. 아니면 415 `unsupported_media_type`이다. - 시각은 ISO 8601 문자열이다(`2026-10-25T08:30:00.000Z`). REST API 레퍼런스의 에포크 밀리초와 다르다. - 이 API는 **앱과 키를 다룬다.** 방·메시지를 만들고 보내는 일은 여기서 받은 `sk_`로 [REST API](https://chat.gravex.app/docs/rest-api)와 [서버 연동](https://chat.gravex.app/docs/server)이 한다. 콘솔 API로 방을 만들지는 않는다. ## 계정 키 받기 계정 키는 **사람이 한 번 만든다.** 가입과 키 발급은 AI가 대신할 수 없다. 1. [콘솔](https://console.chat.gravex.app/login)에서 이메일로 받은 6자리 코드로 가입한다. 2. 위쪽 메뉴의 **API 키** → **새 키** → 이름(예: `노트북 .env`)을 적는다. 3. 키 전체(`ck_…`)가 **한 번만** 보인다. 바로 복사해 프로젝트의 `.env`에 넣는다. 콘솔은 해시만 저장해서 다시 보여 줄 수 없다. ```sh # .env — 값은 사람이 넣는다 CHAT_ACCOUNT_KEY=ck_... ``` 계정당 살아 있는 키는 최대 10개다. 안 쓰는 키는 **API 키** 화면에서 폐기한다. ### 보관 원칙 **`ck_`는 내 계정의 전권이다.** 이 키로 내 모든 앱의 키를 새로 발급하고, 앱을 지우고, 웹훅을 바꿀 수 있다. - 서버나 로컬 `.env`에만 둔다. **프런트엔드 코드, 브라우저, 모바일 앱에 넣지 않는다.** - git 저장소에 커밋하지 않는다. `.env`가 `.gitignore`에 있는지 먼저 확인한다. - AI 대화창에 붙여 넣지 않는다. 대화는 기록으로 남는다. AI에게는 "`.env`의 `CHAT_ACCOUNT_KEY`를 읽어서 써"라고만 시킨다. - 일이 끝나면 폐기한다. 필요할 때 새로 만들면 된다. 유출됐다고 의심되면 바로 폐기한다. 폐기해도 이미 만든 앱과 그 `pk_`·`sk_`, 웹훅 설정은 그대로 동작한다 — 계정 키는 이 API를 부르는 자격일 뿐이다. 폐기한 뒤에는 `.env`의 `CHAT_ACCOUNT_KEY` 줄도 지운다. - 키 하나가 하는 일은 감사 로그에 키 id와 함께 남는다. ## 인증 모든 요청에 헤더 하나를 붙인다. 쿠키는 필요 없다. ```sh curl https://console.chat.gravex.app/api/apps \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" ``` | 경로 | 계정 키 | |---|---| | `/api/apps/**` — 이 문서의 모든 라우트 | 된다 | | `/api/account/keys/**` — 계정 키 발급·목록·폐기 | **안 된다.** 콘솔 화면(로그인 세션)에서만. 키로 키를 만들 수 없다 | | `/api/auth/**` — 로그인·로그아웃 | 안 된다 | | 운영자용 경로 | 안 된다. 운영자 계정의 키도 마찬가지다 | - 키가 없거나 틀렸거나 폐기됐으면 401 `unauthorized`다. 이유를 가르지 않는다. - 키마다 1분에 120요청까지다. 넘으면 429 `rate_limited`이고 `Retry-After`(초)만큼 기다렸다 다시 보낸다. 틀린 키로 반복해서 두드리면 그 주소도 429가 된다. - 브라우저에서 부르는 용도가 아니다(CORS를 열지 않는다). 서버나 로컬 스크립트에서 부른다. - 남의 앱, 없는 앱, 지운 앱의 id는 모두 404 `not_found`다. 403으로 앱이 있다는 사실을 알려 주지 않는다. ## 에러 형식 실패는 HTTP 상태 코드와 이 모양의 JSON이다. 분기는 `code`로 한다. ```json { "code": "limit_reached", "limit": 10 } ``` | `code` | HTTP | 뜻 | |---|---|---| | `unauthorized` | 401 | 계정 키가 없거나 틀렸거나 폐기됐다 | | `not_found` | 404 | 내 앱이 아니거나 없는 앱·키·방·유저다 | | `invalid_request` | 400 | 본문·쿼리가 틀렸다. 한도 변경이면 `field`(와 `limit`)가 같이 온다 | | `invalid_name` | 400 | 앱 이름은 1~100자 | | `invalid_origins` | 400 | origin 모양이 틀렸다. `invalid`에 틀린 값 목록이 온다 | | `invalid_env` | 400 | `env`는 `production`이나 `development` | | `limit_above_ceiling` | 400 | 이 앱의 한도 상한을 넘겼다. `limit`에 상한이 온다 | | `limit_below_min` | 400 | 최솟값 아래다. `limit`에 최솟값이 온다 | | `limit_reached` | 409 | 계정당 앱 10개, 앱당 종류별 활성 키 5개 상한이다. `limit`에 상한이 온다 | | `conflict` | 409 | 지금 상태와 부딪힌다(예: 이미 도는 영구 삭제) | | `export_in_progress` | 409 | 이 앱의 내보내기가 이미 돌고 있다 | | `unsupported_media_type` | 415 | 본문을 `application/json`으로 보내지 않았다 | | `rate_limited` | 429 | 요청이 너무 많다. `Retry-After` 뒤에 다시 | | `export_busy` | 429 | 엔진이 다른 내보내기로 바쁘다. 몇 초 뒤에 다시 | | `engine_error` | 502 | 채팅 엔진이 에러를 돌려줬다 | | `engine_unavailable` | 503 | 채팅 엔진에 닿지 못했다. 잠시 뒤에 다시 | | `unknown` | 500 | 서버 오류 | 5xx는 지수 백오프로 다시 시도한다. 429와 409는 위 설명대로 상태가 바뀐 뒤에 다시 시도한다. ## 앱 만들고 키 받기 계정 키만 있으면 아래 순서로 끝난다. AI에게 시킬 때도 같은 순서다. ### 1. 앱을 만든다 허용 origin에는 개발 중인 주소를 넣는다. `env`를 생략하면 `development`다. ```sh curl -X POST https://console.chat.gravex.app/api/apps \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "my-chat", "origins": ["http://localhost:5173"], "env": "development"}' ``` 응답은 201이고 **`pk_`와 `sk_` 전체 값이 이 응답에 같이 온다.** ```json { "app": { "id": "0AAS4P1MM0000", "name": "my-chat", "createdAt": "2026-10-25T08:30:00.000Z", "webhook": { "url": null, "beforePublishEnabled": false, "beforePublishOnError": "allow", "stopped": false, "stoppedAt": null, "hasSecret": false }, "limits": { "effective": {}, "overrides": {}, "defaults": {}, "max": {}, "min": {}, "fixed": [] } }, "publishableKey": { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...", "label": null, "allowedOrigins": ["http://localhost:5173"], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" }, "secretKey": { "id": "0AAS4P1MM0002", "appId": "0AAS4P1MM0000", "kind": "sk", "value": "sk_...", "label": null, "allowedOrigins": [], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" } } ``` ### 2. 두 키를 `.env`에 채운다 - `publishableKey.value`(`pk_…`)는 프런트에 들어가는 공개값이다. - `secretKey.value`(`sk_…`)는 **이 응답에서만 보인다.** 콘솔은 저장하지 않는다. 응답을 받은 코드가 곧바로 `.env`에 쓰게 하고, 화면·로그·대화에 찍지 않는다. 놓쳤으면 아래 「키 발급」으로 새 `sk_`를 받고 옛 것을 폐기한다. ```sh # .env VITE_CHAT_PK=pk_... CHAT_SECRET_KEY=sk_... ``` 프런트가 읽는 것은 `VITE_CHAT_PK` 하나다(Vite는 `VITE_`로 시작하는 변수만 브라우저 번들에 넣는다). `CHAT_SECRET_KEY`와 `CHAT_ACCOUNT_KEY`는 서버에서만 읽는다. **Vite의 `envPrefix`를 `CHAT_`로 넓히지 않는다** — `sk_`와 `ck_`가 번들에 실린다. Vite가 아닌 프런트라면 `pk_`에만 그 프레임워크의 공개 접두사를 붙인다(Next.js는 `NEXT_PUBLIC_CHAT_PK`). ### 3. 허용 origin과 웹훅을 설정한다 개발 서버 포트가 바뀌는 식으로 origin을 나중에 바꾸려면 `PATCH /api/apps/{id}/keys/{keyId}`(아래 「키」), 웹훅은 `PATCH /api/apps/{id}/webhook`(아래 「웹훅」)이다. 웹훅 주소를 처음 넣는 응답에는 서명 시크릿(`secret`)이 한 번 온다. `.env`의 `CHAT_WEBHOOK_SECRET`에 바로 쓴다. ### 4. 그다음은 레퍼런스 앱과 키가 생겼으면 이 API의 일은 끝이다. 채팅을 붙이는 법은 [시작하기](https://chat.gravex.app/docs)와 [서버 연동](https://chat.gravex.app/docs/server), 방·메시지·유저를 다루는 서버 호출은 [REST API 레퍼런스](https://chat.gravex.app/docs/rest-api), 실시간 접속은 [실시간 프로토콜](https://chat.gravex.app/docs/realtime)을 따른다. 방은 백엔드가 `sk_`로 만든다(`PUT /v1/rooms/{key}`). ## 라우트 한눈에 경로의 `{id}`는 앱 id(`app.id`), `{keyId}`는 키 id(`id`)다. 모든 라우트가 계정 키로 된다. | 라우트 | 하는 일 | |---|---| | `GET /api/apps` | 내 앱 목록 | | `POST /api/apps` | 앱 만들기(`pk_`·`sk_` 함께 발급) | | `GET /api/apps/{id}` | 앱 하나와 통계 | | `PATCH /api/apps/{id}` | 환경 라벨(`env`) 바꾸기 | | `DELETE /api/apps/{id}` | 앱 삭제(키 폐기·접속 종료) | | `POST /api/apps/{id}/restore` | 삭제한 앱 복구(새 `pk_`·`sk_`) | | `GET /api/apps/{id}/purge` | 영구 삭제 작업 진행 조회 | | `POST /api/apps/{id}/purge` | 삭제한 앱의 데이터 영구 삭제 | | `GET /api/apps/{id}/keys` | 앱의 키 목록 | | `POST /api/apps/{id}/keys` | `pk_`·`sk_` 새로 발급 | | `PATCH /api/apps/{id}/keys/{keyId}` | `pk_`의 허용 origin 교체 | | `DELETE /api/apps/{id}/keys/{keyId}` | 키 폐기(`?purge=true`면 폐기된 키 삭제) | | `PATCH /api/apps/{id}/webhook` | 웹훅 주소·`before_publish` 설정, 시크릿 교체 | | `POST /api/apps/{id}/webhook/resume` | 멈춘 웹훅 배달 재개 | | `PATCH /api/apps/{id}/limits` | 앱 한도 변경 | | `GET /api/apps/{id}/users` | 유저 목록 | | `DELETE /api/apps/{id}/users/{userId}` | 탈퇴한 유저의 영구 삭제(`?purge=true` 필수) | | `PUT /api/apps/{id}/users/{userId}/ban` | 유저 밴 | | `DELETE /api/apps/{id}/users/{userId}/ban` | 밴 해제 | | `DELETE /api/apps/{id}/users/{userId}/messages` | 유저가 보낸 메시지 모두 거두기 | | `GET /api/apps/{id}/rooms` | 방 목록 | | `DELETE /api/apps/{id}/rooms/{roomId}` | 방 영구 삭제(`?purge=true` 필수) | | `GET /api/apps/{id}/rooms/{roomId}/messages` | 방의 메시지 조회·검색 | | `DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId}` | 메시지 하나 삭제 | | `GET /api/apps/{id}/export` | 앱 전체 데이터 내려받기(NDJSON) | ## 앱 ### `GET /api/apps` ```json { "apps": [ { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" } ] } ``` 삭제한 앱은 빠진다. 최신순이다. ### `POST /api/apps` | 필드 | 필수 | 설명 | |---|---|---| | `name` | 예 | 1~100자 | | `origins` | 아니오 | `pk_`를 쓸 수 있는 페이지의 origin 배열. `https://example.com`처럼 경로 없이, 스킴·호스트·포트가 정확히. 최대 50개 | | `env` | 아니오 | `production` 또는 `development`(기본). 콘솔 목록에 붙는 라벨일 뿐 엔진은 똑같이 동작한다. 운영과 개발은 앱을 따로 만든다 | 201과 위 「앱 만들고 키 받기」의 응답. 계정당 살아 있는 앱은 10개이고 넘으면 409 `limit_reached`다. ### `GET /api/apps/{id}` ```json { "app": { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" }, "stats": { "users": 12, "rooms": 3, "messages": 480, "activeUsers7d": 5, "activeUsers30d": 9, "bots": 0, "activeBots7d": 0, "activeBots30d": 0, "botsIncluded": false, "approximate": [], "webhook": { "pending": 0, "oldestPendingSeconds": null, "stopped": false } } } ``` 통계는 봇을 뺀 값이 기본이다. 엔진이 못 주는 숫자는 `null`이고 0으로 읽지 않는다. `approximate`에 든 필드는 근사치다. ### `PATCH /api/apps/{id}` 본문 `{ "env": "production" }`. 바뀐 `app`(위 `GET`의 `app`과 같은 모양)을 돌려준다. 엔진을 부르지 않는 콘솔 라벨 변경이다. ### `DELETE /api/apps/{id}` 앱을 소프트 삭제한다. 엔진이 키를 모두 폐기하고 접속을 끊고 웹훅을 멈춘다. 데이터는 남는다. ```json { "appId": "0AAS4P1MM0000", "name": "my-chat", "deletedAt": "2026-10-25T09:00:00.000Z" } ``` 엔진 호출이 중간에 실패해도 다시 보내면 된다. ### `POST /api/apps/{id}/restore` **삭제한 앱에만** 된다(살아 있는 앱·남의 앱은 404). 옛 키는 되살아나지 않고 **새 `pk_`·`sk_`가 응답에 한 번** 온다. 응답은 `POST /api/apps`와 같은 모양이다. 영구 삭제가 이미 시작된 앱은 409다. ### `GET /api/apps/{id}/purge`, `POST /api/apps/{id}/purge` 삭제한 앱의 데이터(유저·방·메시지)를 **영구히** 지우는 작업이다. 두 라우트 다 삭제한 앱에만 된다. - `POST`는 작업을 큐에 넣고 202 `{ "job": PurgeJob }`이다. 되돌릴 수 없다. - `GET`은 이 앱의 작업을 새것부터 `{ "jobs": [PurgeJob] }`로 준다. ```json { "job": { "id": "...", "kind": "app", "targetId": "0AAS4P1MM0000", "state": "pending", "requestedAt": "2026-10-25T09:05:00.000Z", "startedAt": null, "finishedAt": null, "error": null, "deleted": {}, "attempts": 0 } } ``` `state`는 `pending` → `running` → `done`이다. `failed`는 끝이 아니다. 5분 뒤 다시 집는다. ## 키 `pk_`는 목록에서 전체 값이 보이고 `sk_`는 마스킹된다(`sk_…abcd`). **`sk_` 전체는 발급 응답에서만** 보인다. 앱마다 종류별로 활성 키 5개까지다. ### `GET /api/apps/{id}/keys` ```json { "keys": [ { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...", "label": null, "allowedOrigins": ["http://localhost:5173"], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" } ] } ``` ### `POST /api/apps/{id}/keys` 본문 `{ "kind": "pk" | "sk", "origins": [...] }`. `origins`는 `pk`에만 의미가 있다. 201과 키 하나(위 `publishableKey`와 같은 모양)이고, `sk`면 `value`에 전체 값이 온다. 종류별 상한을 넘으면 409 `limit_reached`. ### `PATCH /api/apps/{id}/keys/{keyId}` `pk_`의 허용 origin을 통째로 바꾼다. 본문 `{ "origins": ["https://example.com", "http://localhost:5173"] }`. 빈 배열도 된다 (어떤 브라우저도 허용하지 않는다). 바뀐 키를 돌려준다. 하나를 더하거나 빼려면 `GET /api/apps/{id}/keys`로 지금 목록을 읽어 고친 목록 전체를 보낸다 — 보내지 않은 origin은 지워진다. ### `DELETE /api/apps/{id}/keys/{keyId}` 키를 폐기한다. 바로 효력이 있고 되돌릴 수 없다. 폐기된 키를 돌려준다(`revokedAt`이 채워진다). **같은 종류의 마지막 키를 폐기하기 전에 새 키를 발급해 배포한다.** `?purge=true`를 붙이면 **폐기된 키의 기록을 지운다.** 살아 있는 키에는 쓸 수 없고, 응답은 204이며, 한 번 지우면 다시 부를 수 없다(404). `purge` 값은 `true` 또는 `false`만 받는다. ## 웹훅 ### `PATCH /api/apps/{id}/webhook` 언급한 필드만 바꾼다. 세 가지 지시를 한 라우트가 받는다. | 본문 | 효과 | |---|---| | `{ "url": "https://example.com/chat-webhook" }` | 받을 주소 설정. https 주소 | | `{ "url": null }` | 웹훅 끄기 | | `{ "beforePublishEnabled": true, "beforePublishOnError": "deny" }` | `before_publish` 켜기와 훅 실패 시 동작(`allow` 또는 `deny`) | | `{ "rotateSecret": true }` | 서명 시크릿 교체 | ```json { "url": "https://example.com/chat-webhook", "beforePublishEnabled": false, "beforePublishOnError": "allow", "stopped": false, "stoppedAt": null, "hasSecret": true, "secret": "..." } ``` **`secret`은 시크릿이 새로 만들어진 이 응답에서만 온다** — 시크릿이 없던 앱에 처음 `url`을 넣을 때와 `rotateSecret`일 때. 조회로는 다시 볼 수 없다. 받자마자 `.env`의 `CHAT_WEBHOOK_SECRET`에 쓰고 로그에 남기지 않는다. 이벤트와 서명 검증은 [웹훅](https://chat.gravex.app/docs/webhooks)을 본다. ### `POST /api/apps/{id}/webhook/resume` 드레이너가 포기해서 멈춘 배달(`stopped: true`)을 다시 돌린다. 본문은 없다. 멈추지 않은 앱에 보내도 200이다. 위와 같은 모양의 설정을 돌려준다. ## 한도 ### `PATCH /api/apps/{id}/limits` 본문은 `{ "필드": 정수 | null }`이다. `null`은 기본값으로 되돌린다. 한 필드라도 틀리면 아무것도 바꾸지 않는다. ```sh curl -X PATCH https://console.chat.gravex.app/api/apps/$APP_ID/limits \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \ -H "Content-Type: application/json" \ -d '{"publishPerSecond": 3}' ``` ```json { "rows": [ { "field": "publishPerSecond", "group": "rate", "effective": 3, "default": 5, "max": 100, "min": 1, "override": 3, "ceiling": 5, "ceilingSet": false, "ownerUpper": 5, "ownerCanReset": true } ] } ``` 응답은 바뀐 필드의 행(`rows`)이다. `group`은 그 필드를 누가 바꾸는지를 말한다. - 필드 이름과 지금 값은 `POST /api/apps`의 `app.limits`에 있다. - 속도·크기 계열(`group`이 `rate`·`size`)은 이 앱의 콘솔 상한(`ownerUpper`) 이하로만 올릴 수 있다. 운영자가 상한을 따로 정하지 않았으면 엔진 기본값이 상한이라, 기본값보다 높이려면 운영자에게 요청해야 한다. 정책 계열(`policy`)은 자유롭게 바꾼다. 운영 계열(`ops`)과 고정 필드(`fixed`), 모르는 필드는 400 `invalid_request`이고 응답의 `field`가 그 필드다. - 상한을 넘기면 400 `limit_above_ceiling`, 최솟값 아래면 400 `limit_below_min`이고 `limit`에 그 값이 온다. - 바뀐 값이 닿는 시점은 필드마다 다르다. 이어진 연결에는 30초 안에, 어떤 필드는 다음 접속부터 닿는다. - 실효 한도는 [REST API 레퍼런스](https://chat.gravex.app/docs/rest-api#한도)와 [실시간 프로토콜](https://chat.gravex.app/docs/realtime#한도)의 한도와 같은 값이다. ## 조회 유저·방·메시지는 **읽기 전용**이다. 만들거나 고치려면 `sk_`로 REST를 부른다. ### `GET /api/apps/{id}/users` ```json { "items": [ { "externalId": "u_42", "name": "한나", "avatarUrl": null, "lastSeenAt": "2026-10-25T08:00:00.000Z", "deletedAt": null, "createdAt": "2026-10-01T00:00:00.000Z", "bannedUntil": null, "isBot": false, "lastUserAgent": "Mozilla/5.0 ..." } ], "cursor": "abc", "botsSupported": true } ``` 한 페이지 50명이다. `cursor`가 `null`이면 마지막 페이지이고, 아니면 그 값을 `?cursor=`에 그대로 넣어 다음 페이지를 받는다. 필터는 쿼리로 건다: `q`(이름 검색), `status`(`live`·`withdrawn`·`all`), `bots`(`include`·`exclude`·`only`), `last_seen_after`·`last_seen_before`·`created_after`·`created_before`(`YYYY-MM-DD`). 필터를 바꾸면 커서를 버리고 첫 페이지부터 읽는다. ### `GET /api/apps/{id}/rooms` ```json { "items": [ { "id": "0AAS4P1MM0010", "key": "lobby", "type": "public", "name": "로비", "lastSeq": 128, "createdAt": "2026-10-25T08:31:00.000Z" } ], "cursor": null } ``` `public`·`channel`·`private`·`dm` 모두 나온다. 페이지는 유저와 같다(50개, `?cursor=`). ### `GET /api/apps/{id}/rooms/{roomId}/messages` ```json { "messages": [ { "id": "0AAS4P1MM0020", "seq": 128, "kind": "text", "sender": { "id": "u_42", "name": "한나" }, "text": "안녕", "attachmentCount": 0, "threadId": null, "createdAt": "2026-10-25T08:40:00.000Z", "editedAt": null, "deletedAt": null } ], "hasMore": false } ``` 기본은 가장 최근 50건이다. `?before=`는 그보다 오래된 한 페이지, `?after=`는 그보다 새 메시지를 오래된 것부터다. `?q=검색어`는 그 방 안의 본문 검색이고 결과의 `seq`는 이어지지 않는다. `after`는 `before`·`q`와 함께 쓸 수 없다(400). ### `GET /api/apps/{id}/export` 앱의 유저·방·멤버·메시지·리액션·읽음 위치·첨부 메타데이터를 NDJSON 한 파일로 내려받는다(첨부 파일 자체는 없다). 스냅숏이 아니고 이어받기는 없다. **마지막 줄이 `{"type":"end", …}`가 아니면 불완전한 파일이다.** 앱당 하나만 동시에 돌 수 있다 (409 `export_in_progress`). 형식은 [REST API 레퍼런스](https://chat.gravex.app/docs/rest-api)의 `GET /v1/export`와 같다. ```sh curl https://console.chat.gravex.app/api/apps/$APP_ID/export \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" -o export.ndjson ``` ## 모더레이션과 영구 삭제 ### `PUT /api/apps/{id}/users/{userId}/ban`, `DELETE /api/apps/{id}/users/{userId}/ban` `userId`는 서비스가 정한 유저 id(`externalId`)다. 밴 본문은 `{ "until": 1793000000000, "reason": "스팸" }`이다. `until`은 **필수**이고 UTC 에포크 밀리초다(무기한이면 아주 먼 미래). `reason`은 선택이다. 둘 다 204이고, 밴 해제는 밴이 없어도 204다. ### `DELETE /api/apps/{id}/users/{userId}/messages` 그 유저가 보낸 살아 있는 메시지를 모두 지운 표시(tombstone)로 바꾼다. 탈퇴시키지 않는다. ```json { "purgedMessages": 40, "purgeCapped": true } ``` 한 번에 거두는 양에 상한이 있다. `purgeCapped`가 `true`면 남았다는 뜻이니 `false`가 될 때까지 다시 부른다. ### `DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId}` 메시지 하나를 지운 표시로 바꾼다. 204이고 멱등이다. 행은 남고 `seq`는 움직이지 않는다. ### `DELETE /api/apps/{id}/users/{userId}?purge=true`, `DELETE /api/apps/{id}/rooms/{roomId}?purge=true` 탈퇴한 유저의 행, 또는 방과 그 메시지를 **영구히** 지우는 작업을 큐에 넣는다. 202 `{ "job": PurgeJob }`이다(모양은 앱 영구 삭제와 같다). `?purge=true`가 없으면 400이다. 되돌릴 수 없다. 원문: https://chat.gravex.app/docs/console-api --- # @kispi/chat 채팅 서버의 클라이언트 SDK. 소켓 하나에 방 여러 개, 순서가 맞는 히스토리. **런타임 의존성 0개**, ESM과 CJS 둘 다, 타입 선언 포함. 진입점이 둘이다. `@kispi/chat`은 브라우저와 Node에서 쓰는 클라이언트, `@kispi/chat/server`는 `sk_`를 들고 도는 소비자 백엔드용이다. ```sh npm install @kispi/chat ``` 이 문서가 **표면 전체**를 적는다. 여기 없는 것은 내부 구현이고, 다음 버전에 말 없이 바뀐다. --- ## `@kispi/chat` — 클라이언트 ### `createChatClient(options): ChatClient` | 옵션 | 타입 | | |---|---|---| | `key` | `string` | **필수.** `pk_`. 공개값이라 브라우저에 넣어도 된다 | | `token` | `() => string \| Promise` | **필수.** 백엔드가 서명한 유저 토큰. 접속할 때마다, 그리고 REST가 401을 받을 때 다시 부르므로 만료된 것이 재사용되지 않는다. 던지면 백오프로 다시 부르고, **`ChatError`를 던지면 다시 부르지 않는다**(아래) | | `WebSocket` | `new (url) => WebSocketLike` | 전역 대신 쓸 구현 | | `fetch` | `typeof fetch` | 위와 같음 | `token`을 브라우저에서 만들지 않는다. 서명에 `sk_`가 필요하고, `sk_`가 브라우저에 있으면 그 앱 전체가 열린다. ### `ChatClient` | 프로퍼티 | | |---|---| | `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` | | `user` | 접속한 사람. `{id, name, avatar?}` — **서버가 토큰에서 받아들인 신원**(hello가 준 것)이다. 토큰 응답을 따로 파싱하지 말고 이것을 쓴다. `connect()`/`reconnect()`가 끝나면 채워져 있다 | | `connectionId` | 지원 문의와 강제 종료에 쓰는 식별자 | | `hello` | 서버가 접속 때 준 것 전부. `limits`(실효 한도), `unread`(`{total, mentions, roomsCapped?}`, **없는 것은 0이 아니라 '못 셌다'이니 `unread()`로 복구한다**), `warnings`, `serverTime`, `heartbeatMs` | | 메서드 | | |---|---| | `connect()` | 붙는다. 이미 붙어 있거나 붙는 중이면 그 시도에 합류한다. 실패하면 거절하지만 **재시도는 백그라운드에서 계속된다**(아래) | | `close()` | 끊는다. 이건 재접속하지 않는다. 다시 붙으려면 `connect()` | | `reconnect()` | **다시 인증한다**: `token()`을 다시 불러 소켓을 바꾼다. 방 핸들과 구독, `messages`는 그대로이고(**사람이 바뀌면 `messages`는 버리고 다시 채운다**) 상태는 `open → reconnecting → open`이다(`closed`를 거치지 않는다). 닉네임·아바타를 바꾼 뒤, 그리고 **로그인·로그아웃으로 사람이 바뀔 때** 쓴다(아래) | | `room(roomId)` | 방 핸들. 같은 id면 같은 객체 | | `roomByKey(key)` | 키로 여는 방 핸들. 구독할 때 서버가 id를 알려 준다. **방을 만들지 않는다** — 없는 키면 `subscribe()`가 `not_found`로 거절한다. 방은 백엔드가 `rooms.ensure()`로 만든다 | | `rooms.list({cursor?, limit?})` | 내가 멤버인 방 + unread + 마지막 메시지 | | `rooms.discover({type?, cursor?, limit?})` | 공개 방 탐색 | | `rooms.members(roomId).list/add/remove` | 멤버 관리 | | `unread()` | `{total, mentions, roomsCapped?, rooms}`. `chat.hello?.unread`와 **같은 이름**이다 | | `updateMe(meta)` | 내 `meta`. **`name`과 `avatar`는 토큰 claim에서 온다** | | 이벤트 | | |---|---| | `state` | 위 네 값 | | `error` | 클라이언트가 스스로 `closed`로 멈췄고 그 이유(`ChatError`). 인증 거절이나 `token()`의 `ChatError`. `close()`로 닫을 때는 없다 | | `notification` | 멤버인 방에 새 메시지가 왔는데 그 방을 보고 있지 않을 때 | | `user.presence` | 내 다른 접속의 presence 변화 | | `frame` | 받은 프레임 전부. 디버깅용 | `on`은 해지 함수를 돌려준다. ```ts const off = chat.on('state', s => setStatus(s)) off() ``` ### `Room` | 프로퍼티 | | |---|---| | `messages` | seq 오름차순. **정렬과 구멍 메우기는 SDK가 한다.** 바뀔 때마다 새 배열이다. **스레드 답글도 섞여 있다**(`threadId`가 있는 행) — 본 타임라인만 그리려면 `threadId`가 없는 행만 고른다 | | `id` / `key` | 방 식별자. 키로 열었으면 구독 전까지 `id`가 없다 | | `lastSeq` | 서버가 알려 준 방의 마지막 seq | | `presence` | `{count, users?, capped?}` — 지금 이 방에 누가 있나. 구독 ack와 `presence` 이벤트의 증분을 **SDK가 합쳐 둔 값**이다. `users`는 **100명 이하 방에서만** 있고, 그보다 크거나 모르면 없다 — 그때는 `presenceList()` | | `hasOlder` | `loadOlder()`로 더 읽을 과거가 있는지. 첫 `loadOlder()` 전에 "이전 메시지" 버튼을 그릴지 정할 때 쓴다. `loadOlder()`의 `hasMore`와 같은 판정이고, 목록이 비었으면 `false`. `messages` 이벤트 때 다시 읽는다 | | 메서드 | | |---|---| | `subscribe()` | 라이브 피드를 켜고 최근 100건을 읽는다. 이미 따라잡혔으면 아무것도 안 한다. **연결 전에 불러도 된다** — 붙을 때까지 기다린다(`close()`면 `closed`로 거절) | | `unsubscribe()` | 이 방 보기를 그만둔다. 재접속해도 다시 구독하지 않는다 | | `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | 발행. ack로 `{messageId, seq}`. 구독이 진행 중이면 그것을 기다린다. **재접속 중이면 붙을 때까지 기다렸다가 보낸다**(아래) | | `loadOlder({limit?})` | 가장 오래된 메시지 앞 페이지(기본 100)를 읽어 **`messages` 앞에 붙인다.** `{messages, hasMore}`. 스크롤을 올릴 때 쓴다. 목록이 비어 있으면(구독 전) 아무것도 읽지 않는다 | | `history({before?, after?, limit?, view?})` | 과거를 직접 읽는다. **`messages`와 따로 논다.** `view`는 `'main'`, `'all'`, `{thread: id}` | | `reload()` | 최근 페이지를 다시 읽는다 | | `react(messageId, emoji)` / `unreact(...)` | 리액션. 멱등. **프레임보다 먼저** 그 행의 `myReactions`를 고치고, 서버가 거절하면 되돌린다. 개수는 낙관적으로 올리지 않고 **`reaction.*` 이벤트가 올 때 서버 값으로 고친다**(아래) | | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | 누가 눌렀는지. **한 행은 (유저, 이모지) 쌍**이라 한 사람이 여러 줄일 수 있다. `limit` 기본 50 최대 100, 마지막 페이지의 `cursor`는 빈 문자열 | | `markRead(seq, {threadId?})` | 읽음 커서 | | `reads({userId?, cursor?, limit?})` | 읽음 커서 목록. `{reads: [{userId?, seq, threadId?, withdrawn?}], cursor}`. **`userId`로 한 사람만** — 자기 스레드별 커서를 되찾을 때 `reads({userId: chat.user.id})`. `cursor`가 `''`이면 끝 | | `typing({threadId?})` | 입력 중. 던지지 않는다. **입력 이벤트마다 불러도 된다** — SDK가 방마다 1초에 한 번만 보낸다. 스레드 답글을 쓰는 중이면 `threadId` | | `join()` / `leave()` | 멤버십 | | `presenceList()` | 지금 이 방에 있는 사람 | 이벤트 이름은 **와이어의 이름 그대로**이고, 페이로드는 타입 선언에 있다. | 이벤트 | 페이로드 | |---|---| | `message.created` / `message.updated` | `Message`. `created`에는 `thread`가 없고 `reactions`가 비어 있으며, `myReactions`는 **SDK가 `[]`로 채운다**(와이어에는 없다) | | `message.deleted` | `{id, seq}`. `seq`는 그 메시지가 **발행될 때** 받은 값이다 | | `reaction.added` / `reaction.removed` | `{messageId, emoji, count, userId?}`. SDK가 `messages` 안의 그 행의 `reactions` 개수를(내 것이면 `myReactions`도) 이미 고쳐 두었다. `history()`로 따로 읽은 행처럼 **목록 밖의 행은 소비자가 고친다** | | `thread.updated` | `{rootId, count, lastSeq?, lastAt?}`. SDK가 `messages` 안의 **루트 행 `thread`**(`count`·`lastSeq`·`lastAt`)를 이미 고쳐 두었다. 목록 밖의 루트는 소비자가 고친다 | | `presence` | `{count, joined?, left?, users?, capped?}`. `joined`/`left`는 증분, `users`는 교체다 | | `typing` | `{userId, threadId?}` — `threadId`는 스레드 **루트** id, 본 타임라인이면 없다 | | `read` | `{userId, seq, threadId?}` | | `member.joined` / `member.left` | `{userId, role?}` | | `room.updated` | 방 객체(`unknown` — 서버 표현 그대로다) | | `room.deleted` | `{roomId}` | | `custom` | `rooms.custom(roomId, payload)`이 보낸 객체 그대로 | 와이어에 대응물이 없는 셋을 SDK가 더 낸다. | 이벤트 | 언제 | |---|---| | `messages` | 목록이 바뀔 때마다. **매번 새 배열이고, 내보낸 배열은 다시 건드리지 않는다** | | `error` | 히스토리를 읽거나 구멍을 메우는 데 실패했다. **방이 스스로 다시 구독한다.** 사람이 누를 재시도 버튼은 `subscribe()` | | `reset` | 목록을 버리고 다시 채웠다 — 커서가 보존 기간보다 오래됐거나, **다른 사람으로 다시 붙었다**(아래). **렌더한 것을 버려야 한다** | ### 알아 둘 것 **`messages`는 매번 새 배열이고, 바뀐 행만 새 객체다.** 참조로 비교하는 상태에 그대로 넣는다. ```ts // Svelte 5 let messages = $state.raw([]) room.on('messages', m => (messages = m)) // React const [messages, setMessages] = useState([]) useEffect(() => room.on('messages', setMessages), [room]) // Vue const messages = shallowRef([]) room.on('messages', m => (messages.value = m)) ``` - **`reset`을 무시하면 안 된다.** 옛 목록에 이어 붙이면 아무도 메우지 않는 구멍이 남는다. - **과거는 `loadOlder()`로 같은 목록에 붙인다.** `history()`는 목록을 건드리지 않는 직접 읽기이므로 합치고 중복을 거르는 일이 소비자 몫이 된다. - **리액션 칩은 `reactions`와 `myReactions`를 합쳐 그린다.** `react()`는 `myReactions`를 먼저 옮기고 개수는 `reaction.added`를 기다리므로, 처음 다는 이모지는 잠깐 `myReactions`에만 있다. 그 칩은 **강조한 이모지만**(개수 없이) 그리고, `1`을 지어내지 않는다 — 그 사이 남이 눌렀으면 틀린 숫자다. - **이모지 종류에는 메시지당 상한이 있다**(`hello.limits.maxDistinctEmojiPerMessage`, 기본 20). 넘는 새 이모지는 `invalid`로 거절되고 SDK가 `myReactions`를 되돌린다. 이미 달린 이모지를 누르는 것은 상한과 무관하다. 피커를 막으려면 `reactions.length`가 상한에 닿았고 그 이모지가 없을 때 비활성화한다. - **스레드 패널은 두 곳을 합친다.** `messages`에는 SDK가 읽은 구간의 답글만 있다(방 전체 스트림이라 오래된 스레드의 답글은 없다). 지난 답글은 `history({view: {thread: rootId}})`로 읽고, 새 답글은 `messages`에서 `threadId === rootId`로 골라 **id로 합친다.** `history()`로 읽은 행은 목록 밖이라 `reaction.added`/`removed`(개수, 내 `userId`면 `myReactions`)와 `message.updated`·`message.deleted`를 **소비자가 직접 반영한다** — 빠뜨리면 지난 답글만 리액션이 멈춰 보인다. `reset`이면 다시 읽는다. - **`typing`은 보낸 사람에게도 온다.** "X가 입력 중"을 그린다면 자기 `userId`를 건너뛴다. - **`typing`은 이름을 싣지 않는다.** 이름은 `room.presence.users`에서 찾는다 — 방을 보고 있는 사람이 거기 있다. 메시지 발신자에서 이름을 모으면 아직 말하지 않은 사람을 모른다. `users`가 없으면(100명 넘는 방) `presenceList()`로 한 번 받아 오고, 그래도 없으면 대체 이름을 쓴다. - **`typing`은 한 사람당 방마다 많아야 3초에 한 번 온다.** "입력 멈춤" 이벤트는 없으니 표시는 마지막 이벤트 뒤 **5초 이상** 유지한다 — 계속 입력하는 사람의 방송 간격도 4초를 넘을 수 있어서, 그보다 짧으면 이벤트 사이에서 깜빡인다. - **방마다 한 번에 알리는 사람 수에 상한이 있다**(지금 3명). 방송 한 번이 인원수만큼 나가기 때문이고, 화면도 "A, B, C가 입력 중" 정도까지만 그린다. 붐비는 방에서는 입력 중인데 알려지지 않는 사람이 있다 — 그 숫자에 기대는 코드는 짜지 않는다(엔진이 바꿀 수 있다). ```ts room.on('typing', ({ userId }) => { if (userId === chat.user?.id) return const name = room.presence?.users?.find(u => u.id === userId)?.name showTyping(userId, name ?? '누군가') // 5초쯤 뒤 지우는 것은 소비자 몫이다 }) ``` - **연결은 알아서 돌아온다.** 끊기면 지수 백오프로 다시 붙으니 재시도 루프를 짜지 않는다. - **재접속 중에 보낸 것은 기다렸다가 나간다.** `send`·`react`·`unreact`·`markRead`는 상태가 `connecting`/`reconnecting`이면 백오프를 건너뛰고 붙기를 기다려 보낸다. ack 전에 소켓이 끊기면 **같은 프레임을 한 번 더** 보낸다 — 서버가 `clientMessageId`로 중복을 거르고 리액션·읽음은 멱등이라 두 번 반영되지 않는다. 대기와 ack를 합쳐 15초가 넘으면 `timeout`이다. `closed`는 이제 **멈춘 클라이언트**(`close()`, 인증 거절, 한 번도 `connect()`하지 않음)에서만 온다. `typing()`은 기다리지 않고 버린다. - **토큰 만료는 SDK가 처리한다.** REST가 401을 받으면 `token()`을 다시 불러 한 번 재시도한다. 주기적으로 재접속할 필요가 없다. - **`token()`이 `ChatError`를 던지면 멈춘다.** 밴처럼 다시 해도 소용없는 실패를 그렇게 알린다. - **닉네임·아바타를 바꿨으면 `reconnect()`.** 서버는 그 값을 접속할 때 토큰 claim에서 한 번 읽는다. - **로그인·로그아웃도 `reconnect()`다.** `token()`이 다른 사람을 돌려주면 SDK가 알아챈다 — 방마다 들고 있던 목록을 버리고 새 사람의 눈으로 최근 히스토리를 다시 읽어 `reset`을 낸다 (행의 `myReactions`가 이전 사람의 것이었다). 이전 사람으로 기다리던 전송·리액션·읽음은 새 사람 이름으로 나가지 않고 `closed`로 거절되므로, 새 사람의 전송은 `reconnect()`를 기다린 뒤에 한다. `close()` 뒤의 `connect()`도 같다. 클라이언트를 새로 만들 필요는 없다. 새 사람으로 다시 구독할 수 없는 방(이전 사람만 멤버였던 private 방)은 `reset` 대신 `error`이고 목록은 빈 채로 남는다. **로그인 상태가 바뀌면 반드시 `reconnect()`를 부른다** — REST가 401에서 토큰을 갱신하는 길은 사람이 바뀌었는지 보지 않는다. ### `text`는 신뢰할 수 없는 평문이다 메시지 `text`와 유저가 넣는 표시 이름·닉네임(`name`)은 **엔진이 HTML 이스케이프하거나 정제하지 않은 평문**이다. 의도적이다 — 이스케이프는 렌더링하는 출력 컨텍스트(HTML 텍스트 노드인지, 속성인지, 다른 포맷인지)에 달려 있어 엔진이 대신 정할 수 없다. 엔진은 크기·형식 같은 **검증**만 한다. 그래서 소비자가 렌더할 때 텍스트로 다뤄야 한다 — `textContent`나 프레임워크의 텍스트 바인딩(React의 `{text}`, Svelte의 `{text}`, Vue의 `{{ text }}`). **`innerHTML`이나 `v-html`에 그대로 꽂으면 안 된다.** 링크 자동 인식이나 마크업을 더하고 싶으면 문자열을 HTML로 만들어 정규식으로 치환하지 말고, 텍스트 노드를 만들어 DOM에 직접 붙인다. ```ts // 하면 안 된다 — 정규식으로 HTML 문자열을 만든다 el.innerHTML = text.replace(urlRegex, '$1') // 이렇게 — 텍스트 노드로 DOM을 만든다 for (const part of splitByUrl(text)) { el.appendChild(part.isUrl ? makeAnchor(part.text) : document.createTextNode(part.text)) } ``` 욕설 같은 금칙어 처리는 이스케이프가 아니라 **테넌트 정책**이다 — 엔진은 `before_publish` 훅이라는 기전만 열어 주고, 판정은 소비자가 한다(웹훅 문서의 `before_publish` 절). ### 브라우저에서 쓸 때 `pk_`의 허용 오리진에 **페이지의 오리진을 넣어야 한다.** 이것 하나다 — 서버가 CORS 헤더를 직접 내므로 프록시를 건드릴 일은 없다. 오리진은 콘솔의 키 화면에서 편집한다. 스킴·호스트·포트가 **정확히** 맞아야 한다. `https://example.com`과 `https://www.example.com`, `http://localhost:3000`과 `http://127.0.0.1:3000`이 서로 다른 오리진이다. 개발용은 따로 넣는다. 목록에 없는 오리진에서는 REST만이 아니라 **접속부터 401이다.** 인증 거절은 SDK가 다시 시도하지 않으므로 클라이언트가 `closed`에서 멈추고, 이유는 `chat.on('error')`로 온다. **Node(봇·테스트)에서는 오리진을 넣지 않는다.** 서버는 `Origin` 헤더가 **없는** 요청을 오리진 검사 없이 받는다 — Node의 전역 `WebSocket`·`fetch`는 그 헤더를 보내지 않으므로 그대로 붙는다. 헤더를 직접 붙이면 그 값은 허용 오리진에 있어야 한다. 오리진 목록은 남의 사이트가 방문자의 브라우저에서 당신의 `pk_`를 쓰는 것만 막는다(`pk_`는 공개값이다). 유저 토큰은 어디서나 필요하다. ### 에러 실패는 `ChatError`다. ```ts try { await room.send({ text }) } catch (err) { if (err instanceof ChatError && err.code === 'rate_limited') { retryAfter(err.retryAfterMs) } } ``` | 필드 | | |---|---| | `code` | 알려진 값의 유니온(`ChatErrorCode`)이라 오타가 컴파일에서 잡힌다. 서버: `unauthorized`, `forbidden`, `not_found`, `invalid`, `too_large`, `rate_limited`, `banned`, `moderation_denied`, `cursor_too_old`, `quota_exceeded`, `internal`. SDK: `timeout`, `closed`. 서버가 새 코드를 내도 좁혀 놓은 타입이 막지 않는다 | | `retryAfterMs` | 한도에 걸렸을 때 서버가 알려 준 대기 시간 | | `appCode` | `before_publish` 웹훅이 거절하며 붙인 소비자 쪽 코드 | | `status` | REST 호출에서 난 에러면 HTTP 상태 | `quota_exceeded`는 앱의 저장 쿼터(`limits.maxStoredMessages`)가 찼다는 뜻이다. 새 메시지만 거절되고(REST는 403) 편집·삭제·리액션은 된다. 메시지를 지워도 공간은 비지 않는다(삭제된 메시지도 센다) — 풀리는 길은 앱 운영자가 쿼터를 올리거나 방을 purge하는 것이다. 기다려서 풀리지 않으므로 `retryAfterMs`가 없고 SDK도 다시 보내지 않는다 — 재시도 루프를 짜지 말고 "저장 공간이 찼다"를 보여 준다. --- ## `@kispi/chat/server` — 백엔드 `sk_`를 쓴다. **브라우저에 넣지 않는다.** ```ts import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, // sk_… webhookSecret: process.env.CHAT_WEBHOOK_SECRET, guestSecret: process.env.CHAT_GUEST_SECRET, // guest()를 쓸 때만 }) ``` ### `chat.token(input): string` 네트워크를 타지 않는 로컬 서명이다. | 필드 | | |---|---| | `userId` | **필수.** JWT `sub`가 된다. 당신 서비스의 유저 id | | `name` | **필수.** 접속할 때마다 이 값이 이긴다 | | `avatar`, `meta` | 선택 | | `ttlSeconds` | 기본 1시간, 최대 24시간. 넘으면 자르지 않고 거절한다 | ### `chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})` 로그인하지 않은 방문자의 신원. `{userId, credential, token, created}`를 돌려준다. 네트워크도 저장소도 쓰지 않는다. ```ts app.post('/api/chat-token', (req, res) => { if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) }) const g = chat.guest({ credential: req.cookies.chat_guest, name: '손님' }) res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 }) res.json({ token: g.token }) }) ``` **브라우저가 돌려준 `userId`를 그대로 서명하지 않는다.** `guest()`는 브라우저가 id 대신 **자격증명**을 들게 한다 — 검증되면 그 안의 id로, 없거나 틀리면 새 게스트(`created: true`)로 서명한다. 가능하면 `httpOnly` 쿠키에 둔다. | | | |---|---| | `guestSecret` | 32자 이상. 백엔드에만 둔다. **`sk_`에서 유도하지 않는다** | | 교체 | 배열로 준다: `[새것, 옛것]`. 첫 번째로 서명하고 전부로 검증한다. 옛것으로 검증된 자격증명은 같은 `userId`로 새로 서명해 돌려주므로 다음 방문에 옮겨 간다 | 게스트 id는 `g_`로 시작한다. 로그인 유저의 id를 `g_`로 시작하지 않게 하면 겹치지 않는다. ### 나머지 | 그룹 | 메서드 | |---|---| | `rooms` | `list({type?, cursor?, limit?})`, `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` | | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` | | `users` | `list({cursor?, limit?})`, `update(userId, {name?, avatar?, meta?})`, `withdraw(userId, {purgeMessages?})`, `purgeMessages(userId)`, `ban(userId, {until, reason?})`, `unban(userId)`, `revokeTokens(userId)`, `disconnect(userId, {connectionId?, reason?})` | | `events` | `list({after?, limit?})` — 놓친 웹훅 이벤트를 따라잡는다 | | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` — `tolerance`는 밀리초 | `rooms.ensure`는 get-or-create다 — 있으면 그대로 돌려주므로 페이지를 열 때마다 불러도 된다. `rooms.list`는 앱의 방을 최신순으로 준다. `type`이 없으면 공개 목록(`public`·`channel`)이고, `type: ['private', 'dm']`처럼 주면 비공개 방과 DM도 나온다. `limit` 기본 50 최대 100, 빈 `cursor`가 마지막 페이지이고, **커서는 `type`을 싣지 않으므로** 필터를 바꾸면 처음부터 받는다. `rooms.presence(roomId)`는 `{count}`이고, `{full: true}`면 `users`(최대 1000명, 잘리면 `capped: true`)가 붙는다. 빈 방은 404가 아니라 `{count: 0}`이다. `rooms.custom(roomId, payload)`은 **저장되지 않는 제어 신호**다. `payload`는 JSON 객체이고 그대로 `room.on('custom', ...)`에 도착한다. 히스토리도 unread도 웹훅도 남지 않으니, 재접속해도 받아야 하는 신호는 `messages.send(roomId, {kind:'system', ...})`으로 보낸다. `users.ban`의 `until`은 **unix 밀리초이고 필수다.** 무기한 밴은 먼 미래 시각을 직접 고른다. `users.purgeMessages(userId)`는 그 유저의 메시지를 거두되 **탈퇴시키지 않는다.** 한 번에 상한만큼만 하므로 `purgeCapped`가 `true`인 동안 다시 부르고, 스팸이면 **밴을 먼저** 한다. ```ts await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' }) while ((await chat.users.purgeMessages(userId)).purgeCapped) {} ``` `users.list`는 최신순이고 `limit` 기본 50 최대 100, `cursor`는 불투명 문자열이며 빈 문자열이 마지막 페이지다. **탈퇴한 유저도 나오고 `deletedAt`이 그 표시다.** `users.*`는 그 사람이 **한 번이라도 접속한 뒤에만** 통한다 — 유저는 접속으로 생긴다. ### 웹훅 ```ts app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8')) if (seen.has(delivery)) return res.sendStatus(200) // at-least-once다 res.sendStatus(200) // 먼저 접수하고 void handle(event, data) // 처리는 비동기로 }) ``` **raw 바디여야 한다.** 파싱했다가 다시 직렬화한 것은 다른 문자열이라 검증에 실패한다. 세 가지를 지킨다. `verify`가 서명과 시각(기본 5분)을 보고, **중복 제거는 직접** 해야 하며 (`delivery`로), 10초 안에 2xx로 답해야 한다. 넘기면 실패로 치고 백오프가 시작된다. 다른 언어로 검증하는 규격은 [웹훅 문서](https://chat.gravex.app/docs/webhooks)에 있다. 원문: https://chat.gravex.app/docs/sdk