connectbase-client 3.8.1 → 3.10.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/dist/index.js CHANGED
@@ -90,6 +90,40 @@ function createTimeoutController(options = {}) {
90
90
  };
91
91
  }
92
92
 
93
+ // src/core/recent-calls.ts
94
+ var DEFAULT_CAPACITY = 20;
95
+ var MAX_CAPACITY = 50;
96
+ var RecentCallsBuffer = class {
97
+ constructor(capacity = DEFAULT_CAPACITY) {
98
+ this.buf = [];
99
+ this.capacity = Math.min(Math.max(1, capacity), MAX_CAPACITY);
100
+ }
101
+ push(call) {
102
+ if (this.buf.length >= this.capacity) {
103
+ this.buf.shift();
104
+ }
105
+ this.buf.push(call);
106
+ }
107
+ /**
108
+ * 시간순(오래된 → 최신) 복사본 반환. 호출자가 mutate 해도 내부에 영향 없음.
109
+ */
110
+ snapshot() {
111
+ return this.buf.slice();
112
+ }
113
+ clear() {
114
+ this.buf = [];
115
+ }
116
+ };
117
+ function sanitizePathForBreadcrumb(rawUrl) {
118
+ try {
119
+ const u = rawUrl.startsWith("http") ? new URL(rawUrl) : new URL(rawUrl, "http://x");
120
+ return u.pathname || rawUrl;
121
+ } catch {
122
+ const idx = rawUrl.indexOf("?");
123
+ return idx >= 0 ? rawUrl.slice(0, idx) : rawUrl;
124
+ }
125
+ }
126
+
93
127
  // src/core/http.ts
94
128
  var TOKEN_STORAGE_KEY = "cb_auth_tokens";
