connectbase-client 6.10.0 → 6.11.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
@@ -623,6 +623,89 @@ var AIAPI = class {
623
623
  }
624
624
  };
625
625
 
626
+ // src/core/url-secrets.ts
627
+ var CREDENTIAL_PARAMS = /* @__PURE__ */ new Set([
628
+ "access_token",
629
+ "refresh_token",
630
+ "id_token",
631
+ "token",
632
+ "auth_token",
633
+ "session_token",
634
+ "jwt",
635
+ "client_secret",
636
+ "secret_key",
637
+ "api_key",
638
+ "apikey",
639
+ "code_verifier",
640
+ "password"
641
+ ]);
642
+ var TOKEN_CALLBACK_COMPANIONS = /* @__PURE__ */ new Set([
643
+ "member_id",
644
+ "is_new_member",
645
+ "email",
646
+ "state"
647
+ ]);
648
+ var CREDENTIAL_IN_TEXT = /\b(access_token|refresh_token|id_token|token|auth_token|session_token|jwt|client_secret|secret_key|api_key|apikey|code_verifier)=[^&#\s"'<>)\]]+/gi;
649
+ function paramName(pair) {
650
+ const raw = pair.split("=", 1)[0].replace(/\+/g, " ");
651
+ try {
652
+ return decodeURIComponent(raw).toLowerCase();
653
+ } catch {
654
+ return raw.toLowerCase();
655
+ }
656
+ }
657
+ function filterParams(raw, shouldRemove) {
658
+ if (!raw) return raw;
659
+ const pairs = raw.split("&");
660
+ const kept = pairs.filter((pair) => !shouldRemove(paramName(pair)));
661
+ return kept.length === pairs.length ? raw : kept.join("&");
662
+ }
663
+ function scrubParams(raw) {
664
+ if (!raw) return raw;
665
+ const names = new Set(raw.split("&").map(paramName));
666
+ const hasToken = names.has("access_token") || names.has("refresh_token");
667
+ const isCodeCallback = names.has("code") && names.has("state");
668
+ return filterParams(
669
+ raw,
670
+ (name) => CREDENTIAL_PARAMS.has(name) || hasToken && TOKEN_CALLBACK_COMPANIONS.has(name) || isCodeCallback && (name === "code" || name === "state")
671
+ );
672
+ }
673
+ function scrubFragment(fragment) {
674
+ const queryAt = fragment.indexOf("?");
675
+ if (queryAt >= 0) {
676
+ const head = fragment.slice(0, queryAt);
677
+ const clean = scrubParams(fragment.slice(queryAt + 1));
678
+ return clean ? `${head}?${clean}` : head;
679
+ }
680
+ return fragment.includes("=") ? scrubParams(fragment) : fragment;
681
+ }
682
+ function mapUrlParts(url, mapQuery, mapFragment) {
683
+ const hashAt = url.indexOf("#");
684
+ const beforeHash = hashAt >= 0 ? url.slice(0, hashAt) : url;
685
+ const queryAt = beforeHash.indexOf("?");
686
+ const path = queryAt >= 0 ? beforeHash.slice(0, queryAt) : beforeHash;
687
+ const rawQuery = queryAt >= 0 ? beforeHash.slice(queryAt + 1) : "";
688
+ const rawFragment = hashAt >= 0 ? url.slice(hashAt + 1) : "";
689
+ const query = mapQuery(rawQuery);
690
+ const fragment = mapFragment(rawFragment);
691
+ if (query === rawQuery && fragment === rawFragment) return url;
692
+ return `${path}${query ? `?${query}` : ""}${fragment ? `#${fragment}` : ""}`;
693
+ }
694
+ function scrubUrlSecrets(url) {
695
+ return url ? mapUrlParts(url, scrubParams, scrubFragment) : url;
696
+ }
697
+ function scrubSecretsInText(text) {
698
+ return text.replace(CREDENTIAL_IN_TEXT, "$1=[redacted]");
699
+ }
700
+ function removeQueryParams(url, names) {
701
+ const targets = new Set(names);
702
+ return mapUrlParts(
703
+ url,
704
+ (query) => filterParams(query, (name) => targets.has(name)),
705
+ (fragment) => fragment
706
+ );
707
+ }
708
+
626
709
  // src/api/analytics.ts
627
710
  var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
628
711
  var SESSION_KEY = "__cb_session";
@@ -888,9 +971,9 @@ var AnalyticsAPI = class {
888
971
  this.session.touch();
889
972
  const event = this.createBaseEvent("page_view");
890
973
  event.page_path = path || window.location.pathname;
891
- event.page_url = window.location.href;
974
+ event.page_url = scrubUrlSecrets(window.location.href);
892
975
  event.page_title = document.title;
893
- event.referrer = document.referrer || void 0;
976
+ event.referrer = scrubUrlSecrets(document.referrer) || void 0;
894
977
  event.screen_width = window.screen.width;
895
978
  event.screen_height = window.screen.height;
896
979
  Object.assign(event, this.utm);
@@ -906,7 +989,7 @@ var AnalyticsAPI = class {
906
989
  event.event_name = name;
907
990
  event.event_properties = properties;
908
991
  event.page_path = window.location.pathname;
909
- event.page_url = window.location.href;
992
+ event.page_url = scrubUrlSecrets(window.location.href);
910
993
  this.enqueue(event);
911
994
  }
912
995
  /**
@@ -1392,8 +1475,8 @@ var AnalyticsAPI = class {
1392
1475
  trackSessionStart() {
1393
1476
  const event = this.createBaseEvent("session_start");
1394
1477
  event.page_path = window.location.pathname;
1395
- event.page_url = window.location.href;
1396
- event.referrer = document.referrer || void 0;
1478
+ event.page_url = scrubUrlSecrets(window.location.href);
1479
+ event.referrer = scrubUrlSecrets(document.referrer) || void 0;
1397
1480
  event.screen_width = window.screen.width;
1398
1481
  event.screen_height = window.screen.height;
1399
1482
  Object.assign(event, this.utm);
@@ -2558,6 +2641,8 @@ var DatabaseAPI = class {
2558
2641
  * `options.autoCreate: true` 시 테이블이 없으면 빈 schemaless 테이블을
2559
2642
  * 자동으로 생성한 뒤 insert. opt-in 이며 프로토타입/AI 자동화 워크플로에
2560
2643
  * 권장합니다. production 앱은 명시적 `createTable` 호출 권장.
2644
+ * 테이블 생성은 `secretKey` 나 service_role 함수에서만 됩니다. Public Key 만으로는
2645
+ * 옵션이 무시되어, 없는 테이블이면 404 입니다 (이미 있는 테이블에는 그대로 씁니다).
2561
2646
  */
2562
2647
  async createData(tableIdOrName, data, options) {
2563
2648
  const prefix = this.getPublicPrefix();
@@ -3031,13 +3116,13 @@ var DatabaseAPI = class {
3031
3116
  * // 자기 앱 테이블 구독 (AppMember JWT)
3032
3117
  * cb.database.connectRealtime({
3033
3118
  * accessToken: 'your-jwt-token',
3034
- * dataServerUrl: 'https://data.connectbase.world'
3119
+ * dataServerUrl: 'https://api.connectbase.world'
3035
3120
  * })
3036
3121
  *
3037
3122
  * // cross-app: Provider 앱의 테이블 구독 (OAuth access token)
3038
3123
  * cb.database.connectRealtime({
3039
3124
  * accessToken: oauthAccessToken,
3040
- * dataServerUrl: 'https://data.connectbase.world'
3125
+ * dataServerUrl: 'https://api.connectbase.world'
3041
3126
  * })
3042
3127
  *
3043
3128
  * // 테이블 구독
@@ -3741,6 +3826,18 @@ async function drainBody(res) {
3741
3826
  }
3742
3827
 
3743
3828
  // src/api/error-tracker.ts
3829
+ function scrubReport(report) {
3830
+ const text = (value) => typeof value === "string" ? scrubSecretsInText(value) : value;
3831
+ const url = (value) => typeof value === "string" ? scrubUrlSecrets(value) : value;
3832
+ return {
3833
+ ...report,
3834
+ message: text(report.message),
3835
+ stack: text(report.stack),
3836
+ url: url(report.url),
3837
+ referrer: url(report.referrer),
3838
+ source: url(report.source)
3839
+ };
3840
+ }
3744
3841
  var ErrorTrackerAPI = class {
3745
3842
  constructor(http, config = {}) {
3746
3843
  this.storageWebId = null;
@@ -3919,7 +4016,7 @@ var ErrorTrackerAPI = class {
3919
4016
  return filtered;
3920
4017
  }
3921
4018
  queueError(report) {
3922
- this.errorQueue.push(report);
4019
+ this.errorQueue.push(scrubReport(report));
3923
4020
  this.log("Error queued", {
3924
4021
  message: report.message,
3925
4022
  queueSize: this.errorQueue.length
@@ -4231,6 +4328,43 @@ function toCreateRoomWire(config) {
4231
4328
  if (config.metadata) out.metadata = config.metadata;
4232
4329
  return out;
4233
4330
  }
4331
+ var FORBIDDEN_STATE_PATH_SEGMENTS = /* @__PURE__ */ new Set([
4332
+ "__proto__",
4333
+ "constructor",
4334
+ "prototype"
4335
+ ]);
4336
+ var hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key);
4337
+ function applyGameStateChange(state, change) {
4338
+ const segments = typeof change.path === "string" ? change.path.split(".") : null;
4339
+ if (!segments || segments.some((segment) => FORBIDDEN_STATE_PATH_SEGMENTS.has(segment))) {
4340
+ console.warn(
4341
+ "[connect-base game] \uC704\uD5D8\uD55C \uACBD\uB85C\uC758 \uC0C1\uD0DC \uBCC0\uACBD\uC744 \uBB34\uC2DC\uD588\uC2B5\uB2C8\uB2E4:",
4342
+ String(change.path).slice(0, 100)
4343
+ );
4344
+ return false;
4345
+ }
4346
+ const isDelete = change.operation === "delete";
4347
+ let current = state;
4348
+ for (const key of segments.slice(0, -1)) {
4349
+ const next = hasOwn(current, key) ? current[key] : void 0;
4350
+ if (next !== null && typeof next === "object") {
4351
+ current = next;
4352
+ } else if (isDelete) {
4353
+ return true;
4354
+ } else {
4355
+ const created = {};
4356
+ current[key] = created;
4357
+ current = created;
4358
+ }
4359
+ }
4360
+ const lastKey = segments[segments.length - 1];
4361
+ if (isDelete) {
4362
+ if (hasOwn(current, lastKey)) delete current[lastKey];
4363
+ } else {
4364
+ current[lastKey] = change.value;
4365
+ }
4366
+ return true;
4367
+ }
4234
4368
  var GameRoom = class {
4235
4369
  constructor(config) {
4236
4370
  this.ws = null;
@@ -4747,21 +4881,7 @@ var GameRoom = class {
4747
4881
  }
4748
4882
  applyChange(change) {
4749
4883
  if (!this._state) return;
4750
- const path = change.path.split(".");
4751
- let current = this._state.state;
4752
- for (let i = 0; i < path.length - 1; i++) {
4753
- const key = path[i];
4754
- if (!(key in current)) {
4755
- current[key] = {};
4756
- }
4757
- current = current[key];
4758
- }
4759
- const lastKey = path[path.length - 1];
4760
- if (change.operation === "delete") {
4761
- delete current[lastKey];
4762
- } else {
4763
- current[lastKey] = change.value;
4764
- }
4884
+ applyGameStateChange(this._state.state, change);
4765
4885
  }
4766
4886
  handlePlayerEvent(msg) {
4767
4887
  const player = {
@@ -5443,6 +5563,7 @@ var KnowledgeAPI = class {
5443
5563
  * 문서 추가
5444
5564
  *
5445
5565
  * 지식 베이스에 새 문서를 추가합니다. 텍스트 소스인 경우 즉시 처리됩니다.
5566
+ * 로그인한 회원 토큰(본인 문서)이나 `secretKey` 가 필요합니다. Public Key 만으로는 401 입니다.
5446
5567
  *
5447
5568
  * @param kbID - 지식 베이스 ID
5448
5569
  * @param data - 문서 생성 요청
@@ -5598,6 +5719,7 @@ var KnowledgeAPI = class {
5598
5719
  * 문서 삭제
5599
5720
  *
5600
5721
  * 문서와 관련된 모든 청크도 함께 삭제됩니다.
5722
+ * 로그인한 회원 토큰(본인 문서)이나 `secretKey` 가 필요합니다. Public Key 만으로는 401 입니다.
5601
5723
  *
5602
5724
  * @param kbID - 지식 베이스 ID
5603
5725
  * @param docID - 문서 ID
@@ -6536,6 +6658,77 @@ var NativeAPI = class {
6536
6658
  }
6537
6659
  };
6538
6660
 
6661
+ // src/core/oauth-state.ts
6662
+ var STORAGE_KEY = "cb_oauth_pending_states";
6663
+ var PENDING_TTL_MS = 60 * 60 * 1e3;
6664
+ var MAX_PENDING = 8;
6665
+ var OAUTH_STATE_MISMATCH = "oauth_state_mismatch";
6666
+ function browserStorage() {
6667
+ try {
6668
+ return typeof window !== "undefined" ? window.localStorage : null;
6669
+ } catch {
6670
+ return null;
6671
+ }
6672
+ }
6673
+ function readPending(storage, now) {
6674
+ try {
6675
+ const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || "[]");
6676
+ if (!Array.isArray(parsed)) return [];
6677
+ return parsed.filter(
6678
+ (entry) => typeof entry?.nonce === "string" && typeof entry?.expiresAt === "number" && entry.expiresAt > now
6679
+ );
6680
+ } catch {
6681
+ return [];
6682
+ }
6683
+ }
6684
+ function writePending(storage, entries) {
6685
+ if (entries.length === 0) {
6686
+ storage.removeItem(STORAGE_KEY);
6687
+ } else {
6688
+ storage.setItem(STORAGE_KEY, JSON.stringify(entries));
6689
+ }
6690
+ }
6691
+ function randomNonce() {
6692
+ const bytes = new Uint8Array(16);
6693
+ globalThis.crypto.getRandomValues(bytes);
6694
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
6695
+ }
6696
+ function createPendingOAuthState(appState) {
6697
+ const storage = browserStorage();
6698
+ const nonce = randomNonce();
6699
+ try {
6700
+ if (!storage) throw new Error("storage unavailable");
6701
+ const now = Date.now();
6702
+ const entries = readPending(storage, now);
6703
+ entries.push({
6704
+ nonce,
6705
+ expiresAt: now + PENDING_TTL_MS,
6706
+ ...appState ? { appState } : {}
6707
+ });
6708
+ writePending(storage, entries.slice(-MAX_PENDING));
6709
+ } catch {
6710
+ throw new Error(
6711
+ "\uBE0C\uB77C\uC6B0\uC800 \uC800\uC7A5\uC18C\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC5B4 \uC18C\uC15C \uB85C\uADF8\uC778\uC744 \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uCFE0\uD0A4\uC640 \uC0AC\uC774\uD2B8 \uB370\uC774\uD130 \uCC28\uB2E8\uC744 \uD574\uC81C\uD55C \uB4A4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574 \uC8FC\uC138\uC694."
6712
+ );
6713
+ }
6714
+ return nonce;
6715
+ }
6716
+ function consumePendingOAuthState(state) {
6717
+ if (!state) return { valid: false };
6718
+ const storage = browserStorage();
6719
+ if (!storage) return { valid: false };
6720
+ try {
6721
+ const entries = readPending(storage, Date.now());
6722
+ const index = entries.findIndex((entry) => entry.nonce === state);
6723
+ if (index < 0) return { valid: false };
6724
+ const [matched] = entries.splice(index, 1);
6725
+ writePending(storage, entries);
6726
+ return { valid: true, appState: matched.appState };
6727
+ } catch {
6728
+ return { valid: false };
6729
+ }
6730
+ }
6731
+
6539
6732
  // src/api/oauth.ts
6540
6733
  function getNativeOAuthBridge() {
6541
6734
  if (typeof window === "undefined") return null;
@@ -6545,6 +6738,71 @@ function getNativeOAuthBridge() {
6545
6738
  }
6546
6739
  return bridge;
6547
6740
  }
6741
+ var POPUP_OPENER_NOT_ALLOWED = "popup_opener_not_allowed";
6742
+ var TOKEN_CALLBACK_PARAMS = [
6743
+ "access_token",
6744
+ "refresh_token",
6745
+ "member_id",
6746
+ "is_new_member",
6747
+ "email",
6748
+ "state"
6749
+ ];
6750
+ var CODE_CALLBACK_PARAMS = ["code", "state"];
6751
+ var capturedTokenCallbacks = /* @__PURE__ */ new WeakMap();
6752
+ function hasSameOriginOpener() {
6753
+ try {
6754
+ return !!window.opener && window.opener.location.origin === window.location.origin;
6755
+ } catch {
6756
+ return false;
6757
+ }
6758
+ }
6759
+ function stripParamsFromUrl(names) {
6760
+ try {
6761
+ const { pathname, search, hash } = window.location;
6762
+ window.history.replaceState(
6763
+ window.history.state,
6764
+ "",
6765
+ removeQueryParams(`${pathname}${search}${hash}`, names)
6766
+ );
6767
+ } catch {
6768
+ }
6769
+ }
6770
+ function captureTokenCallback() {
6771
+ if (typeof window === "undefined" || !window.location) return null;
6772
+ const existing = capturedTokenCallbacks.get(window);
6773
+ const params = new URLSearchParams(window.location.search);
6774
+ const accessToken = params.get("access_token");
6775
+ const refreshToken = params.get("refresh_token");
6776
+ const inUrl = !!accessToken && !!refreshToken;
6777
+ if (!inUrl) {
6778
+ return existing && existing.href === window.location.href ? existing : null;
6779
+ }
6780
+ if (existing?.accessToken === accessToken) return existing;
6781
+ stripParamsFromUrl(TOKEN_CALLBACK_PARAMS);
6782
+ const memberId = params.get("member_id");
6783
+ if (!memberId) return null;
6784
+ const state = params.get("state");
6785
+ const check = consumePendingOAuthState(state);
6786
+ const captured = {
6787
+ // state 가 맞으면 opener 가 있어도 리다이렉트다(같은 출처 창이 연 창에서 signIn 을 부른 경우).
6788
+ kind: check.valid ? "redirect" : hasSameOriginOpener() ? "popup" : "rejected",
6789
+ accessToken,
6790
+ refreshToken,
6791
+ memberId,
6792
+ isNewMember: params.get("is_new_member") === "true",
6793
+ email: params.get("email"),
6794
+ appState: check.valid ? check.appState : state || void 0,
6795
+ href: window.location.href
6796
+ };
6797
+ capturedTokenCallbacks.set(window, captured);
6798
+ if (captured.kind === "rejected") warnStateMismatch();
6799
+ return captured;
6800
+ }
6801
+ function warnStateMismatch() {
6802
+ console.warn(
6803
+ "[connect-base-client] OAuth \uCF5C\uBC31\uC744 \uBC1B\uC544\uB4E4\uC774\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uC774 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC2DC\uC791\uD55C \uB85C\uADF8\uC778\uC758 \uACB0\uACFC\uC784\uC744 \uD655\uC778\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4(state \uBD88\uC77C\uCE58). \uB0A8\uC774 \uBCF4\uB0B8 \uB9C1\uD06C\uB77C\uBA74 \uC815\uC0C1 \uB3D9\uC791\uC785\uB2C8\uB2E4. \uC815\uC0C1 \uB85C\uADF8\uC778\uC774 \uAC70\uBD80\uB41C\uB2E4\uBA74 cb.oauth.signIn() \uC744 \uBD80\uB978 \uD398\uC774\uC9C0\uC640 \uCF5C\uBC31 \uC8FC\uC18C\uC758 \uCD9C\uCC98\uAC00 \uAC19\uC740\uC9C0, \uC778\uAC00 \uC8FC\uC18C\uB97C \uC9C1\uC811 \uB9CC\uB4E0\uB2E4\uBA74 cb.oauth.createCallbackState() \uAC12\uC744 state \uB85C \uB118\uACBC\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694."
6804
+ );
6805
+ }
6548
6806
  function detectInAppBrowser() {
6549
6807
  if (typeof navigator === "undefined") return null;
6550
6808
  const ua = navigator.userAgent || "";
@@ -6582,6 +6840,10 @@ var OAuthAPI = class {
6582
6840
  * 발화하지 않도록 이 promise 를 공유한다 (이중 re-issue/rotation 방지).
6583
6841
  */
6584
6842
  this.bootConsumePromise = null;
6843
+ /** 이 인스턴스가 `getCallbackResult()` 로 이미 적재한 콜백. 다시 불려도 토큰을 되돌리지 않는다. */
6844
+ this.loadedCallback = null;
6845
+ /** 진행 중이거나 끝난 code 교환. 주소에서 code 를 지운 뒤의 재호출이 같은 결과를 받게 한다. */
6846
+ this.codeExchange = null;
6585
6847
  }
6586
6848
  /**
6587
6849
  * 활성화된 OAuth 프로바이더 목록 조회
@@ -6611,7 +6873,8 @@ var OAuthAPI = class {
6611
6873
  *
6612
6874
  * @param provider - OAuth 프로바이더 (google, naver, github, discord)
6613
6875
  * @param callbackUrl - OAuth 완료 후 리다이렉트될 앱의 URL
6614
- * @param state - 선택적 state 파라미터 (CSRF 방지 등)
6876
+ * @param state - 앱이 콜백까지 들고 가고 싶은 값(선택). `getCallbackResult()` 결과의 `state` 로 돌아온다.
6877
+ * 로그인 CSRF 방어는 SDK 가 별도의 일회용 값으로 항상 수행하므로 이 값이 없어도 안전하다.
6615
6878
  * @param options - `createIfNotExists: true` 면 미가입 사용자를 자동 가입시킨다([signUp] 과 동일 동작).
6616
6879
  *
6617
6880
  * @example
@@ -6656,9 +6919,9 @@ var OAuthAPI = class {
6656
6919
  const callbackScheme = bridge.getCallbackScheme();
6657
6920
  const bridgeParams = new URLSearchParams({
6658
6921
  app_callback: callbackScheme,
6659
- intent
6922
+ intent,
6923
+ state: this.createCallbackStateFor(callbackUrl, state)
6660
6924
  });
6661
- if (state) bridgeParams.append("state", state);
6662
6925
  const bridgeResp = await this.http.get(
6663
6926
  `/v1/public/oauth/${provider}/authorize/central?${bridgeParams.toString()}`
6664
6927
  );
@@ -6683,15 +6946,52 @@ var OAuthAPI = class {
6683
6946
  `\uD604\uC7AC ${inApp.type === "kakaotalk" ? "\uCE74\uCE74\uC624\uD1A1" : inApp.type === "line" ? "\uB77C\uC778" : "\uC778\uC571"} \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C\uB294 Google/Apple \uB85C\uADF8\uC778\uC774 \uC81C\uD55C\uB429\uB2C8\uB2E4. \uC6B0\uCE21 \uC0C1\uB2E8 \uBA54\uB274\uC5D0\uC11C "\uB2E4\uB978 \uBE0C\uB77C\uC6B0\uC800\uB85C \uC5F4\uAE30"\uB97C \uC120\uD0DD\uD574\uC8FC\uC138\uC694.`
6684
6947
  );
6685
6948
  }
6686
- const params = new URLSearchParams({ app_callback: callbackUrl, intent });
6687
- if (state) {
6688
- params.append("state", state);
6689
- }
6949
+ const params = new URLSearchParams({
6950
+ app_callback: callbackUrl,
6951
+ intent,
6952
+ state: this.createCallbackStateFor(callbackUrl, state)
6953
+ });
6690
6954
  const response = await this.http.get(
6691
6955
  `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
6692
6956
  );
6693
6957
  window.location.href = response.authorization_url;
6694
6958
  }
6959
+ /**
6960
+ * 콜백에서 확인할 일회용 state 를 만들어 이 브라우저에 저장합니다.
6961
+ *
6962
+ * `signIn()` / `signUp()` 은 이미 내부에서 부르므로 호출할 필요가 없습니다. 인가 주소를 SDK 밖에서
6963
+ * 만드는 앱(`GET /v1/public/oauth/:provider/authorize/central` 직접 호출 등)만, 돌려받은 값을 `state`
6964
+ * 쿼리로 넘기세요. 이 값 없이 돌아온 콜백의 토큰은 남이 보낸 링크와 구분할 수 없어 거부됩니다.
6965
+ *
6966
+ * @param appState - 콜백까지 들고 갈 앱의 값(선택). 서버로 보내지 않고 `getCallbackResult()` 의 `state` 로 돌아옵니다.
6967
+ * @returns 인가 요청의 `state` 쿼리에 넣을 값
6968
+ * @throws 브라우저 저장소를 쓸 수 없을 때
6969
+ *
6970
+ * @example
6971
+ * ```typescript
6972
+ * const state = cb.oauth.createCallbackState()
6973
+ * const res = await fetch(`${API}/v1/public/oauth/google/authorize/central?app_callback=${callback}&state=${state}`, {
6974
+ * headers: { 'X-Public-Key': PUBLIC_KEY },
6975
+ * })
6976
+ * ```
6977
+ */
6978
+ createCallbackState(appState) {
6979
+ return createPendingOAuthState(appState);
6980
+ }
6981
+ /** 콜백 주소가 다른 출처면 그쪽 저장소에는 state 가 없어 콜백에서 거부된다는 점을 시작할 때 알린다. */
6982
+ createCallbackStateFor(callbackUrl, appState) {
6983
+ const state = createPendingOAuthState(appState);
6984
+ try {
6985
+ const target = new URL(callbackUrl, window.location.href);
6986
+ if (target.origin !== window.location.origin) {
6987
+ console.warn(
6988
+ `[connect-base-client] \uCF5C\uBC31 \uC8FC\uC18C(${target.origin})\uAC00 \uC9C0\uAE08 \uD398\uC774\uC9C0\uC640 \uCD9C\uCC98\uAC00 \uB2EC\uB77C SDK \uAC00 \uD1A0\uD070\uC744 \uBC1B\uC544\uB4E4\uC774\uC9C0 \uBABB\uD569\uB2C8\uB2E4. \uB85C\uADF8\uC778\uC744 \uC2DC\uC791\uD55C \uD398\uC774\uC9C0\uC640 \uAC19\uC740 \uCD9C\uCC98\uB97C \uCF5C\uBC31\uC73C\uB85C \uC9C0\uC815\uD558\uC138\uC694.`
6989
+ );
6990
+ }
6991
+ } catch {
6992
+ }
6993
+ return state;
6994
+ }
6695
6995
  /**
6696
6996
  * 소셜 로그인 (팝업 방식)
6697
6997
  * 팝업 창에서 소셜 로그인을 처리하고 결과를 Promise로 반환합니다.
@@ -6704,6 +7004,7 @@ var OAuthAPI = class {
6704
7004
  *
6705
7005
  * @param provider - OAuth 프로바이더
6706
7006
  * @param callbackUrl - (선택) OAuth 완료 후 리다이렉트될 팝업의 URL. 미지정 시 서버 제공 콜백 사용.
7007
+ * 지정한다면 이 메서드를 부르는 페이지와 같은 출처여야 합니다(콜백 페이지는 토큰을 같은 출처 창에만 넘깁니다).
6707
7008
  * @returns 로그인 결과
6708
7009
  *
6709
7010
  * @example
@@ -6762,10 +7063,24 @@ var OAuthAPI = class {
6762
7063
  params.set("intent", intent);
6763
7064
  if (callbackUrl) {
6764
7065
  params.set("app_callback", callbackUrl);
7066
+ } else {
7067
+ params.set("opener_origin", window.location.origin);
7068
+ }
7069
+ let response;
7070
+ try {
7071
+ response = await this.http.get(
7072
+ `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
7073
+ );
7074
+ } catch (err) {
7075
+ if (err instanceof ApiError && err.code === POPUP_OPENER_NOT_ALLOWED) {
7076
+ throw new ApiError(
7077
+ err.statusCode,
7078
+ `\uC774 \uD398\uC774\uC9C0(${window.location.origin})\uC5D0\uC11C\uB294 \uD31D\uC5C5 \uB85C\uADF8\uC778\uC744 \uC2DC\uC791\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uC774 \uC571\uC758 \uC6F9 \uC2A4\uD1A0\uB9AC\uC9C0 \uC8FC\uC18C\uB098 \uC5F0\uACB0\uB41C \uCEE4\uC2A4\uD140 \uB3C4\uBA54\uC778\uC5D0\uC11C \uC5EC\uC138\uC694. \uB2E4\uB978 \uC8FC\uC18C\uB77C\uBA74 \uCF58\uC194\uC758 \uC6F9 \uC2A4\uD1A0\uB9AC\uC9C0 \uBCF4\uC548 \uC124\uC815\uC5D0 \uC774 \uCD9C\uCC98\uB97C \uD5C8\uC6A9 origin \uC73C\uB85C \uCD94\uAC00\uD558\uAC70\uB098 \uB9AC\uB2E4\uC774\uB809\uD2B8 \uBC29\uC2DD(cb.oauth.signIn)\uC744 \uC4F0\uC138\uC694.`,
7079
+ POPUP_OPENER_NOT_ALLOWED
7080
+ );
7081
+ }
7082
+ throw err;
6765
7083
  }
6766
- const response = await this.http.get(
6767
- `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
6768
- );
6769
7084
  const width = 500;
6770
7085
  const height = 600;
6771
7086
  const left = window.screenX + (window.outerWidth - width) / 2;
@@ -6778,6 +7093,10 @@ var OAuthAPI = class {
6778
7093
  if (!popup) {
6779
7094
  throw new Error("\uD31D\uC5C5\uC774 \uCC28\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uD31D\uC5C5 \uCC28\uB2E8\uC744 \uD574\uC81C\uD574\uC8FC\uC138\uC694.");
6780
7095
  }
7096
+ const allowedOrigins = this.popupMessageOrigins(
7097
+ response.authorization_url,
7098
+ callbackUrl
7099
+ );
6781
7100
  return new Promise((resolve, reject) => {
6782
7101
  let settled = false;
6783
7102
  const cleanup = () => {
@@ -6787,6 +7106,8 @@ var OAuthAPI = class {
6787
7106
  clearTimeout(timeout);
6788
7107
  };
6789
7108
  const handleMessage = async (event) => {
7109
+ if (event.source !== popup) return;
7110
+ if (!allowedOrigins.has(event.origin)) return;
6790
7111
  if (event.data?.type !== "oauth-callback") return;
6791
7112
  if (settled) return;
6792
7113
  cleanup();
@@ -6794,14 +7115,13 @@ var OAuthAPI = class {
6794
7115
  reject(new Error(event.data.error));
6795
7116
  return;
6796
7117
  }
6797
- const rawEmail = event.data.email;
6798
- const result = {
6799
- member_id: event.data.member_id,
6800
- access_token: event.data.access_token,
6801
- refresh_token: event.data.refresh_token,
6802
- is_new_member: event.data.is_new_member === "true" || event.data.is_new_member === true,
6803
- ...typeof rawEmail === "string" && rawEmail.length > 0 ? { email: rawEmail } : {}
6804
- };
7118
+ let result;
7119
+ try {
7120
+ result = await this.readPopupResult(event.data);
7121
+ } catch (err) {
7122
+ reject(err);
7123
+ return;
7124
+ }
6805
7125
  this.http.setTokens(result.access_token, result.refresh_token);
6806
7126
  try {
6807
7127
  await this.http.bootstrapRefreshCookie();
@@ -6834,6 +7154,51 @@ var OAuthAPI = class {
6834
7154
  }, 18e4);
6835
7155
  });
6836
7156
  }
7157
+ /**
7158
+ * 팝업 메시지를 보낼 수 있는 출처: 서버 제공 콜백 페이지(API 서버)와 앱이 지정한 콜백 페이지.
7159
+ *
7160
+ * 서버 제공 콜백의 출처는 `baseUrl` 과 다를 수 있어(`/cb-api` 패스스루) 서버가 정한 redirect_uri 도
7161
+ * 함께 본다. 인가 주소는 우리 API 응답이므로 믿을 수 있다.
7162
+ */
7163
+ popupMessageOrigins(authorizationUrl, callbackUrl) {
7164
+ const origins = /* @__PURE__ */ new Set();
7165
+ const add = (url) => {
7166
+ if (!url) return;
7167
+ try {
7168
+ origins.add(new URL(url, window.location.href).origin);
7169
+ } catch {
7170
+ }
7171
+ };
7172
+ add(this.http.getBaseUrl());
7173
+ try {
7174
+ add(new URL(authorizationUrl).searchParams.get("redirect_uri"));
7175
+ } catch {
7176
+ }
7177
+ add(callbackUrl);
7178
+ return origins;
7179
+ }
7180
+ /**
7181
+ * 팝업 메시지에서 로그인 결과를 만든다. code 가 있으면 교환한다(opener_origin 을 보낸 요청의 기본 응답).
7182
+ * 토큰을 직접 싣는 메시지는 opener_origin 을 모르는 구버전 서버 호환이다.
7183
+ */
7184
+ async readPopupResult(data) {
7185
+ if (typeof data.code === "string" && data.code) {
7186
+ return this.http.post("/v1/auth/oauth/exchange", {
7187
+ code: data.code
7188
+ });
7189
+ }
7190
+ const { access_token, refresh_token, member_id, email } = data;
7191
+ if (typeof access_token !== "string" || typeof refresh_token !== "string" || typeof member_id !== "string" || !access_token || !refresh_token || !member_id) {
7192
+ throw new Error("OAuth \uC751\uB2F5\uC5D0 \uD1A0\uD070\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
7193
+ }
7194
+ return {
7195
+ member_id,
7196
+ access_token,
7197
+ refresh_token,
7198
+ is_new_member: data.is_new_member === "true" || data.is_new_member === true,
7199
+ ...typeof email === "string" && email.length > 0 ? { email } : {}
7200
+ };
7201
+ }
6837
7202
  /**
6838
7203
  * 콜백 URL에서 OAuth 결과 추출
6839
7204
  * 중앙 콜백 방식에서 리다이렉트 후 URL 파라미터에서 결과를 추출합니다.
@@ -6849,6 +7214,10 @@ var OAuthAPI = class {
6849
7214
  * 없는 상태로 `getMe()` 가 401 으로 떨어지는 회귀가 있었다 (platform-issue
6850
7215
  * 019e638d, 2026-05-26).
6851
7216
  *
7217
+ * 토큰은 이 브라우저에서 `signIn()` / `signUp()` 으로 시작한 로그인의 결과일 때만 받아들입니다. 확인되지
7218
+ * 않으면(남이 보낸 링크 등) 토큰을 적재하지 않고 `{ error: 'oauth_state_mismatch' }` 를 돌려줍니다.
7219
+ * 토큰은 SDK 가 생성될 때 주소에서 지우므로 `location.search` 로 직접 읽을 수 없습니다.
7220
+ *
6852
7221
  * @returns OAuth 결과 (토큰, member_id 등) 또는 null
6853
7222
  *
6854
7223
  * @example
@@ -6865,36 +7234,40 @@ var OAuthAPI = class {
6865
7234
  * ```
6866
7235
  */
6867
7236
  async getCallbackResult() {
7237
+ const callback = captureTokenCallback();
6868
7238
  const params = new URLSearchParams(window.location.search);
6869
7239
  const error = params.get("error");
6870
7240
  if (error) {
7241
+ consumePendingOAuthState(params.get("state"));
6871
7242
  const errorResult = { error };
6872
- if (window.opener) {
7243
+ if (hasSameOriginOpener()) {
6873
7244
  window.opener.postMessage(
6874
7245
  { type: "oauth-callback", ...errorResult },
6875
- "*"
7246
+ window.location.origin
6876
7247
  );
6877
7248
  window.close();
6878
7249
  }
6879
7250
  return errorResult;
6880
7251
  }
6881
- const accessToken = params.get("access_token");
6882
- const refreshToken = params.get("refresh_token");
6883
- const memberId = params.get("member_id");
6884
- if (!accessToken || !refreshToken || !memberId) {
7252
+ if (!callback) {
6885
7253
  return null;
6886
7254
  }
6887
- const emailParam = params.get("email");
7255
+ if (callback.kind === "rejected") {
7256
+ return { error: OAUTH_STATE_MISMATCH };
7257
+ }
6888
7258
  const result = {
6889
- access_token: accessToken,
6890
- refresh_token: refreshToken,
6891
- member_id: memberId,
6892
- is_new_member: params.get("is_new_member") === "true",
6893
- ...emailParam ? { email: emailParam } : {},
6894
- state: params.get("state") || void 0
7259
+ access_token: callback.accessToken,
7260
+ refresh_token: callback.refreshToken,
7261
+ member_id: callback.memberId,
7262
+ is_new_member: callback.isNewMember,
7263
+ ...callback.email ? { email: callback.email } : {},
7264
+ state: callback.appState
6895
7265
  };
6896
- if (window.opener) {
6897
- window.opener.postMessage({ type: "oauth-callback", ...result }, "*");
7266
+ if (callback.kind === "popup") {
7267
+ window.opener.postMessage(
7268
+ { type: "oauth-callback", ...result },
7269
+ window.location.origin
7270
+ );
6898
7271
  window.close();
6899
7272
  return result;
6900
7273
  }
@@ -6904,10 +7277,35 @@ var OAuthAPI = class {
6904
7277
  return result;
6905
7278
  }
6906
7279
  }
6907
- this.http.setTokens(accessToken, refreshToken);
6908
- await this.http.bootstrapRefreshCookie();
7280
+ await this.loadCallbackOnce(callback);
6909
7281
  return result;
6910
7282
  }
7283
+ /**
7284
+ * 콜백 토큰을 이 인스턴스에 한 번만 적재한다. 앱이 `getCallbackResult()` 를 다시 불러도 그사이 회전된
7285
+ * 토큰을 콜백의 옛 토큰으로 되돌리지 않는다.
7286
+ */
7287
+ loadCallbackOnce(callback) {
7288
+ if (this.loadedCallback?.callback !== callback) {
7289
+ this.http.setTokens(callback.accessToken, callback.refreshToken);
7290
+ this.loadedCallback = {
7291
+ callback,
7292
+ done: this.http.bootstrapRefreshCookie()
7293
+ };
7294
+ }
7295
+ return this.loadedCallback.done;
7296
+ }
7297
+ /**
7298
+ * (SDK 내부용 — `ConnectBase` 생성자가 호출) 주소의 토큰 콜백을 회수해 지우고 그 종류를 알린다.
7299
+ *
7300
+ * @returns `redirect` 면 부팅 안전망 대상, `popup` 이면 팝업 콜백, `none` 이면 토큰 콜백이 아니거나 거부됨.
7301
+ */
7302
+ captureCallbackOnBoot() {
7303
+ const callback = captureTokenCallback();
7304
+ if (callback?.kind === "redirect" || callback?.kind === "popup") {
7305
+ return callback.kind;
7306
+ }
7307
+ return "none";
7308
+ }
6911
7309
  /**
6912
7310
  * (SDK 내부용 — `ConnectBase` 생성자가 호출) 부팅 안전망.
6913
7311
  *
@@ -6921,10 +7319,9 @@ var OAuthAPI = class {
6921
7319
  * 이 안전망은 콜백 처리 코드를 빠뜨린 앱도 동작하게 한다.
6922
7320
  *
6923
7321
  * **token rotation race(2026-05-16) 가 없는 이유:** 사전 `re-issue`(cookie 복구) 없이 URL
6924
- * 토큰을 *직접* 소비한다 — `getCallbackResult()` 의 리다이렉트 경로와 동일한 동작이다. 팝업
6925
- * (`window.opener` 존재)은 `ConnectBase` 생성자에서 제외되어 호출되지 않는다 (팝업 콜백은
6926
- * `getCallbackResult()` 의 postMessage 경로 전담). code-only(`?code=`)·에러(`?error=`)
6927
- * 콜백도 생성자에서 제외된다 (네트워크 교환/에러 분기는 앱이 명시 호출).
7322
+ * 토큰을 *직접* 소비한다 — `getCallbackResult()` 의 리다이렉트 경로와 동일한 동작이다. 팝업 콜백과
7323
+ * 거부된 콜백(`captureCallbackOnBoot()` 참고)은 생성자에서 제외되어 호출되지 않는다. code-only(`?code=`),
7324
+ * 에러(`?error=`) 콜백도 생성자에서 제외된다 (네트워크 교환/에러 분기는 앱이 명시 호출).
6928
7325
  *
6929
7326
  * @returns 토큰 적재 성공 시 true, no-op(토큰 없음 등)이면 false.
6930
7327
  */
@@ -6936,14 +7333,9 @@ var OAuthAPI = class {
6936
7333
  }
6937
7334
  async doConsumeRedirectOnBoot() {
6938
7335
  try {
6939
- if (typeof window === "undefined" || !window.location) return false;
6940
- const params = new URLSearchParams(window.location.search);
6941
- if (params.get("error")) return false;
6942
- const accessToken = params.get("access_token");
6943
- const refreshToken = params.get("refresh_token");
6944
- const memberId = params.get("member_id");
6945
- if (!accessToken || !refreshToken || !memberId) return false;
6946
- this.http.setTokens(accessToken, refreshToken);
7336
+ const callback = captureTokenCallback();
7337
+ if (callback?.kind !== "redirect") return false;
7338
+ this.http.setTokens(callback.accessToken, callback.refreshToken);
6947
7339
  await this.http.bootstrapRefreshCookie();
6948
7340
  return true;
6949
7341
  } catch {
@@ -6960,6 +7352,9 @@ var OAuthAPI = class {
6960
7352
  * 1) `cb.oauth.exchangeCodeFromCallback()` 먼저 — code-only 모드면 성공
6961
7353
  * 2) null 반환 시 `cb.oauth.getCallbackResult()` 폴백 (legacy 토큰-in-URL 모드)
6962
7354
  *
7355
+ * `getCallbackResult()` 와 같은 이유로, 이 브라우저에서 시작한 로그인의 `code` 만 교환합니다. 확인되지
7356
+ * 않으면 `oauth_state_mismatch` 오류를 던지고 교환하지 않습니다.
7357
+ *
6963
7358
  * @example
6964
7359
  * ```typescript
6965
7360
  * const result = await cb.oauth.exchangeCodeFromCallback()
@@ -6969,8 +7364,27 @@ var OAuthAPI = class {
6969
7364
  async exchangeCodeFromCallback() {
6970
7365
  const params = new URLSearchParams(window.location.search);
6971
7366
  const code = params.get("code");
6972
- if (!code) return null;
7367
+ if (!code) {
7368
+ return this.codeExchange?.href === window.location.href ? this.codeExchange.result : null;
7369
+ }
6973
7370
  if (params.get("error")) return null;
7371
+ const state = params.get("state");
7372
+ const check = consumePendingOAuthState(state);
7373
+ const isPopup = !check.valid && hasSameOriginOpener();
7374
+ if (!check.valid && !isPopup) {
7375
+ warnStateMismatch();
7376
+ throw new Error(OAUTH_STATE_MISMATCH);
7377
+ }
7378
+ stripParamsFromUrl(CODE_CALLBACK_PARAMS);
7379
+ const result = this.exchangeCode(
7380
+ code,
7381
+ check.valid ? check.appState : state || void 0,
7382
+ isPopup
7383
+ );
7384
+ this.codeExchange = { href: window.location.href, result };
7385
+ return result;
7386
+ }
7387
+ async exchangeCode(code, appState, isPopup) {
6974
7388
  const resp = await this.http.post("/v1/auth/oauth/exchange", { code });
6975
7389
  const result = {
6976
7390
  access_token: resp.access_token,
@@ -6978,10 +7392,13 @@ var OAuthAPI = class {
6978
7392
  member_id: resp.member_id,
6979
7393
  is_new_member: resp.is_new_member,
6980
7394
  ...resp.email ? { email: resp.email } : {},
6981
- state: params.get("state") || void 0
7395
+ state: appState
6982
7396
  };
6983
- if (window.opener) {
6984
- window.opener.postMessage({ type: "oauth-callback", ...result }, "*");
7397
+ if (isPopup) {
7398
+ window.opener.postMessage(
7399
+ { type: "oauth-callback", ...result },
7400
+ window.location.origin
7401
+ );
6985
7402
  window.close();
6986
7403
  return result;
6987
7404
  }
@@ -7347,6 +7764,9 @@ var PaymentAPI = class {
7347
7764
  * 결제 취소
7348
7765
  * 완료된 결제를 취소합니다. 부분 취소도 가능합니다.
7349
7766
  *
7767
+ * **서버 전용.** 환불은 돈이 나가는 동작이라 `secretKey`(`cb_sk_*`, `app:update` 권한)가 필요합니다.
7768
+ * 브라우저에서 Public Key 만으로 호출하면 401 `SECRET_KEY_REQUIRED` 입니다.
7769
+ *
7350
7770
  * @param paymentId - 결제 ID (Connect Base에서 발급한 ID)
7351
7771
  * @param data - 취소 정보 (사유, 부분 취소 금액)
7352
7772
  * @returns 취소된 결제 정보
@@ -7376,6 +7796,10 @@ var PaymentAPI = class {
7376
7796
  * 주문 ID로 결제 조회
7377
7797
  * 특정 주문의 결제 상태를 조회합니다.
7378
7798
  *
7799
+ * Public Key 만으로 호출하면 상태와 금액만 돌려줍니다. `customer_email`, `customer_name`, `customer_id`, `receipt_url`,
7800
+ * `metadata`, `country`, `settlement_*`, `cancel_reason` 은 그 결제의 회원 본인(로그인 상태), `secretKey`(`app:read` 권한),
7801
+ * 또는 `payment:read` 스코프의 service_role 함수에만 옵니다.
7802
+ *
7379
7803
  * @param orderId - 주문 ID
7380
7804
  * @returns 결제 상세 정보
7381
7805
  *
@@ -11234,8 +11658,8 @@ var SupportAPI = class {
11234
11658
  }
11235
11659
  };
11236
11660
  function detectSdkVersion() {
11237
- if ("6.10.0") {
11238
- return "6.10.0";
11661
+ if ("6.11.0") {
11662
+ return "6.11.0";
11239
11663
  }
11240
11664
  return "unknown";
11241
11665
  }
@@ -12935,6 +13359,7 @@ var HttpClient = class {
12935
13359
  this.config = { ...config };
12936
13360
  this.storageKey = this.buildStorageKey();
12937
13361
  this.warnIfUnsafePersistence();
13362
+ this.warnIfSecretKeyInBrowser();
12938
13363
  this.restoreTokens();
12939
13364
  }
12940
13365
  /**
@@ -12968,6 +13393,20 @@ var HttpClient = class {
12968
13393
  clearRecentCalls() {
12969
13394
  this.recentCalls.clear();
12970
13395
  }
13396
+ /**
13397
+ * Secret Key 는 계정 권한의 자격증명이다. 브라우저 번들에 넣으면 페이지를 여는 누구나 읽는다.
13398
+ * 막지는 않는다(Electron 등 신뢰된 환경이 있다) — 대신 무엇이 위험한지 분명히 알린다.
13399
+ */
13400
+ warnIfSecretKeyInBrowser() {
13401
+ if (typeof window === "undefined" || typeof document === "undefined") {
13402
+ return;
13403
+ }
13404
+ const misplaced = this.config.publicKey?.startsWith("cb_sk_") === true;
13405
+ if (!this.config.secretKey && !misplaced) return;
13406
+ console.warn(
13407
+ "[connect-base-client] \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C Secret Key(cb_sk_)\uB97C \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uD398\uC774\uC9C0\uB97C \uC5EC\uB294 \uB204\uAD6C\uB098 \uC774 \uD0A4\uB97C \uC77D\uC5B4 \uACC4\uC815 \uAD8C\uD55C\uC73C\uB85C API \uB97C \uD638\uCD9C\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uBE0C\uB77C\uC6B0\uC800\uC5D0\uB294 publicKey(cb_pk_)\uB9CC \uB123\uACE0 Secret Key \uB294 \uC11C\uBC84\uB098 CLI \uC5D0\uC11C\uB9CC \uC4F0\uC138\uC694. \uC774\uBBF8 \uBC30\uD3EC\uB410\uB2E4\uBA74 \uCF58\uC194\uC5D0\uC11C \uD0A4\uB97C \uD3D0\uAE30\uD558\uACE0 \uC0C8\uB85C \uBC1C\uAE09\uD558\uC138\uC694."
13408
+ );
13409
+ }
12971
13410
  warnIfUnsafePersistence() {
12972
13411
  if (typeof window === "undefined") return;
12973
13412
  if (this.config.persistence === "localStorage") {
@@ -13302,15 +13741,23 @@ var HttpClient = class {
13302
13741
  if (credential) {
13303
13742
  headers["X-Public-Key"] = credential;
13304
13743
  }
13744
+ const cookieOnly = !this.config.refreshToken;
13305
13745
  const response = await withAbort(
13306
- fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
13307
- method: "POST",
13308
- headers,
13309
- credentials: "include",
13310
- signal
13311
- }),
13746
+ fetch(
13747
+ `${this.config.baseUrl}/v1/auth/re-issue${cookieOnly ? "?quiet=1" : ""}`,
13748
+ {
13749
+ method: "POST",
13750
+ headers,
13751
+ credentials: "include",
13752
+ signal
13753
+ }
13754
+ ),
13312
13755
  signal
13313
13756
  );
13757
+ if (cookieOnly && response.status === 204) {
13758
+ failureKind = "permanent";
13759
+ throw new Error("Token refresh failed (no session)");
13760
+ }
13314
13761
  if (!response.ok) {
13315
13762
  const status = response.status;
13316
13763
  let oauthError;
@@ -14494,21 +14941,7 @@ var GameRoomTransport = class {
14494
14941
  }
14495
14942
  applyChange(change) {
14496
14943
  if (!this._state) return;
14497
- const path = change.path.split(".");
14498
- let current = this._state.state;
14499
- for (let i = 0; i < path.length - 1; i++) {
14500
- const key = path[i];
14501
- if (!(key in current)) {
14502
- current[key] = {};
14503
- }
14504
- current = current[key];
14505
- }
14506
- const lastKey = path[path.length - 1];
14507
- if (change.operation === "delete") {
14508
- delete current[lastKey];
14509
- } else {
14510
- current[lastKey] = change.value;
14511
- }
14944
+ applyGameStateChange(this._state.state, change);
14512
14945
  }
14513
14946
  handlePlayerEvent(msg) {
14514
14947
  const player = {
@@ -14623,14 +15056,15 @@ var ConnectBase = class {
14623
15056
  this.endpoint = new EndpointAPI(this.http);
14624
15057
  this.support = new SupportAPI(this.http);
14625
15058
  this.auth._attachAnalytics(this.analytics);
15059
+ const tokenCallback = typeof window !== "undefined" ? this.oauth.captureCallbackOnBoot() : "none";
14626
15060
  const shouldAutoRestore = config.autoRestoreSession ?? true;
14627
15061
  if (shouldAutoRestore && typeof window !== "undefined") {
14628
- if (this.isOAuthRedirectCallbackUrl()) {
15062
+ if (tokenCallback === "redirect") {
14629
15063
  this.http.setBootRestorePromise(
14630
15064
  this.oauth.consumeRedirectCallbackOnBoot(),
14631
15065
  { alwaysAwait: true }
14632
15066
  );
14633
- } else if (!this.isOAuthCallbackUrl()) {
15067
+ } else if (tokenCallback !== "popup" && !this.isOAuthCallbackUrl()) {
14634
15068
  this.http.setBootRestorePromise(
14635
15069
  this.http.tryRestoreSessionFromCookie()
14636
15070
  );
@@ -14638,34 +15072,19 @@ var ConnectBase = class {
14638
15072
  }
14639
15073
  }
14640
15074
  /**
14641
- * 현재 페이지가 OAuth 리다이렉트 콜백 페이지인지 판별.
15075
+ * 현재 페이지가 앱이 직접 처리할 OAuth 콜백 페이지인지 판별.
14642
15076
  *
14643
- * `?access_token=...&refresh_token=...` (legacy 토큰-in-URL 흐름) 또는
14644
- * `?code=...` (OAUTH_CODE_ONLY 흐름) 또는 OAuth 에러 응답 (`?error=...&state=...`)
14645
- * 패턴이면 true. 콜백 페이지에서 일반 `autoRestoreSession` 을 건너뛰어 rotation race 를 피한다.
15077
+ * `?code=...` (OAUTH_CODE_ONLY 흐름) 또는 OAuth 에러 응답 (`?error=...&state=...`) 패턴이면 true.
15078
+ * 콜백 페이지에서 일반 `autoRestoreSession` 을 건너뛰어 rotation race 를 피한다. 토큰 콜백은
15079
+ * 이미 주소에서 회수됐으므로 여기서 보지 않는다(`oauth.captureCallbackOnBoot`).
14646
15080
  */
14647
15081
  isOAuthCallbackUrl() {
14648
15082
  if (typeof window === "undefined" || !window.location) return false;
14649
15083
  const params = new URLSearchParams(window.location.search);
14650
- if (params.has("access_token") && params.has("refresh_token")) return true;
14651
15084
  if (params.has("code") && params.has("state")) return true;
14652
15085
  if (params.has("error") && params.has("state")) return true;
14653
15086
  return false;
14654
15087
  }
14655
- /**
14656
- * 현재 페이지가 토큰-in-URL OAuth 리다이렉트 콜백(팝업 아님)인지 판별.
14657
- *
14658
- * `?access_token=&refresh_token=` 이 있고 `window.opener` 가 없으면 true — 부팅 안전망
14659
- * (`oauth.consumeRedirectCallbackOnBoot`)으로 토큰을 자동 소비할 대상이다. 팝업 콜백
14660
- * (`window.opener` 존재)은 `getCallbackResult()` 의 postMessage 경로가 전담하므로 제외한다.
14661
- * code-only(`?code=`)·에러(`?error=`) 콜백도 제외(네트워크 교환/에러 분기는 앱이 명시 호출).
14662
- */
14663
- isOAuthRedirectCallbackUrl() {
14664
- if (typeof window === "undefined" || !window.location) return false;
14665
- if (window.opener) return false;
14666
- const params = new URLSearchParams(window.location.search);
14667
- return params.has("access_token") && params.has("refresh_token");
14668
- }
14669
15088
  /**
14670
15089
  * 새로고침/탭 재개 후 cookie 만으로 세션을 복원한다 (브라우저 전용).
14671
15090
  *