서버 연동
백엔드가 할 일은 둘이다 — 유저 토큰에 서명하는 것, 방·메시지·유저를 REST로 다루는
것. TypeScript에는 이 둘을 감싼 @kispi/chat/server가 있고, 다른 언어는 같은 일을 직접
한다. 아래 탭에서 언어를 고른다.
둘 다 sk_ 하나로 한다.
유저 토큰 규격
| 형식 | 표준 JWT. 서명 알고리즘은 HS256 고정이다. 다른 알고리즘과 none은 거절한다 |
| 서명 키 | sk_ 문자열 전체를 UTF-8 바이트로 쓴다. 접두사를 떼거나 디코드하지 않는다 |
| 헤더 | {"alg":"HS256","typ":"JWT","kid":"…"} — kid는 sk_<키 id>_<토큰>의 둘째 칸이고 선택이다 |
클레임:
| 클레임 | ||
|---|---|---|
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를 쓴다. 나머지는 표준
라이브러리로 충분하다.
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 })import base64, hmac, hashlib, json, time
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: 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)}"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 {
token.Header["kid"] = parts[1]
}
return token.SignedString([]byte(sk))
}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) builder.withKeyId(it[1]) }
return builder.sign(Algorithm.HMAC256(sk))
}서버 API
방·메시지·유저를 다루는 창구는 REST 하나다. TypeScript SDK는 그것을 감싼 것이라, 아래 세 탭은 같은 엔드포인트를 부르는 세 가지 방법이다.
@kispi/chat/server의 표면 전체다.
| 그룹 | 메서드 |
|---|---|
rooms |
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?}) |
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분 전입니다'
})주소는 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·PATCH·DELETE /v1/rooms/{roomId}, GET /v1/rooms/by-key/{key} |
| 메시지 보내기 | 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= |
| 메시지 삭제 | DELETE /v1/rooms/{roomId}/messages/{messageId} |
| 저장되지 않는 제어 신호 | 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 |
| 토큰 무효화 | POST /v1/users/{userId}/revoke-tokens |
| 강제 종료 | POST /v1/users/{userId}/disconnect |
| 유저 목록 | GET /v1/users?cursor=&limit= |
| 놓친 이벤트 | GET /v1/events?after=&limit= |
# 방 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.*는 그 사람이 한 번이라도 접속한 뒤에만 통한다. 유저는 접속으로 생긴다.
그 사람의 메시지를 전부 거두는 것(DELETE /v1/users/{userId}/messages)은 탈퇴와 다르고,
한 번에 상한만큼만 지우므로 purgeCapped가 true인 동안 다시 부른다.
방을 만들 때 고르는 타입:
| 타입 | 누가 읽나 | 누가 쓰나 | 찾는 법 |
|---|---|---|---|
public |
누구나 | 누구나 | 키 또는 id. 탐색에 나온다 |
private |
멤버만 | 멤버만 | 키 또는 id |
channel |
누구나 | owner·admin만 | 키 또는 id. 탐색에 나온다 |
dm |
두 사람만 | 두 사람만 | 두 사람의 조합. 키가 없다 |
방의 정본은 id다. key는 사람이 붙이는 별칭(lobby, order-1234)이고 브라우저는
chat.roomByKey(key)로 연다. channel을 서버에서 만들면 owner가 없으므로, 유저 토큰으로
공지를 올릴 사람이 있어야 한다면 만든 뒤 멤버 API로 owner나 admin을 지정한다.
키 교체
sk_는 새 키 발급 → 백엔드 배포 → 확인 → 옛 키 폐기 순서로 바꾼다.
- 콘솔 키 화면에서 새 secret key를 누른다. 전체 값은 이 창에서만 보인다.
- 백엔드의
sk_를 새 값으로 바꾸고 배포한다. 이 시점에 두 키가 모두 살아 있다. - 새 키로 서명한 토큰으로 접속이 되는지, 서버 API가 도는지 확인한다.
- 콘솔에서 옛 키를 폐기한다.
폐기를 먼저 하지 않는다. 옛 키를 먼저 끄면 그 키로 서명하던 백엔드가 그 순간 멈춘다.
배포한 뒤에도 브라우저에는 옛 키로 서명된 토큰이 남아 있다. 토큰의 kid가 가리키는 키를
엔진이 확인하므로, 옛 키를 폐기하지 않는 동안 그 토큰들은 그대로 동작한다. 토큰 수명이
한 바퀴 돌면(기본 1시간) 나가 있는 토큰이 전부 새 키로 갈린다 — 그때 폐기하면 아무도 튕기지
않는다. 더 빨리 폐기해도 옛 토큰은 다음 호출에서 새 토큰으로 갈린다.
pk_도 모양은 같다. 한 가지가 더 있다 — 허용 오리진은 앱이 아니라 키마다 붙어 있다.
목록이 빈 pk_는 어떤 브라우저 페이지도 받지 못하므로, 새 키를 발급할 때 지금 키가 가진
오리진을 그대로 넣는다. 오리진만 바꾸면 되는 경우에는 키를 바꾸지 않고 키 화면의
오리진 편집으로 같은 키의 목록을 고친다.
폐기는 즉시 효력이 있고 되돌릴 수 없다. 키를 잃어도 데이터는 잃지 않는다 — 유저·방·메시지는 앱에 달려 있다.
운영과 개발은 키가 아니라 앱으로 가른다. 앱이 테넌트이므로 데이터의 경계도 앱이다. 앱을 둘 만들고 콘솔에서 프로덕션·개발 라벨을 붙인다.