서버 연동

백엔드가 할 일은 둘이다 — 유저 토큰에 서명하는 것, 방·메시지·유저를 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를 쓴다. 나머지는 표준 라이브러리로 충분하다.

ts
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
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)}"
go
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))
}
kotlin
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?})
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분 전입니다'
})

주소는 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=
bash
# 방 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_는 새 키 발급 → 백엔드 배포 → 확인 → 옛 키 폐기 순서로 바꾼다.

  1. 콘솔 키 화면에서 새 secret key를 누른다. 전체 값은 이 창에서만 보인다.
  2. 백엔드의 sk_를 새 값으로 바꾸고 배포한다. 이 시점에 두 키가 모두 살아 있다.
  3. 새 키로 서명한 토큰으로 접속이 되는지, 서버 API가 도는지 확인한다.
  4. 콘솔에서 옛 키를 폐기한다.

폐기를 먼저 하지 않는다. 옛 키를 먼저 끄면 그 키로 서명하던 백엔드가 그 순간 멈춘다.

배포한 뒤에도 브라우저에는 옛 키로 서명된 토큰이 남아 있다. 토큰의 kid가 가리키는 키를 엔진이 확인하므로, 옛 키를 폐기하지 않는 동안 그 토큰들은 그대로 동작한다. 토큰 수명이 한 바퀴 돌면(기본 1시간) 나가 있는 토큰이 전부 새 키로 갈린다 — 그때 폐기하면 아무도 튕기지 않는다. 더 빨리 폐기해도 옛 토큰은 다음 호출에서 새 토큰으로 갈린다.

pk_도 모양은 같다. 한 가지가 더 있다 — 허용 오리진은 앱이 아니라 키마다 붙어 있다. 목록이 빈 pk_는 어떤 브라우저 페이지도 받지 못하므로, 새 키를 발급할 때 지금 키가 가진 오리진을 그대로 넣는다. 오리진만 바꾸면 되는 경우에는 키를 바꾸지 않고 키 화면의 오리진 편집으로 같은 키의 목록을 고친다.

폐기는 즉시 효력이 있고 되돌릴 수 없다. 키를 잃어도 데이터는 잃지 않는다 — 유저·방·메시지는 앱에 달려 있다.

운영과 개발은 키가 아니라 앱으로 가른다. 앱이 테넌트이므로 데이터의 경계도 앱이다. 앱을 둘 만들고 콘솔에서 프로덕션·개발 라벨을 붙인다.