95
129
  var HttpClient = class {
@@ -100,11 +134,24 @@ var HttpClient = class {
100
134
  // 밀리초 단위로 서버에 재시도 요청이 쏟아지는 것을 차단.
101
135
  this.refreshFailureCount = 0;
102
136
  this.refreshLockedUntil = 0;
137
+ /**
138
+ * 최근 API 호출 breadcrumb (PII strip 후 저장). platform_issue 발행 시 자동 첨부 가능.
139
+ * `client.support.getRecentCalls()` 로 외부 노출.
140
+ */
141
+ this.recentCalls = new RecentCallsBuffer();
103
142
  this.config = { ...config };
104
143
  this.storageKey = this.buildStorageKey();
105
144
  this.warnIfUnsafePersistence();
106
145
  this.restoreTokens();
107
146
  }
147
+ /** 최근 호출 ring buffer 스냅샷 (시간순). */
148
+ getRecentCalls() {
149
+ return this.recentCalls.snapshot();
150
+ }
151
+ /** 최근 호출 buffer clear (테스트/프라이버시 처리). */
152
+ clearRecentCalls() {
153
+ this.recentCalls.clear();
154
+ }
108
155
  warnIfUnsafePersistence() {
109
156
  if (typeof window === "undefined") return;
110
157
  if (this.config.persistence === "localStorage") {
@@ -261,6 +308,7 @@ var HttpClient = class {
261
308
  const { signal, cleanup } = createTimeoutController({
262
309
  timeout: this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
263
310
  });
311
+ let failureKind = "transient";
264
312
  try {
265
313
  const headers = {
266
314
  "Content-Type": "application/json"
@@ -279,7 +327,28 @@ var HttpClient = class {
279
327
  signal
280
328
  });
281
329
  if (!response.ok) {
282
- throw new Error("Token refresh failed");
330
+ const status = response.status;
331
+ let oauthError;
332
+ try {
333
+ const errBody = await response.clone().json();
334
+ if (errBody && typeof errBody.error === "string") {
335
+ oauthError = errBody.error;
336
+ } else if (errBody && typeof errBody.code === "string") {
337
+ oauthError = errBody.code;
338
+ }
339
+ } catch {
340
+ }
341
+ if (status >= 500) {
342
+ throw new Error(`Token refresh failed (${status})`);
343
+ }
344
+ if (status === 401 || status === 403 || status === 400 && (oauthError === "invalid_grant" || oauthError === "invalid_token")) {
345
+ failureKind = "permanent";
346
+ } else {
347
+ failureKind = "client_bug";
348
+ }
349
+ throw new Error(
350
+ `Token refresh failed (${status}${oauthError ? ` ${oauthError}` : ""})`
351
+ );
283
352
  }
284
353
  const data = await response.json();
285
354
  if (!data || typeof data.access_token !== "string") {
@@ -299,17 +368,35 @@ var HttpClient = class {
299
368
  this.refreshFailureCount = 0;
300
369
  this.refreshLockedUntil = 0;
301
370
  return data.access_token;
302
- } catch {
371
+ } catch (e) {
372
+ const baseMsg = e instanceof Error ? e.message : "Token refresh failed";
303
373
  this.refreshFailureCount++;
304
374
  const backoffMs = Math.min(
305
375
  500 * 2 ** Math.max(0, this.refreshFailureCount - 1),
306
376
  3e4
307
377
  );
308
378
  this.refreshLockedUntil = Date.now() + backoffMs;
309
- this.clearTokens();
310
- this.config.onTokenExpired?.();
311
- const error = new AuthError("Token refresh failed. Please login again.");
379
+ if (failureKind === "permanent") {
380
+ this.clearTokens();
381
+ this.config.onTokenExpired?.();
382
+ const error2 = new AuthError(`${baseMsg}. Please login again.`);
383
+ this.emitError(error2);
384
+ this.config.onAuthError?.(error2);
385
+ throw error2;
386
+ }
387
+ if (failureKind === "client_bug") {
388
+ const error2 = new AuthError(
389
+ `${baseMsg}. Client request invalid; tokens preserved.`
390
+ );
391
+ this.emitError(error2);
392
+ this.config.onAuthError?.(error2);
393
+ throw error2;
394
+ }
395
+ const error = new AuthError(
396
+ `${baseMsg}. Transient failure; tokens preserved, will retry after backoff.`
397
+ );
312
398
  this.emitError(error);
399
+ this.config.onTransientRefreshFailure?.(error);
313
400
  this.config.onAuthError?.(error);
314
401
  throw error;
315
402
  } finally {
@@ -438,14 +525,24 @@ var HttpClient = class {
438
525
  timeout: config?.timeout ?? this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
439
526
  signal: config?.signal
440
527
  });
528
+ const startedAt = Date.now();
529
+ let status = 0;
441
530
  try {
442
531
  const response = await fetch(`${this.config.baseUrl}${url}`, {
443
532
  ...init,
444
533
  credentials: "include",
445
534
  signal
446
535
  });
536
+ status = response.status;
447
537
  return await this.handleResponse(response);
448
538
  } finally {
539
+ this.recentCalls.push({
540
+ method: (init.method || "GET").toUpperCase(),
541
+ path: sanitizePathForBreadcrumb(url),
542
+ status,
543
+ duration_ms: Date.now() - startedAt,
544
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
545
+ });
449
546
  cleanup();
450
547
  }
451
548
  }
@@ -8399,8 +8496,8 @@ var AnalyticsAPI = class {
8399
8496
  * @example
8400
8497
  * ```ts
8401
8498
  * // 로그인 성공 직후
8402
- * await cb.auth.signIn({ email, password })
8403
- * cb.analytics.identify(member.id)
8499
+ * const member = await cb.auth.signInMember({ login_id, password })
8500
+ * cb.analytics.identify(member.member_id)
8404
8501
  * ```
8405
8502
  */
8406
8503
  identify(memberId) {
@@ -8982,6 +9079,174 @@ var AnalyticsAPI = class {
8982
9079
  }
8983
9080
  };
8984
9081
 
9082
+ // src/api/support.ts
9083
+ var SupportAPI = class {
9084
+ constructor(http) {
9085
+ this.http = http;
9086
+ }
9087
+ /**
9088
+ * 앱 운영자에게 이슈/문의/요청을 발행한다.
9089
+ *
9090
+ * 로그인된 사용자(AppMember)면 신뢰 등급이 높고 reporter_member_id 가 자동으로 채워진다.
9091
+ * 익명 발행도 가능하나 운영자가 reCAPTCHA 를 활성화한 경우 `recaptchaToken` 이 권장된다.
9092
+ *
9093
+ * @returns 발행된 이슈 id + 초기 status (`open`) + 생성 시각.
9094
+ * @throws ApiError — 본문 길이 초과 / 쿼터 초과(429) / reCAPTCHA 거부(403) 등.
9095
+ */
9096
+ async reportIssue(req) {
9097
+ const body = {
9098
+ title: req.title,
9099
+ body: req.body,
9100
+ category: req.category,
9101
+ metadata: req.metadata
9102
+ };
9103
+ if (req.anonymousEmail) body.anonymous_email = req.anonymousEmail;
9104
+ if (req.recaptchaToken) body.recaptcha_token = req.recaptchaToken;
9105
+ return this.http.post("/v1/public/reports", body);
9106
+ }
9107
+ /**
9108
+ * ConnectBase 플랫폼 자체 버그/요청/문의를 발행한다.
9109
+ *
9110
+ * `reportIssue` 와 다른 점: target 이 앱 운영자가 아닌 **ConnectBase 운영팀**.
9111
+ * SDK 가 자체 버그를 던지거나, 결제/문서/플랫폼 동작이 이상할 때 사용.
9112
+ *
9113
+ * 자동 첨부 (opt-out 가능):
9114
+ * - SDK 버전 + 플랫폼 (web/node)
9115
+ * - 마지막 N(20)개 API 호출 breadcrumb (PII strip 후)
9116
+ * - error 객체의 stack trace (sanitize)
9117
+ *
9118
+ * @example SDK 가 throw 한 에러를 자동 첨부해서 발행
9119
+ * ```typescript
9120
+ * try {
9121
+ * await cb.functions.invoke('foo', {})
9122
+ * } catch (err) {
9123
+ * await cb.support.reportPlatformBug({
9124
+ * title: 'functions.invoke 가 504 만 반환',
9125
+ * body: '같은 인자로 5분 째 504. 콘솔에선 정상.',
9126
+ * category: 'sdk',
9127
+ * severity: 'high',
9128
+ * error: err as Error,
9129
+ * })
9130
+ * }
9131
+ * ```
9132
+ */
9133
+ async reportPlatformBug(req) {
9134
+ const attachContext = req.attachAutomaticContext !== false;
9135
+ const body = {
9136
+ title: req.title,
9137
+ body: req.body,
9138
+ category: req.category ?? "other",
9139
+ severity: req.severity ?? "medium",
9140
+ metadata: req.metadata
9141
+ };
9142
+ if (req.reporterEmail) body.reporter_email = req.reporterEmail;
9143
+ if (req.recaptchaToken) body.recaptcha_token = req.recaptchaToken;
9144
+ if (attachContext) {
9145
+ body.sdk_version = req.sdkVersion ?? detectSdkVersion();
9146
+ body.sdk_platform = req.sdkPlatform ?? detectSdkPlatform();
9147
+ body.environment = req.environment ?? "unknown";
9148
+ if (req.error) {
9149
+ body.stack_trace = sanitizeStackTrace(req.error.stack ?? String(req.error));
9150
+ } else if (req.stackTrace) {
9151
+ body.stack_trace = sanitizeStackTrace(req.stackTrace);
9152
+ }
9153
+ const calls = req.recentApiCalls ?? this.http.getRecentCalls();
9154
+ if (calls.length > 0) {
9155
+ body.recent_api_calls = calls;
9156
+ }
9157
+ }
9158
+ return this.http.post(
9159
+ "/v1/public/platform-issues",
9160
+ body
9161
+ );
9162
+ }
9163
+ /** 디버깅용: 마지막 N개 API 호출 breadcrumb. PII 제거 후 저장돼있다. */
9164
+ getRecentApiCalls() {
9165
+ return this.http.getRecentCalls();
9166
+ }
9167
+ /** breadcrumb buffer 비우기 (사용자 명시적 요청 / 프라이버시). */
9168
+ clearRecentApiCalls() {
9169
+ this.http.clearRecentCalls();
9170
+ }
9171
+ /**
9172
+ * Platform issue 의 reply thread 조회.
9173
+ *
9174
+ * 본인이 발행한 issue 만 조회 가능 (server-side ownership guard). admin 의 internal 메모는 응답 제외.
9175
+ *
9176
+ * @param issueId - `reportPlatformBug` 가 반환한 id
9177
+ * @throws ApiError 404 — 본인 issue 가 아니거나 존재하지 않음
9178
+ */
9179
+ async listPlatformIssueReplies(issueId) {
9180
+ const res = await this.http.get(
9181
+ `/v1/public/platform-issues/${issueId}/comments`
9182
+ );
9183
+ return res.comments;
9184
+ }
9185
+ /**
9186
+ * Platform issue 에 follow-up reply 작성.
9187
+ *
9188
+ * 단말 상태(resolved/wontfix/duplicate) issue 는 reply 거부 — 새 issue 발행 권장.
9189
+ */
9190
+ async replyToPlatformIssue(issueId, body) {
9191
+ return this.http.post(
9192
+ `/v1/public/platform-issues/${issueId}/comments`,
9193
+ { body }
9194
+ );
9195
+ }
9196
+ /**
9197
+ * 본인이 발행한 platform issue 의 처리 진행 상황을 단건 조회.
9198
+ *
9199
+ * AI 가 "내가 발행한 이슈 처리됐어?" 를 폴링하는 표준 경로. status / resolution_note /
9200
+ * triage_summary / external_links 로 ConnectBase 운영팀의 처리 상태를 확인.
9201
+ *
9202
+ * @throws ApiError 404 — 본인 issue 가 아니거나 존재하지 않음
9203
+ */
9204
+ async getPlatformIssue(issueId) {
9205
+ return this.http.get(
9206
+ `/v1/public/platform-issues/${issueId}`
9207
+ );
9208
+ }
9209
+ /**
9210
+ * 본인 app 으로 발행한 platform issue 목록 (cursor 페이지네이션).
9211
+ *
9212
+ * status/severity/category 필터 + `since_updated_at` 으로 미해결만 폴링하는 사용 패턴 권장:
9213
+ *
9214
+ * ```typescript
9215
+ * const { issues } = await cb.support.listMyPlatformIssues({ status: ['open', 'triaged', 'in_progress'] })
9216
+ * ```
9217
+ */
9218
+ async listMyPlatformIssues(opts = {}) {
9219
+ const params = new URLSearchParams();
9220
+ for (const s of opts.status ?? []) params.append("status", s);
9221
+ for (const s of opts.severity ?? []) params.append("severity", s);
9222
+ for (const s of opts.category ?? []) params.append("category", s);
9223
+ if (opts.sinceUpdatedAt) params.set("since_updated_at", opts.sinceUpdatedAt);
9224
+ if (opts.cursor) params.set("cursor", opts.cursor);
9225
+ if (typeof opts.limit === "number") params.set("limit", String(opts.limit));
9226
+ const query = params.toString();
9227
+ return this.http.get(
9228
+ `/v1/public/platform-issues${query ? "?" + query : ""}`
9229
+ );
9230
+ }
9231
+ };
9232
+ function detectSdkVersion() {
9233
+ if (typeof __SDK_VERSION__ !== "undefined" && __SDK_VERSION__) {
9234
+ return __SDK_VERSION__;
9235
+ }
9236
+ return "unknown";
9237
+ }
9238
+ function detectSdkPlatform() {
9239
+ return typeof window !== "undefined" && typeof document !== "undefined" ? "web" : "node";
9240
+ }
9241
+ function sanitizeStackTrace(raw) {
9242
+ let s = raw;
9243
+ s = s.replace(/(\(|\s|^)(?:file:\/\/)?\/[^\s)]+\/([^\s/)]+)(:\d+:\d+)?/g, "$1$2$3");
9244
+ s = s.replace(/(https?:\/\/[^\s)?]+)\?[^\s)]*/g, "$1");
9245
+ const max = 32 * 1024;
9246
+ if (s.length > max) s = s.slice(0, max) + "\n\u2026[truncated]";
9247
+ return s;
9248
+ }
9249
+
8985
9250
  // src/types/knowledge.ts
8986
9251
  var AUTH_MEMBER_ID_TOKEN = "$auth.member_id";
8987
9252
 
@@ -9646,7 +9911,8 @@ var ConnectBase = class {
9646
9911
  onError: config.onError,
9647
9912
  onTokenRefresh: config.onTokenRefresh,
9648
9913
  onAuthError: config.onAuthError,
9649
- onTokenExpired: config.onTokenExpired
9914
+ onTokenExpired: config.onTokenExpired,
9915
+ onTransientRefreshFailure: config.onTransientRefreshFailure
9650
9916
  };
9651
9917
  this.http = new HttpClient(httpConfig);
9652
9918
  this.auth = new AuthAPI(this.http);
@@ -9670,6 +9936,7 @@ var ConnectBase = class {
9670
9936
  this.queue = new QueueAPI(this.http);
9671
9937
  this.analytics = new AnalyticsAPI(this.http);
9672
9938
  this.endpoint = new EndpointAPI(this.http);
9939
+ this.support = new SupportAPI(this.http);
9673
9940
  this.auth._attachAnalytics(this.analytics);
9674
9941
  const shouldAutoRestore = config.autoRestoreSession ?? true;
9675
9942
  if (shouldAutoRestore && typeof window !== "undefined") {