connectbase-client 3.7.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,142 @@
3
3
  본 SDK 의 모든 주요 변경사항을 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식으로 기록합니다.
4
4
  버전은 [Semantic Versioning](https://semver.org/lang/ko/) 을 따릅니다.
5
5
 
6
+ ## [3.8.0] - 2026-05-07
7
+
8
+ ### Added — HttpOnly cookie 기반 refresh token 흐름 (XSS 면역 default 세션)
9
+
10
+ 3.7.x 의 `persistence` 콘솔 경고가 권고하던 "HttpOnly cookie + 기본값('none')" 흐름을
11
+ 실제로 동작하도록 SDK + 백엔드를 함께 구현했습니다.
12
+
13
+ - `persistence: 'none'` (기본값) 으로도 **새로고침 후 자동 복구** 가능. refresh token 은
14
+ 서버 HttpOnly cookie 로만 보관되어 JS 가 접근할 수 없습니다 (XSS 시 탈취 불가).
15
+ - `localStorage` / `sessionStorage` 옵션은 여전히 사용 가능하지만 위험 경고가 유지됩니다.
16
+
17
+ **SDK 변경:**
18
+
19
+ - 모든 ConnectBase API fetch 호출에 `credentials: 'include'` 적용 — HttpOnly refresh cookie 가
20
+ 자동 첨부됩니다 (`api.connectbase.world` host-only cookie 기준).
21
+ - `/v1/auth/re-issue` 호출이 cookie 만으로 동작 — 메모리에 refresh token 이 없어도 cookie 가
22
+ 있으면 access token 회복.
23
+ - `ConnectBase` 옵션에 `autoRestoreSession?: boolean` 추가 (브라우저 기본 true). 인스턴스 생성
24
+ 시 자동으로 cookie 기반 세션 복구를 시도하며, 미로그인/cookie 만료 시 silent 실패.
25
+ - `cb.restoreSession(): Promise<boolean>` 메서드로 명시적 await 도 가능.
26
+ - `persistence` 콘솔 경고를 갱신: 위험은 그대로 표시하되 'none' + HttpOnly cookie 흐름이
27
+ 실제로 작동함을 안내.
28
+
29
+ **백엔드 변경 (core-server):**
30
+
31
+ - `pkg/util/cookie` 에 `CrossSite` / `HostOnly` 옵션 추가 (SameSite=None + Secure + host-only).
32
+ - 새 공용 헬퍼 `core-server/app/util/auth_cookie/` — platform 용 `refresh_token` 과 AppMember/OAuth
33
+ 용 `cb_member_refresh_token` 두 종류를 분리해 충돌 방지.
34
+ - `/v1/public/app-members/{signin,signup,signout}` + `CreateGuestMember` + OAuth callback/exchange
35
+ 엔드포인트가 refresh token 을 HttpOnly cookie 로 발급.
36
+ - `/v1/auth/re-issue` 가 입력 우선순위 `[member cookie → user cookie → Authorization Bearer]`
37
+ 로 처리하며, cookie 흐름은 sliding (재호출 시 cookie 만료 7일 연장).
38
+ - 응답 body 의 `refresh_token` 은 하위호환을 위해 그대로 유지 (구버전 SDK / Node.js / 게임 SDK
39
+ 호환). 신규 SDK 는 cookie 만 신뢰합니다.
40
+
41
+ **마이그레이션:**
42
+
43
+ 기존 `persistence: 'sessionStorage'` 또는 `'localStorage'` 사용 중이었다면 옵션을 제거하기만
44
+ 하면 됩니다 (default 가 안전 흐름):
45
+
46
+ ```ts
47
+ // before (3.7.x)
48
+ new ConnectBase({ publicKey, persistence: 'sessionStorage' })
49
+
50
+ // after (3.8.0)
51
+ new ConnectBase({ publicKey }) // persistence: 'none' + autoRestoreSession: true (기본)
52
+ ```
53
+
54
+ 다른 origin 에서 호출하는 경우 (앱 도메인 ≠ `api.connectbase.world`) CORS 화이트리스트에 등록되어
55
+ 있어야 합니다 — 콘솔의 커스텀 도메인 등록 흐름이 그대로 적용됩니다.
56
+
57
+ ## [3.7.2] - 2026-05-01
58
+
59
+ ### Fixed — `cb.endpoint.connectWebSocket()` 메시지 깨짐 + permessage-deflate 누설
60
+
61
+ 3.7.0/3.7.1 의 endpoint WS pass-through 가 handshake 는 101 정상이지만 모든
62
+ 메시지가 binary frame 으로 도착하고 payload 가 origin (ComfyUI/aiohttp) 의
63
+ raw WS frame bytes (RSV1=1 압축 frame 포함) 라 client 의 native WebSocket
64
+ 이 디코드 불가하던 문제를 수정했습니다. 두 개의 별개 버그가 합쳐진 회귀:
65
+
66
+ 1. **CLI 가 WS frame parse/encode 안 함** — 기존 `startWSStream` 이
67
+ `socket.on('data')` 의 raw bytes 를 그대로 v2 binary frame payload 로
68
+ forward 해서 client 에 frame header (0x81/0x82/0xc1) 가 섞인 상태로 도달.
69
+ 2. **`Sec-WebSocket-Extensions` 가 upstream 까지 forward 됨** — 브라우저 native
70
+ WebSocket 이 default 로 `permessage-deflate` 를 광고하고, sanitize 가 hop-by-hop
71
+ 만 strip 해서 upstream (aiohttp default) 이 accept → compressed frame 송신
72
+ 시작. CLI 는 deflate context 가 없어 디코드 불가.
73
+
74
+ **Fix (CLI):**
75
+
76
+ - `UpstreamWsFrameParser`: incoming WS frame 을 parse 해서 payload 만 추출.
77
+ TEXT/BINARY/CONTINUATION/PING/CLOSE 처리, fragmented frame 은 누적, RSV1
78
+ (compression) frame 은 protocol error 로 거부.
79
+ - `buildClientFrame` / `createUpstreamTextFrame`: outgoing payload 를 RFC 6455
80
+ §5.3 client masking 적용한 masked WS frame 으로 encode 후 upstream 에 write.
81
+ - 로컬 upstream 요청에서 `Sec-WebSocket-Extensions` 헤더 strip — proxy chain 이
82
+ deflate context 를 end-to-end 로 carry 하지 못하므로 compression 자체를 비활성.
83
+
84
+ **회귀 영향 범위:** 3.7.0 의 모든 `cb.endpoint.connectWebSocket()` 사용자.
85
+ ComfyUI / vLLM 등 default-on compression origin 영향. 사용자가 3.7.2 로 업그레이드
86
+ + 터널 재시작 시 즉시 정상화. text/binary 구분은 client 측에서 항상 binary
87
+ (ArrayBuffer) 로 도달 — JSON 은 `TextDecoder.decode` 로 string 변환 필요.
88
+ Opcode propagation 은 별도 follow-up.
89
+
90
+ **회귀 가드:** `UpstreamWsFrameParser` 단위 테스트 (TEXT payload 추출 / fragmented
91
+ 누적 / RSV1 reject / TCP coalescing / split-across-chunks / PING callback) +
92
+ `buildClientFrame` masking round-trip + `WSStreamForwarder` e2e (real local HTTP
93
+ server 로 `Sec-WebSocket-Extensions` 미도달 검증).
94
+
95
+ ## [3.7.1] - 2026-05-01
96
+
97
+ ### Fixed — `cb.endpoint.connectWebSocket()` 가 502 로 떨어지던 회귀
98
+
99
+ 3.7.0 의 endpoint WS pass-through 가 client 단에서 항상 CF 502 page 로 떨어지던
100
+ 문제를 수정했습니다. Root cause 는 CLI 가 로컬 upstream HTTP 요청에 WebSocket
101
+ upgrade 헤더 (`Connection: Upgrade` + `Upgrade: websocket`) 를 누락한 것:
102
+ upstream (ComfyUI 등) 이 일반 GET 으로 인식하고 400 Bad Request 반환 → core-server
103
+ ReverseProxy 가 비-101 body 를 relay 하려다 `net/http: abort Handler` panic →
104
+ CF 가 corrupted response 를 자체 502 page 로 substitute.
105
+
106
+ **Primary fix (CLI):**
107
+
108
+ - `cli.ts` `startWSStream` 과 `tunnel-v2.ts` `WSStreamForwarder` 가 upstream
109
+ 요청에 `Connection: Upgrade` + `Upgrade: websocket` 명시적으로 set. tunnel-server
110
+ 의 `sanitizeRequestHeaders` 가 RFC 7230 hop-by-hop 으로 strip 하므로 CLI →
111
+ upstream 새 hop 에서 다시 추가 필요.
112
+
113
+ **Defensive fix (core-server, 자동 deploy):**
114
+
115
+ - `ForwardWebSocket` `ModifyResponse` 에서 비-101 upstream 응답 시 ErrorHandler
116
+ 경로로 라우팅 — ReverseProxy 가 비-101 body 를 relay 하려다 panic 하는 path
117
+ 차단. Upstream 이 정당한 사유로 비-101 (rate limit / auth 실패 등) 반환할 때도
118
+ 깨끗한 502 + 진단 메시지가 client 까지 도달.
119
+
120
+ **회귀 영향 범위:** 3.7.0 의 모든 `cb.endpoint.connectWebSocket()` 사용자.
121
+ 사용자가 3.7.1 로 업그레이드 + 터널 재시작 시 정상 동작.
122
+
123
+ **회귀 가드:** `tunnel-v2.test.ts` 가 `WSStreamForwarder` 의 upgrade 헤더 송신을
124
+ real local HTTP server 로 e2e 검증. Go 측 `proxy_service_test.go
125
+ TestForwardWebSocket_Non101UpstreamReturnsCleanError` 가 panic 없는 깨끗한 502
126
+ 반환을 락인.
127
+
128
+ ## [3.7.0] - 2026-05-01
129
+
130
+ ### Added — `cb.endpoint.connectWebSocket()` 네이티브 WebSocket 지원
131
+
132
+ Endpoint Proxy v2 (Phase 5) 가 출하되어, SDK 사용자가 `cb.endpoint.connectWebSocket()`
133
+ 한 줄로 ComfyUI / vLLM / 일반 모델 서버의 WebSocket 엔드포인트를 직접 연결할 수
134
+ 있습니다. CLI 도 v2 endpoint (`/v2/tunnel/connect`) 를 사용하도록 전환.
135
+
136
+ > **요구사항:** 백엔드 v2 endpoint 가 먼저 배포돼 있어야 동작합니다. 프로덕션
137
+ > 배포 완료 후 SDK 업그레이드를 권장합니다.
138
+
139
+ > **알려진 이슈:** 3.7.0 / 3.7.1 에서 WS frame leakage / 502 회귀가 발견되어
140
+ > **3.7.2 이상 사용 권장**. 자세한 내용은 3.7.1 / 3.7.2 항목 참고.
141
+
6
142
  ## [3.6.0] - 2026-05-01
7
143
 
8
144
  ### Added — `cb.realtime.stream()` 멀티모달 메시지 (Vision) 지원
@@ -168,6 +304,38 @@ await cb.knowledge.search(kbId, {
168
304
 
169
305
  자세한 사용법: [docs/knowledge-base/USER_ISOLATION.md](https://github.com/connectbase-world/connectbase/blob/release/docs/knowledge-base/USER_ISOLATION.md)
170
306
 
307
+ ## [3.4.0] - 2026-04-30
308
+
309
+ ### Added — `cb.analytics.reset()` 사용자 전환 오염 방지 helper
310
+
311
+ 같은 브라우저에서 다른 사용자로 로그인할 때 방문자 (`visitor_uid`) 데이터가 이전
312
+ 사용자의 식별자에 묶인 채로 남아 데이터가 오염되던 문제를 SDK 측에서 끊어 낼 수
313
+ 있는 helper 를 추가했습니다.
314
+
315
+ - **`cb.analytics.reset()`** — 로그아웃 시 호출. `visitor_uid` 를 새로 발급하고
316
+ in-flight 큐를 비웁니다.
317
+ - **자동 reset (1) — `identify(memberId)` 가 다른 멤버 감지** — 직전 세션과 다른
318
+ `memberId` 가 들어오면 SDK 가 자동으로 `reset()` → identify 순으로 처리.
319
+ - **자동 reset (2) — `linkMemberSilent` conflict 안전망** — 백엔드가 `409
320
+ VISITOR_LINKED_TO_OTHER_MEMBER` 응답을 주면 SDK 가 자동으로 reset 후 1회 재시도.
321
+ 명시적 `reset()` 호출을 누락한 경우의 fallback.
322
+
323
+ ### Changed — BatchEvent 멱등성
324
+
325
+ - 모든 `BatchEvent` 에 `event_id` (UUID) 자동 부여. 백엔드가 `(visitor_id,
326
+ event_id)` UNIQUE 인덱스 + `OnConflict DoNothing` 으로 at-least-once 재전송 시
327
+ 중복 INSERT 차단. SDK 사용자 입장에서 추가 작업 없음.
328
+
329
+ ### Fixed — 첫 방문 봇 오판 방지
330
+
331
+ - `flushSync` (`navigator.sendBeacon`) 호출에 `user_agent` 를 첨부 — 첫 방문 직후
332
+ `pagehide` 로 flush 될 때 백엔드가 UA 누락으로 봇으로 분류해 카운트가 누락되던
333
+ 문제 해결.
334
+
335
+ **서버 측 동시 변경:** `LinkMember` idempotent 처리, `WebPageView.event_id` 컬럼 +
336
+ UNIQUE 인덱스, `link-member` 옵셔널 토큰 검증 (Authorization Bearer 가 있으면
337
+ AppMember 토큰의 `member_id` 와 body 일치 검증, 없으면 BC 보존 + 경고 로그).
338
+
171
339
  ## [3.3.1] - 2026-04-30
172
340
 
173
341
  ### Fixed — Docs
package/dist/cli.js CHANGED
@@ -1456,6 +1456,129 @@ function createWsBinaryFrame(payload) {
1456
1456
  }
1457
1457
  return Buffer.concat([header, maskKey, masked]);
1458
1458
  }
1459
+ var UpstreamWsFrameParser = class {
1460
+ constructor(handlers) {
1461
+ this.buffer = Buffer.alloc(0);
1462
+ this.fragmentBuffer = null;
1463
+ this.h = handlers;
1464
+ }
1465
+ feed(chunk) {
1466
+ this.buffer = Buffer.concat([this.buffer, chunk]);
1467
+ try {
1468
+ this.parse();
1469
+ } catch (e) {
1470
+ this.h.onError(e instanceof Error ? e : new Error(String(e)));
1471
+ }
1472
+ }
1473
+ parse() {
1474
+ while (this.buffer.length >= 2) {
1475
+ const firstByte = this.buffer[0];
1476
+ const secondByte = this.buffer[1];
1477
+ const fin = (firstByte & 128) !== 0;
1478
+ const rsv1 = (firstByte & 64) !== 0;
1479
+ const opcode = firstByte & 15;
1480
+ const isMasked = (secondByte & 128) !== 0;
1481
+ let payloadLen = secondByte & 127;
1482
+ let offset = 2;
1483
+ if (payloadLen === 126) {
1484
+ if (this.buffer.length < 4) return;
1485
+ payloadLen = this.buffer.readUInt16BE(2);
1486
+ offset = 4;
1487
+ } else if (payloadLen === 127) {
1488
+ if (this.buffer.length < 10) return;
1489
+ const big = this.buffer.readBigUInt64BE(2);
1490
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
1491
+ throw new Error(`payload too large: ${big}`);
1492
+ }
1493
+ payloadLen = Number(big);
1494
+ offset = 10;
1495
+ }
1496
+ let maskKey = null;
1497
+ if (isMasked) {
1498
+ if (this.buffer.length < offset + 4) return;
1499
+ maskKey = this.buffer.subarray(offset, offset + 4);
1500
+ offset += 4;
1501
+ }
1502
+ if (this.buffer.length < offset + payloadLen) return;
1503
+ if (rsv1 && (opcode === 1 || opcode === 2 || opcode === 0)) {
1504
+ throw new Error("upstream sent compressed (RSV1) frame without negotiation");
1505
+ }
1506
+ let payload = this.buffer.subarray(offset, offset + payloadLen);
1507
+ if (maskKey) {
1508
+ const unmasked = Buffer.alloc(payloadLen);
1509
+ for (let i = 0; i < payloadLen; i++) {
1510
+ unmasked[i] = payload[i] ^ maskKey[i % 4];
1511
+ }
1512
+ payload = unmasked;
1513
+ }
1514
+ this.buffer = this.buffer.subarray(offset + payloadLen);
1515
+ const payloadCopy = Buffer.from(payload);
1516
+ switch (opcode) {
1517
+ case 0:
1518
+ if (this.fragmentBuffer == null) {
1519
+ throw new Error("continuation frame without preceding non-final data frame");
1520
+ }
1521
+ this.fragmentBuffer = Buffer.concat([this.fragmentBuffer, payloadCopy]);
1522
+ if (fin) {
1523
+ const out = this.fragmentBuffer;
1524
+ this.fragmentBuffer = null;
1525
+ this.h.onPayload(out);
1526
+ }
1527
+ break;
1528
+ case 1:
1529
+ // TEXT
1530
+ case 2:
1531
+ if (fin) {
1532
+ this.h.onPayload(payloadCopy);
1533
+ } else {
1534
+ this.fragmentBuffer = payloadCopy;
1535
+ }
1536
+ break;
1537
+ case 8:
1538
+ this.h.onClose();
1539
+ break;
1540
+ case 9:
1541
+ this.h.onPing(payloadCopy);
1542
+ break;
1543
+ case 10:
1544
+ break;
1545
+ default:
1546
+ throw new Error(`unknown WS opcode 0x${opcode.toString(16)}`);
1547
+ }
1548
+ }
1549
+ }
1550
+ };
1551
+ function createUpstreamTextFrame(payload) {
1552
+ return buildClientFrame(129, payload);
1553
+ }
1554
+ function createUpstreamPongFrame(payload) {
1555
+ return buildClientFrame(138, payload);
1556
+ }
1557
+ function buildClientFrame(firstByte, payload) {
1558
+ const len = payload.length;
1559
+ const maskKey = crypto.randomBytes(4);
1560
+ let header;
1561
+ if (len < 126) {
1562
+ header = Buffer.alloc(2);
1563
+ header[0] = firstByte;
1564
+ header[1] = 128 | len;
1565
+ } else if (len < 65536) {
1566
+ header = Buffer.alloc(4);
1567
+ header[0] = firstByte;
1568
+ header[1] = 128 | 126;
1569
+ header.writeUInt16BE(len, 2);
1570
+ } else {
1571
+ header = Buffer.alloc(10);
1572
+ header[0] = firstByte;
1573
+ header[1] = 128 | 127;
1574
+ header.writeBigUInt64BE(BigInt(len), 2);
1575
+ }
1576
+ const masked = Buffer.alloc(len);
1577
+ for (let i = 0; i < len; i++) {
1578
+ masked[i] = payload[i] ^ maskKey[i % 4];
1579
+ }
1580
+ return Buffer.concat([header, maskKey, masked]);
1581
+ }
1459
1582
  var WsFrameParser = class {
1460
1583
  constructor(handlers) {
1461
1584
  this.buffer = Buffer.alloc(0);
@@ -1951,7 +2074,10 @@ ${colors.cyan}ConnectBase Tunnel${colors.reset}`);
1951
2074
  const fullPath = query ? `${reqPath}?${query}` : reqPath;
1952
2075
  const localHeaders = {};
1953
2076
  for (const [k, v] of Object.entries(headers)) {
1954
- if (k.toLowerCase() !== "host") localHeaders[k] = v;
2077
+ const lk = k.toLowerCase();
2078
+ if (lk === "host") continue;
2079
+ if (lk === "sec-websocket-extensions") continue;
2080
+ localHeaders[k] = v;
1955
2081
  }
1956
2082
  localHeaders["host"] = `localhost:${localPort}`;
1957
2083
  localHeaders["connection"] = "Upgrade";
@@ -1980,9 +2106,28 @@ ${colors.cyan}ConnectBase Tunnel${colors.reset}`);
1980
2106
  websocket: true
1981
2107
  });
1982
2108
  log(`${colors.dim}${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${colors.reset} ${colors.cyan}WS${colors.reset} ${reqPath} \u2192 101`);
2109
+ const upstreamParser = new UpstreamWsFrameParser({
2110
+ onPayload: (payload) => {
2111
+ if (cancelled) return;
2112
+ sendBinary(sock, streamId, payload);
2113
+ },
2114
+ onPing: (payload) => {
2115
+ if (cancelled || !upstream) return;
2116
+ upstream.write(createUpstreamPongFrame(payload));
2117
+ },
2118
+ onClose: () => {
2119
+ if (cancelled) return;
2120
+ sendControl(sock, { type: "stream_close", stream_id: streamId });
2121
+ streams.delete(streamId);
2122
+ },
2123
+ onError: (err) => {
2124
+ sendControl(sock, { type: "stream_close", stream_id: streamId, error: `upstream_frame_error: ${err.message}` });
2125
+ streams.delete(streamId);
2126
+ }
2127
+ });
1983
2128
  sk.on("data", (chunk) => {
1984
2129
  if (cancelled) return;
1985
- sendBinary(sock, streamId, chunk);
2130
+ upstreamParser.feed(chunk);
1986
2131
  });
1987
2132
  sk.on("close", () => {
1988
2133
  if (cancelled) return;
@@ -2017,8 +2162,16 @@ ${colors.cyan}ConnectBase Tunnel${colors.reset}`);
2017
2162
  req.end();
2018
2163
  const forwarder = {
2019
2164
  kind: "ws",
2165
+ // Encode the v2 payload as an upstream WS frame BEFORE writing to
2166
+ // the socket. The prior implementation wrote raw payload to the
2167
+ // socket which is not a valid WS frame; upstream WS servers (e.g.
2168
+ // ComfyUI) would error out. Default to TEXT opcode — the v2
2169
+ // protocol does not propagate the original opcode from the client,
2170
+ // and most WS APIs (JSON-based) use text. Binary client→upstream
2171
+ // is a known limitation pending opcode propagation in v2.
2020
2172
  feedBody: (chunk) => {
2021
- if (!cancelled && upstream) upstream.write(chunk);
2173
+ if (cancelled || !upstream) return;
2174
+ upstream.write(createUpstreamTextFrame(chunk));
2022
2175
  },
2023
2176
  endBody: () => {
2024
2177
  },