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.mjs CHANGED
@@ -571,6 +571,89 @@ var AIAPI = class {
571
571
  }
572
572
  };
573
573
 
574
+ // src/core/url-secrets.ts
575
+ var CREDENTIAL_PARAMS = /* @__PURE__ */ new Set([
576
+ "access_token",
577
+ "refresh_token",
578
+ "id_token",
579
+ "token",
580
+ "auth_token",
581
+ "session_token",
582
+ "jwt",
583
+ "client_secret",
584
+ "secret_key",
585
+ "api_key",
586
+ "apikey",
587
+ "code_verifier",
588
+ "password"
589
+ ]);
590
+ var TOKEN_CALLBACK_COMPANIONS = /* @__PURE__ */ new Set([
591
+ "member_id",
592
+ "is_new_member",
593
+ "email",
594
+ "state"
595
+ ]);
596
+ 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;
597
+ function paramName(pair) {
598
+ const raw = pair.split("=", 1)[0].replace(/\+/g, " ");
599
+ try {
600
+ return decodeURIComponent(raw).toLowerCase();
601
+ } catch {
602
+ return raw.toLowerCase();
603
+ }
604
+ }
605
+ function filterParams(raw, shouldRemove) {
606
+ if (!raw) return raw;
607
+ const pairs = raw.split("&");
608
+ const kept = pairs.filter((pair) => !shouldRemove(paramName(pair)));
609
+ return kept.length === pairs.length ? raw : kept.join("&");
610
+ }
611
+ function scrubParams(raw) {
612
+ if (!raw) return raw;
613
+ const names = new Set(raw.split("&").map(paramName));
614
+ const hasToken = names.has("access_token") || names.has("refresh_token");
615
+ const isCodeCallback = names.has("code") && names.has("state");
616
+ return filterParams(
617
+ raw,
618
+ (name) => CREDENTIAL_PARAMS.has(name) || hasToken && TOKEN_CALLBACK_COMPANIONS.has(name) || isCodeCallback && (name === "code" || name === "state")
619
+ );
620
+ }
621
+ function scrubFragment(fragment) {
622
+ const queryAt = fragment.indexOf("?");
623
+ if (queryAt >= 0) {
624
+ const head = fragment.slice(0, queryAt);
625
+ const clean = scrubParams(fragment.slice(queryAt + 1));
626
+ return clean ? `${head}?${clean}` : head;
627
+ }
628
+ return fragment.includes("=") ? scrubParams(fragment) : fragment;
629
+ }
630
+ function mapUrlParts(url, mapQuery, mapFragment) {
631
+ const hashAt = url.indexOf("#");
632
+ const beforeHash = hashAt >= 0 ? url.slice(0, hashAt) : url;
633
+ const queryAt = beforeHash.indexOf("?");
634
+ const path = queryAt >= 0 ? beforeHash.slice(0, queryAt) : beforeHash;
635
+ const rawQuery = queryAt >= 0 ? beforeHash.slice(queryAt + 1) : "";
636
+ const rawFragment = hashAt >= 0 ? url.slice(hashAt + 1) : "";
637
+ const query = mapQuery(rawQuery);
638
+ const fragment = mapFragment(rawFragment);
639
+ if (query === rawQuery && fragment === rawFragment) return url;
640
+ return `${path}${query ? `?${query}` : ""}${fragment ? `#${fragment}` : ""}`;
641
+ }
642
+ function scrubUrlSecrets(url) {
643
+ return url ? mapUrlParts(url, scrubParams, scrubFragment) : url;
644
+ }
645
+ function scrubSecretsInText(text) {
646
+ return text.replace(CREDENTIAL_IN_TEXT, "$1=[redacted]");
647
+ }
648
+ function removeQueryParams(url, names) {
649
+ const targets = new Set(names);
650
+ return mapUrlParts(
651
+ url,
652
+ (query) => filterParams(query, (name) => targets.has(name)),
653
+ (fragment) => fragment
654
+ );
655
+ }
656
+
574
657
  // src/api/analytics.ts
575
658
  var SESSION_TIMEOUT_MS = 30 * 60 * 1e3;
576
659
  var SESSION_KEY = "__cb_session";
@@ -836,9 +919,9 @@ var AnalyticsAPI = class {
836
919
  this.session.touch();
837
920
  const event = this.createBaseEvent("page_view");
838
921
  event.page_path = path || window.location.pathname;
839
- event.page_url = window.location.href;
922
+ event.page_url = scrubUrlSecrets(window.location.href);
840
923
  event.page_title = document.title;
841
- event.referrer = document.referrer || void 0;
924
+ event.referrer = scrubUrlSecrets(document.referrer) || void 0;
842
925
  event.screen_width = window.screen.width;
843
926
  event.screen_height = window.screen.height;
844
927
  Object.assign(event, this.utm);
@@ -854,7 +937,7 @@ var AnalyticsAPI = class {
854
937
  event.event_name = name;
855
938
  event.event_properties = properties;
856
939
  event.page_path = window.location.pathname;
857
- event.page_url = window.location.href;
940
+ event.page_url = scrubUrlSecrets(window.location.href);
858
941
  this.enqueue(event);
859
942
  }
860
943
  /**
@@ -1340,8 +1423,8 @@ var AnalyticsAPI = class {
1340
1423
  trackSessionStart() {
1341
1424
  const event = this.createBaseEvent("session_start");
1342
1425
  event.page_path = window.location.pathname;
1343
- event.page_url = window.location.href;
1344
- event.referrer = document.referrer || void 0;
1426
+ event.page_url = scrubUrlSecrets(window.location.href);
1427
+ event.referrer = scrubUrlSecrets(document.referrer) || void 0;
1345
1428
  event.screen_width = window.screen.width;
1346
1429
  event.screen_height = window.screen.height;
1347
1430
  Object.assign(event, this.utm);
@@ -2506,6 +2589,8 @@ var DatabaseAPI = class {
2506
2589
  * `options.autoCreate: true` 시 테이블이 없으면 빈 schemaless 테이블을
2507
2590
  * 자동으로 생성한 뒤 insert. opt-in 이며 프로토타입/AI 자동화 워크플로에
2508
2591
  * 권장합니다. production 앱은 명시적 `createTable` 호출 권장.
2592
+ * 테이블 생성은 `secretKey` 나 service_role 함수에서만 됩니다. Public Key 만으로는
2593
+ * 옵션이 무시되어, 없는 테이블이면 404 입니다 (이미 있는 테이블에는 그대로 씁니다).
2509
2594
  */
2510
2595
  async createData(tableIdOrName, data, options) {
2511
2596
  const prefix = this.getPublicPrefix();
@@ -2979,13 +3064,13 @@ var DatabaseAPI = class {
2979
3064
  * // 자기 앱 테이블 구독 (AppMember JWT)
2980
3065
  * cb.database.connectRealtime({
2981
3066
  * accessToken: 'your-jwt-token',
2982
- * dataServerUrl: 'https://data.connectbase.world'
3067
+ * dataServerUrl: 'https://api.connectbase.world'
2983
3068
  * })
2984
3069
  *
2985
3070
  * // cross-app: Provider 앱의 테이블 구독 (OAuth access token)
2986
3071
  * cb.database.connectRealtime({
2987
3072
  * accessToken: oauthAccessToken,
2988
- * dataServerUrl: 'https://data.connectbase.world'
3073
+ * dataServerUrl: 'https://api.connectbase.world'
2989
3074
  * })
2990
3075
  *
2991
3076
  * // 테이블 구독
@@ -3689,6 +3774,18 @@ async function drainBody(res) {
3689
3774
  }
3690
3775
 
3691
3776
  // src/api/error-tracker.ts
3777
+ function scrubReport(report) {
3778
+ const text = (value) => typeof value === "string" ? scrubSecretsInText(value) : value;
3779
+ const url = (value) => typeof value === "string" ? scrubUrlSecrets(value) : value;
3780
+ return {
3781
+ ...report,
3782
+ message: text(report.message),
3783
+ stack: text(report.stack),
3784
+ url: url(report.url),
3785
+ referrer: url(report.referrer),
3786
+ source: url(report.source)
3787
+ };
3788
+ }
3692
3789
  var ErrorTrackerAPI = class {
3693
3790
  constructor(http, config = {}) {
3694
3791
  this.storageWebId = null;
@@ -3867,7 +3964,7 @@ var ErrorTrackerAPI = class {
3867
3964
  return filtered;
3868
3965
  }
3869
3966
  queueError(report) {
3870
- this.errorQueue.push(report);
3967
+ this.errorQueue.push(scrubReport(report));
3871
3968
  this.log("Error queued", {
3872
3969
  message: report.message,
3873
3970
  queueSize: this.errorQueue.length
@@ -4179,6 +4276,43 @@ function toCreateRoomWire(config) {
4179
4276
  if (config.metadata) out.metadata = config.metadata;
4180
4277
  return out;
4181
4278
  }
4279
+ var FORBIDDEN_STATE_PATH_SEGMENTS = /* @__PURE__ */ new Set([
4280
+ "__proto__",
4281
+ "constructor",
4282
+ "prototype"
4283
+ ]);
4284
+ var hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key);
4285
+ function applyGameStateChange(state, change) {
4286
+ const segments = typeof change.path === "string" ? change.path.split(".") : null;
4287
+ if (!segments || segments.some((segment) => FORBIDDEN_STATE_PATH_SEGMENTS.has(segment))) {
4288
+ console.warn(
4289
+ "[connect-base game] \uC704\uD5D8\uD55C \uACBD\uB85C\uC758 \uC0C1\uD0DC \uBCC0\uACBD\uC744 \uBB34\uC2DC\uD588\uC2B5\uB2C8\uB2E4:",
4290
+ String(change.path).slice(0, 100)
4291
+ );
4292
+ return false;
4293
+ }
4294
+ const isDelete = change.operation === "delete";
4295
+ let current = state;
4296
+ for (const key of segments.slice(0, -1)) {
4297
+ const next = hasOwn(current, key) ? current[key] : void 0;
4298
+ if (next !== null && typeof next === "object") {
4299
+ current = next;
4300
+ } else if (isDelete) {
4301
+ return true;
4302
+ } else {
4303
+ const created = {};
4304
+ current[key] = created;
4305
+ current = created;
4306
+ }
4307
+ }
4308
+ const lastKey = segments[segments.length - 1];
4309
+ if (isDelete) {
4310
+ if (hasOwn(current, lastKey)) delete current[lastKey];
4311
+ } else {
4312
+ current[lastKey] = change.value;
4313
+ }
4314
+ return true;
4315
+ }
4182
4316
  var GameRoom = class {
4183
4317
  constructor(config) {
4184
4318
  this.ws = null;
@@ -4695,21 +4829,7 @@ var GameRoom = class {
4695
4829
  }
4696
4830
  applyChange(change) {
4697
4831
  if (!this._state) return;
4698
- const path = change.path.split(".");
4699
- let current = this._state.state;
4700
- for (let i = 0; i < path.length - 1; i++) {
4701
- const key = path[i];
4702
- if (!(key in current)) {
4703
- current[key] = {};
4704
- }
4705
- current = current[key];
4706
- }
4707
- const lastKey = path[path.length - 1];
4708
- if (change.operation === "delete") {
4709
- delete current[lastKey];
4710
- } else {
4711
- current[lastKey] = change.value;
4712
- }
4832
+ applyGameStateChange(this._state.state, change);
4713
4833
  }
4714
4834
  handlePlayerEvent(msg) {
4715
4835
  const player = {
@@ -5391,6 +5511,7 @@ var KnowledgeAPI = class {
5391
5511
  * 문서 추가
5392
5512
  *
5393
5513
  * 지식 베이스에 새 문서를 추가합니다. 텍스트 소스인 경우 즉시 처리됩니다.
5514
+ * 로그인한 회원 토큰(본인 문서)이나 `secretKey` 가 필요합니다. Public Key 만으로는 401 입니다.
5394
5515
  *
5395
5516
  * @param kbID - 지식 베이스 ID
5396
5517
  * @param data - 문서 생성 요청
@@ -5546,6 +5667,7 @@ var KnowledgeAPI = class {
5546
5667
  * 문서 삭제
5547
5668
  *
5548
5669
  * 문서와 관련된 모든 청크도 함께 삭제됩니다.
5670
+ * 로그인한 회원 토큰(본인 문서)이나 `secretKey` 가 필요합니다. Public Key 만으로는 401 입니다.
5549
5671
  *
5550
5672
  * @param kbID - 지식 베이스 ID
5551
5673
  * @param docID - 문서 ID
@@ -6484,6 +6606,77 @@ var NativeAPI = class {
6484
6606
  }
6485
6607
  };
6486
6608
 
6609
+ // src/core/oauth-state.ts
6610
+ var STORAGE_KEY = "cb_oauth_pending_states";
6611
+ var PENDING_TTL_MS = 60 * 60 * 1e3;
6612
+ var MAX_PENDING = 8;
6613
+ var OAUTH_STATE_MISMATCH = "oauth_state_mismatch";
6614
+ function browserStorage() {
6615
+ try {
6616
+ return typeof window !== "undefined" ? window.localStorage : null;
6617
+ } catch {
6618
+ return null;
6619
+ }
6620
+ }
6621
+ function readPending(storage, now) {
6622
+ try {
6623
+ const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || "[]");
6624
+ if (!Array.isArray(parsed)) return [];
6625
+ return parsed.filter(
6626
+ (entry) => typeof entry?.nonce === "string" && typeof entry?.expiresAt === "number" && entry.expiresAt > now
6627
+ );
6628
+ } catch {
6629
+ return [];
6630
+ }
6631
+ }
6632
+ function writePending(storage, entries) {
6633
+ if (entries.length === 0) {
6634
+ storage.removeItem(STORAGE_KEY);
6635
+ } else {
6636
+ storage.setItem(STORAGE_KEY, JSON.stringify(entries));
6637
+ }
6638
+ }
6639
+ function randomNonce() {
6640
+ const bytes = new Uint8Array(16);
6641
+ globalThis.crypto.getRandomValues(bytes);
6642
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
6643
+ }
6644
+ function createPendingOAuthState(appState) {
6645
+ const storage = browserStorage();
6646
+ const nonce = randomNonce();
6647
+ try {
6648
+ if (!storage) throw new Error("storage unavailable");
6649
+ const now = Date.now();
6650
+ const entries = readPending(storage, now);
6651
+ entries.push({
6652
+ nonce,
6653
+ expiresAt: now + PENDING_TTL_MS,
6654
+ ...appState ? { appState } : {}
6655
+ });
6656
+ writePending(storage, entries.slice(-MAX_PENDING));
6657
+ } catch {
6658
+ throw new Error(
6659
+ "\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."
6660
+ );
6661
+ }
6662
+ return nonce;
6663
+ }
6664
+ function consumePendingOAuthState(state) {
6665
+ if (!state) return { valid: false };
6666
+ const storage = browserStorage();
6667
+ if (!storage) return { valid: false };
6668
+ try {
6669
+ const entries = readPending(storage, Date.now());
6670
+ const index = entries.findIndex((entry) => entry.nonce === state);
6671
+ if (index < 0) return { valid: false };
6672
+ const [matched] = entries.splice(index, 1);
6673
+ writePending(storage, entries);
6674
+ return { valid: true, appState: matched.appState };
6675
+ } catch {
6676
+ return { valid: false };
6677
+ }
6678
+ }
6679
+
6487
6680
  // src/api/oauth.ts
6488
6681
  function getNativeOAuthBridge() {
6489
6682
  if (typeof window === "undefined") return null;
@@ -6493,6 +6686,71 @@ function getNativeOAuthBridge() {
6493
6686
  }
6494
6687
  return bridge;
6495
6688
  }
6689
+ var POPUP_OPENER_NOT_ALLOWED = "popup_opener_not_allowed";
6690
+ var TOKEN_CALLBACK_PARAMS = [
6691
+ "access_token",
6692
+ "refresh_token",
6693
+ "member_id",
6694
+ "is_new_member",
6695
+ "email",
6696
+ "state"
6697
+ ];
6698
+ var CODE_CALLBACK_PARAMS = ["code", "state"];
6699
+ var capturedTokenCallbacks = /* @__PURE__ */ new WeakMap();
6700
+ function hasSameOriginOpener() {
6701
+ try {
6702
+ return !!window.opener && window.opener.location.origin === window.location.origin;
6703
+ } catch {
6704
+ return false;
6705
+ }
6706
+ }
6707
+ function stripParamsFromUrl(names) {
6708
+ try {
6709
+ const { pathname, search, hash } = window.location;
6710
+ window.history.replaceState(
6711
+ window.history.state,
6712
+ "",
6713
+ removeQueryParams(`${pathname}${search}${hash}`, names)
6714
+ );
6715
+ } catch {
6716
+ }
6717
+ }
6718
+ function captureTokenCallback() {
6719
+ if (typeof window === "undefined" || !window.location) return null;
6720
+ const existing = capturedTokenCallbacks.get(window);
6721
+ const params = new URLSearchParams(window.location.search);
6722
+ const accessToken = params.get("access_token");
6723
+ const refreshToken = params.get("refresh_token");
6724
+ const inUrl = !!accessToken && !!refreshToken;
6725
+ if (!inUrl) {
6726
+ return existing && existing.href === window.location.href ? existing : null;
6727
+ }
6728
+ if (existing?.accessToken === accessToken) return existing;
6729
+ stripParamsFromUrl(TOKEN_CALLBACK_PARAMS);
6730
+ const memberId = params.get("member_id");
6731
+ if (!memberId) return null;
6732
+ const state = params.get("state");
6733
+ const check = consumePendingOAuthState(state);
6734
+ const captured = {
6735
+ // state 가 맞으면 opener 가 있어도 리다이렉트다(같은 출처 창이 연 창에서 signIn 을 부른 경우).
6736
+ kind: check.valid ? "redirect" : hasSameOriginOpener() ? "popup" : "rejected",
6737
+ accessToken,
6738
+ refreshToken,
6739
+ memberId,
6740
+ isNewMember: params.get("is_new_member") === "true",
6741
+ email: params.get("email"),
6742
+ appState: check.valid ? check.appState : state || void 0,
6743
+ href: window.location.href
6744
+ };
6745
+ capturedTokenCallbacks.set(window, captured);
6746
+ if (captured.kind === "rejected") warnStateMismatch();
6747
+ return captured;
6748
+ }
6749
+ function warnStateMismatch() {
6750
+ console.warn(
6751
+ "[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."
6752
+ );
6753
+ }
6496
6754
  function detectInAppBrowser() {
6497
6755
  if (typeof navigator === "undefined") return null;
6498
6756
  const ua = navigator.userAgent || "";
@@ -6530,6 +6788,10 @@ var OAuthAPI = class {
6530
6788
  * 발화하지 않도록 이 promise 를 공유한다 (이중 re-issue/rotation 방지).
6531
6789
  */
6532
6790
  this.bootConsumePromise = null;
6791
+ /** 이 인스턴스가 `getCallbackResult()` 로 이미 적재한 콜백. 다시 불려도 토큰을 되돌리지 않는다. */
6792
+ this.loadedCallback = null;
6793
+ /** 진행 중이거나 끝난 code 교환. 주소에서 code 를 지운 뒤의 재호출이 같은 결과를 받게 한다. */
6794
+ this.codeExchange = null;
6533
6795
  }
6534
6796
  /**
6535
6797
  * 활성화된 OAuth 프로바이더 목록 조회
@@ -6559,7 +6821,8 @@ var OAuthAPI = class {
6559
6821
  *
6560
6822
  * @param provider - OAuth 프로바이더 (google, naver, github, discord)
6561
6823
  * @param callbackUrl - OAuth 완료 후 리다이렉트될 앱의 URL
6562
- * @param state - 선택적 state 파라미터 (CSRF 방지 등)
6824
+ * @param state - 앱이 콜백까지 들고 가고 싶은 값(선택). `getCallbackResult()` 결과의 `state` 로 돌아온다.
6825
+ * 로그인 CSRF 방어는 SDK 가 별도의 일회용 값으로 항상 수행하므로 이 값이 없어도 안전하다.
6563
6826
  * @param options - `createIfNotExists: true` 면 미가입 사용자를 자동 가입시킨다([signUp] 과 동일 동작).
6564
6827
  *
6565
6828
  * @example
@@ -6604,9 +6867,9 @@ var OAuthAPI = class {
6604
6867
  const callbackScheme = bridge.getCallbackScheme();
6605
6868
  const bridgeParams = new URLSearchParams({
6606
6869
  app_callback: callbackScheme,
6607
- intent
6870
+ intent,
6871
+ state: this.createCallbackStateFor(callbackUrl, state)
6608
6872
  });
6609
- if (state) bridgeParams.append("state", state);
6610
6873
  const bridgeResp = await this.http.get(
6611
6874
  `/v1/public/oauth/${provider}/authorize/central?${bridgeParams.toString()}`
6612
6875
  );
@@ -6631,15 +6894,52 @@ var OAuthAPI = class {
6631
6894
  `\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.`
6632
6895
  );
6633
6896
  }
6634
- const params = new URLSearchParams({ app_callback: callbackUrl, intent });
6635
- if (state) {
6636
- params.append("state", state);
6637
- }
6897
+ const params = new URLSearchParams({
6898
+ app_callback: callbackUrl,
6899
+ intent,
6900
+ state: this.createCallbackStateFor(callbackUrl, state)
6901
+ });
6638
6902
  const response = await this.http.get(
6639
6903
  `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
6640
6904
  );
6641
6905
  window.location.href = response.authorization_url;
6642
6906
  }
6907
+ /**
6908
+ * 콜백에서 확인할 일회용 state 를 만들어 이 브라우저에 저장합니다.
6909
+ *
6910
+ * `signIn()` / `signUp()` 은 이미 내부에서 부르므로 호출할 필요가 없습니다. 인가 주소를 SDK 밖에서
6911
+ * 만드는 앱(`GET /v1/public/oauth/:provider/authorize/central` 직접 호출 등)만, 돌려받은 값을 `state`
6912
+ * 쿼리로 넘기세요. 이 값 없이 돌아온 콜백의 토큰은 남이 보낸 링크와 구분할 수 없어 거부됩니다.
6913
+ *
6914
+ * @param appState - 콜백까지 들고 갈 앱의 값(선택). 서버로 보내지 않고 `getCallbackResult()` 의 `state` 로 돌아옵니다.
6915
+ * @returns 인가 요청의 `state` 쿼리에 넣을 값
6916
+ * @throws 브라우저 저장소를 쓸 수 없을 때
6917
+ *
6918
+ * @example
6919
+ * ```typescript
6920
+ * const state = cb.oauth.createCallbackState()
6921
+ * const res = await fetch(`${API}/v1/public/oauth/google/authorize/central?app_callback=${callback}&state=${state}`, {
6922
+ * headers: { 'X-Public-Key': PUBLIC_KEY },
6923
+ * })
6924
+ * ```
6925
+ */
6926
+ createCallbackState(appState) {
6927
+ return createPendingOAuthState(appState);
6928
+ }
6929
+ /** 콜백 주소가 다른 출처면 그쪽 저장소에는 state 가 없어 콜백에서 거부된다는 점을 시작할 때 알린다. */
6930
+ createCallbackStateFor(callbackUrl, appState) {
6931
+ const state = createPendingOAuthState(appState);
6932
+ try {
6933
+ const target = new URL(callbackUrl, window.location.href);
6934
+ if (target.origin !== window.location.origin) {
6935
+ console.warn(
6936
+ `[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.`
6937
+ );
6938
+ }
6939
+ } catch {
6940
+ }
6941
+ return state;
6942
+ }
6643
6943
  /**
6644
6944
  * 소셜 로그인 (팝업 방식)
6645
6945
  * 팝업 창에서 소셜 로그인을 처리하고 결과를 Promise로 반환합니다.
@@ -6652,6 +6952,7 @@ var OAuthAPI = class {
6652
6952
  *
6653
6953
  * @param provider - OAuth 프로바이더
6654
6954
  * @param callbackUrl - (선택) OAuth 완료 후 리다이렉트될 팝업의 URL. 미지정 시 서버 제공 콜백 사용.
6955
+ * 지정한다면 이 메서드를 부르는 페이지와 같은 출처여야 합니다(콜백 페이지는 토큰을 같은 출처 창에만 넘깁니다).
6655
6956
  * @returns 로그인 결과
6656
6957
  *
6657
6958
  * @example
@@ -6710,10 +7011,24 @@ var OAuthAPI = class {
6710
7011
  params.set("intent", intent);
6711
7012
  if (callbackUrl) {
6712
7013
  params.set("app_callback", callbackUrl);
7014
+ } else {
7015
+ params.set("opener_origin", window.location.origin);
7016
+ }
7017
+ let response;
7018
+ try {
7019
+ response = await this.http.get(
7020
+ `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
7021
+ );
7022
+ } catch (err) {
7023
+ if (err instanceof ApiError && err.code === POPUP_OPENER_NOT_ALLOWED) {
7024
+ throw new ApiError(
7025
+ err.statusCode,
7026
+ `\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.`,
7027
+ POPUP_OPENER_NOT_ALLOWED
7028
+ );
7029
+ }
7030
+ throw err;
6713
7031
  }
6714
- const response = await this.http.get(
6715
- `/v1/public/oauth/${provider}/authorize/central?${params.toString()}`
6716
- );
6717
7032
  const width = 500;
6718
7033
  const height = 600;
6719
7034
  const left = window.screenX + (window.outerWidth - width) / 2;
@@ -6726,6 +7041,10 @@ var OAuthAPI = class {
6726
7041
  if (!popup) {
6727
7042
  throw new Error("\uD31D\uC5C5\uC774 \uCC28\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uD31D\uC5C5 \uCC28\uB2E8\uC744 \uD574\uC81C\uD574\uC8FC\uC138\uC694.");
6728
7043
  }
7044
+ const allowedOrigins = this.popupMessageOrigins(
7045
+ response.authorization_url,
7046
+ callbackUrl
7047
+ );
6729
7048
  return new Promise((resolve, reject) => {
6730
7049
  let settled = false;
6731
7050
  const cleanup = () => {
@@ -6735,6 +7054,8 @@ var OAuthAPI = class {
6735
7054
  clearTimeout(timeout);
6736
7055
  };
6737
7056
  const handleMessage = async (event) => {
7057
+ if (event.source !== popup) return;
7058
+ if (!allowedOrigins.has(event.origin)) return;
6738
7059
  if (event.data?.type !== "oauth-callback") return;
6739
7060
  if (settled) return;
6740
7061
  cleanup();
@@ -6742,14 +7063,13 @@ var OAuthAPI = class {
6742
7063
  reject(new Error(event.data.error));
6743
7064
  return;
6744
7065
  }
6745
- const rawEmail = event.data.email;
6746
- const result = {
6747
- member_id: event.data.member_id,
6748
- access_token: event.data.access_token,
6749
- refresh_token: event.data.refresh_token,
6750
- is_new_member: event.data.is_new_member === "true" || event.data.is_new_member === true,
6751
- ...typeof rawEmail === "string" && rawEmail.length > 0 ? { email: rawEmail } : {}
6752
- };
7066
+ let result;
7067
+ try {
7068
+ result = await this.readPopupResult(event.data);
7069
+ } catch (err) {
7070
+ reject(err);
7071
+ return;
7072
+ }
6753
7073
  this.http.setTokens(result.access_token, result.refresh_token);
6754
7074
  try {
6755
7075
  await this.http.bootstrapRefreshCookie();
@@ -6782,6 +7102,51 @@ var OAuthAPI = class {
6782
7102
  }, 18e4);
6783
7103
  });
6784
7104
  }
7105
+ /**
7106
+ * 팝업 메시지를 보낼 수 있는 출처: 서버 제공 콜백 페이지(API 서버)와 앱이 지정한 콜백 페이지.
7107
+ *
7108
+ * 서버 제공 콜백의 출처는 `baseUrl` 과 다를 수 있어(`/cb-api` 패스스루) 서버가 정한 redirect_uri 도
7109
+ * 함께 본다. 인가 주소는 우리 API 응답이므로 믿을 수 있다.
7110
+ */
7111
+ popupMessageOrigins(authorizationUrl, callbackUrl) {
7112
+ const origins = /* @__PURE__ */ new Set();
7113
+ const add = (url) => {
7114
+ if (!url) return;
7115
+ try {
7116
+ origins.add(new URL(url, window.location.href).origin);
7117
+ } catch {
7118
+ }
7119
+ };
7120
+ add(this.http.getBaseUrl());
7121
+ try {
7122
+ add(new URL(authorizationUrl).searchParams.get("redirect_uri"));
7123
+ } catch {
7124
+ }
7125
+ add(callbackUrl);
7126
+ return origins;
7127
+ }
7128
+ /**
7129
+ * 팝업 메시지에서 로그인 결과를 만든다. code 가 있으면 교환한다(opener_origin 을 보낸 요청의 기본 응답).
7130
+ * 토큰을 직접 싣는 메시지는 opener_origin 을 모르는 구버전 서버 호환이다.
7131
+ */
7132
+ async readPopupResult(data) {
7133
+ if (typeof data.code === "string" && data.code) {
7134
+ return this.http.post("/v1/auth/oauth/exchange", {
7135
+ code: data.code
7136
+ });
7137
+ }
7138
+ const { access_token, refresh_token, member_id, email } = data;
7139
+ if (typeof access_token !== "string" || typeof refresh_token !== "string" || typeof member_id !== "string" || !access_token || !refresh_token || !member_id) {
7140
+ throw new Error("OAuth \uC751\uB2F5\uC5D0 \uD1A0\uD070\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
7141
+ }
7142
+ return {
7143
+ member_id,
7144
+ access_token,
7145
+ refresh_token,
7146
+ is_new_member: data.is_new_member === "true" || data.is_new_member === true,
7147
+ ...typeof email === "string" && email.length > 0 ? { email } : {}
7148
+ };
7149
+ }
6785
7150
  /**
6786
7151
  * 콜백 URL에서 OAuth 결과 추출
6787
7152
  * 중앙 콜백 방식에서 리다이렉트 후 URL 파라미터에서 결과를 추출합니다.
@@ -6797,6 +7162,10 @@ var OAuthAPI = class {
6797
7162
  * 없는 상태로 `getMe()` 가 401 으로 떨어지는 회귀가 있었다 (platform-issue
6798
7163
  * 019e638d, 2026-05-26).
6799
7164
  *
7165
+ * 토큰은 이 브라우저에서 `signIn()` / `signUp()` 으로 시작한 로그인의 결과일 때만 받아들입니다. 확인되지
7166
+ * 않으면(남이 보낸 링크 등) 토큰을 적재하지 않고 `{ error: 'oauth_state_mismatch' }` 를 돌려줍니다.
7167
+ * 토큰은 SDK 가 생성될 때 주소에서 지우므로 `location.search` 로 직접 읽을 수 없습니다.
7168
+ *
6800
7169
  * @returns OAuth 결과 (토큰, member_id 등) 또는 null
6801
7170
  *
6802
7171
  * @example
@@ -6813,36 +7182,40 @@ var OAuthAPI = class {
6813
7182
  * ```
6814
7183
  */
6815
7184
  async getCallbackResult() {
7185
+ const callback = captureTokenCallback();
6816
7186
  const params = new URLSearchParams(window.location.search);
6817
7187
  const error = params.get("error");
6818
7188
  if (error) {
7189
+ consumePendingOAuthState(params.get("state"));
6819
7190
  const errorResult = { error };
6820
- if (window.opener) {
7191
+ if (hasSameOriginOpener()) {
6821
7192
  window.opener.postMessage(
6822
7193
  { type: "oauth-callback", ...errorResult },
6823
- "*"
7194
+ window.location.origin
6824
7195
  );
6825
7196
  window.close();
6826
7197
  }
6827
7198
  return errorResult;
6828
7199
  }
6829
- const accessToken = params.get("access_token");
6830
- const refreshToken = params.get("refresh_token");
6831
- const memberId = params.get("member_id");
6832
- if (!accessToken || !refreshToken || !memberId) {
7200
+ if (!callback) {
6833
7201
  return null;
6834
7202
  }
6835
- const emailParam = params.get("email");
7203
+ if (callback.kind === "rejected") {
7204
+ return { error: OAUTH_STATE_MISMATCH };
7205
+ }
6836
7206
  const result = {
6837
- access_token: accessToken,
6838
- refresh_token: refreshToken,
6839
- member_id: memberId,
6840
- is_new_member: params.get("is_new_member") === "true",
6841
- ...emailParam ? { email: emailParam } : {},
6842
- state: params.get("state") || void 0
7207
+ access_token: callback.accessToken,
7208
+ refresh_token: callback.refreshToken,
7209
+ member_id: callback.memberId,
7210
+ is_new_member: callback.isNewMember,
7211
+ ...callback.email ? { email: callback.email } : {},
7212
+ state: callback.appState
6843
7213
  };
6844
- if (window.opener) {
6845
- window.opener.postMessage({ type: "oauth-callback", ...result }, "*");
7214
+ if (callback.kind === "popup") {
7215
+ window.opener.postMessage(
7216
+ { type: "oauth-callback", ...result },
7217
+ window.location.origin
7218
+ );
6846
7219
  window.close();
6847
7220
  return result;
6848
7221
  }
@@ -6852,10 +7225,35 @@ var OAuthAPI = class {
6852
7225
  return result;
6853
7226
  }
6854
7227
  }
6855
- this.http.setTokens(accessToken, refreshToken);
6856
- await this.http.bootstrapRefreshCookie();
7228
+ await this.loadCallbackOnce(callback);
6857
7229
  return result;
6858
7230
  }
7231
+ /**
7232
+ * 콜백 토큰을 이 인스턴스에 한 번만 적재한다. 앱이 `getCallbackResult()` 를 다시 불러도 그사이 회전된
7233
+ * 토큰을 콜백의 옛 토큰으로 되돌리지 않는다.
7234
+ */
7235
+ loadCallbackOnce(callback) {
7236
+ if (this.loadedCallback?.callback !== callback) {
7237
+ this.http.setTokens(callback.accessToken, callback.refreshToken);
7238
+ this.loadedCallback = {
7239
+ callback,
7240
+ done: this.http.bootstrapRefreshCookie()
7241
+ };
7242
+ }
7243
+ return this.loadedCallback.done;
7244
+ }
7245
+ /**
7246
+ * (SDK 내부용 — `ConnectBase` 생성자가 호출) 주소의 토큰 콜백을 회수해 지우고 그 종류를 알린다.
7247
+ *
7248
+ * @returns `redirect` 면 부팅 안전망 대상, `popup` 이면 팝업 콜백, `none` 이면 토큰 콜백이 아니거나 거부됨.
7249
+ */
7250
+ captureCallbackOnBoot() {
7251
+ const callback = captureTokenCallback();
7252
+ if (callback?.kind === "redirect" || callback?.kind === "popup") {
7253
+ return callback.kind;
7254
+ }
7255
+ return "none";
7256
+ }
6859
7257
  /**
6860
7258
  * (SDK 내부용 — `ConnectBase` 생성자가 호출) 부팅 안전망.
6861
7259
  *
@@ -6869,10 +7267,9 @@ var OAuthAPI = class {
6869
7267
  * 이 안전망은 콜백 처리 코드를 빠뜨린 앱도 동작하게 한다.
6870
7268
  *
6871
7269
  * **token rotation race(2026-05-16) 가 없는 이유:** 사전 `re-issue`(cookie 복구) 없이 URL
6872
- * 토큰을 *직접* 소비한다 — `getCallbackResult()` 의 리다이렉트 경로와 동일한 동작이다. 팝업
6873
- * (`window.opener` 존재)은 `ConnectBase` 생성자에서 제외되어 호출되지 않는다 (팝업 콜백은
6874
- * `getCallbackResult()` 의 postMessage 경로 전담). code-only(`?code=`)·에러(`?error=`)
6875
- * 콜백도 생성자에서 제외된다 (네트워크 교환/에러 분기는 앱이 명시 호출).
7270
+ * 토큰을 *직접* 소비한다 — `getCallbackResult()` 의 리다이렉트 경로와 동일한 동작이다. 팝업 콜백과
7271
+ * 거부된 콜백(`captureCallbackOnBoot()` 참고)은 생성자에서 제외되어 호출되지 않는다. code-only(`?code=`),
7272
+ * 에러(`?error=`) 콜백도 생성자에서 제외된다 (네트워크 교환/에러 분기는 앱이 명시 호출).
6876
7273
  *
6877
7274
  * @returns 토큰 적재 성공 시 true, no-op(토큰 없음 등)이면 false.
6878
7275
  */
@@ -6884,14 +7281,9 @@ var OAuthAPI = class {
6884
7281
  }
6885
7282
  async doConsumeRedirectOnBoot() {
6886
7283
  try {
6887
- if (typeof window === "undefined" || !window.location) return false;
6888
- const params = new URLSearchParams(window.location.search);
6889
- if (params.get("error")) return false;
6890
- const accessToken = params.get("access_token");
6891
- const refreshToken = params.get("refresh_token");
6892
- const memberId = params.get("member_id");
6893
- if (!accessToken || !refreshToken || !memberId) return false;
6894
- this.http.setTokens(accessToken, refreshToken);
7284
+ const callback = captureTokenCallback();
7285
+ if (callback?.kind !== "redirect") return false;
7286
+ this.http.setTokens(callback.accessToken, callback.refreshToken);
6895
7287
  await this.http.bootstrapRefreshCookie();
6896
7288
  return true;
6897
7289
  } catch {
@@ -6908,6 +7300,9 @@ var OAuthAPI = class {
6908
7300
  * 1) `cb.oauth.exchangeCodeFromCallback()` 먼저 — code-only 모드면 성공
6909
7301
  * 2) null 반환 시 `cb.oauth.getCallbackResult()` 폴백 (legacy 토큰-in-URL 모드)
6910
7302
  *
7303
+ * `getCallbackResult()` 와 같은 이유로, 이 브라우저에서 시작한 로그인의 `code` 만 교환합니다. 확인되지
7304
+ * 않으면 `oauth_state_mismatch` 오류를 던지고 교환하지 않습니다.
7305
+ *
6911
7306
  * @example
6912
7307
  * ```typescript
6913
7308
  * const result = await cb.oauth.exchangeCodeFromCallback()
@@ -6917,8 +7312,27 @@ var OAuthAPI = class {
6917
7312
  async exchangeCodeFromCallback() {
6918
7313
  const params = new URLSearchParams(window.location.search);
6919
7314
  const code = params.get("code");
6920
- if (!code) return null;
7315
+ if (!code) {
7316
+ return this.codeExchange?.href === window.location.href ? this.codeExchange.result : null;
7317
+ }
6921
7318
  if (params.get("error")) return null;
7319
+ const state = params.get("state");
7320
+ const check = consumePendingOAuthState(state);
7321
+ const isPopup = !check.valid && hasSameOriginOpener();
7322
+ if (!check.valid && !isPopup) {
7323
+ warnStateMismatch();
7324
+ throw new Error(OAUTH_STATE_MISMATCH);
7325
+ }
7326
+ stripParamsFromUrl(CODE_CALLBACK_PARAMS);
7327
+ const result = this.exchangeCode(
7328
+ code,
7329
+ check.valid ? check.appState : state || void 0,
7330
+ isPopup
7331
+ );
7332
+ this.codeExchange = { href: window.location.href, result };
7333
+ return result;
7334
+ }
7335
+ async exchangeCode(code, appState, isPopup) {
6922
7336
  const resp = await this.http.post("/v1/auth/oauth/exchange", { code });
6923
7337
  const result = {
6924
7338
  access_token: resp.access_token,
@@ -6926,10 +7340,13 @@ var OAuthAPI = class {
6926
7340
  member_id: resp.member_id,
6927
7341
  is_new_member: resp.is_new_member,
6928
7342
  ...resp.email ? { email: resp.email } : {},
6929
- state: params.get("state") || void 0
7343
+ state: appState
6930
7344
  };
6931
- if (window.opener) {
6932
- window.opener.postMessage({ type: "oauth-callback", ...result }, "*");
7345
+ if (isPopup) {
7346
+ window.opener.postMessage(
7347
+ { type: "oauth-callback", ...result },
7348
+ window.location.origin
7349
+ );
6933
7350
  window.close();
6934
7351
  return result;
6935
7352
  }
@@ -7295,6 +7712,9 @@ var PaymentAPI = class {
7295
7712
  * 결제 취소
7296
7713
  * 완료된 결제를 취소합니다. 부분 취소도 가능합니다.
7297
7714
  *
7715
+ * **서버 전용.** 환불은 돈이 나가는 동작이라 `secretKey`(`cb_sk_*`, `app:update` 권한)가 필요합니다.
7716
+ * 브라우저에서 Public Key 만으로 호출하면 401 `SECRET_KEY_REQUIRED` 입니다.
7717
+ *
7298
7718
  * @param paymentId - 결제 ID (Connect Base에서 발급한 ID)
7299
7719
  * @param data - 취소 정보 (사유, 부분 취소 금액)
7300
7720
  * @returns 취소된 결제 정보
@@ -7324,6 +7744,10 @@ var PaymentAPI = class {
7324
7744
  * 주문 ID로 결제 조회
7325
7745
  * 특정 주문의 결제 상태를 조회합니다.
7326
7746
  *
7747
+ * Public Key 만으로 호출하면 상태와 금액만 돌려줍니다. `customer_email`, `customer_name`, `customer_id`, `receipt_url`,
7748
+ * `metadata`, `country`, `settlement_*`, `cancel_reason` 은 그 결제의 회원 본인(로그인 상태), `secretKey`(`app:read` 권한),
7749
+ * 또는 `payment:read` 스코프의 service_role 함수에만 옵니다.
7750
+ *
7327
7751
  * @param orderId - 주문 ID
7328
7752
  * @returns 결제 상세 정보
7329
7753
  *
@@ -11182,8 +11606,8 @@ var SupportAPI = class {
11182
11606
  }
11183
11607
  };
11184
11608
  function detectSdkVersion() {
11185
- if ("6.10.0") {
11186
- return "6.10.0";
11609
+ if ("6.11.0") {
11610
+ return "6.11.0";
11187
11611
  }
11188
11612
  return "unknown";
11189
11613
  }
@@ -12883,6 +13307,7 @@ var HttpClient = class {
12883
13307
  this.config = { ...config };
12884
13308
  this.storageKey = this.buildStorageKey();
12885
13309
  this.warnIfUnsafePersistence();
13310
+ this.warnIfSecretKeyInBrowser();
12886
13311
  this.restoreTokens();
12887
13312
  }
12888
13313
  /**
@@ -12916,6 +13341,20 @@ var HttpClient = class {
12916
13341
  clearRecentCalls() {
12917
13342
  this.recentCalls.clear();
12918
13343
  }
13344
+ /**
13345
+ * Secret Key 는 계정 권한의 자격증명이다. 브라우저 번들에 넣으면 페이지를 여는 누구나 읽는다.
13346
+ * 막지는 않는다(Electron 등 신뢰된 환경이 있다) — 대신 무엇이 위험한지 분명히 알린다.
13347
+ */
13348
+ warnIfSecretKeyInBrowser() {
13349
+ if (typeof window === "undefined" || typeof document === "undefined") {
13350
+ return;
13351
+ }
13352
+ const misplaced = this.config.publicKey?.startsWith("cb_sk_") === true;
13353
+ if (!this.config.secretKey && !misplaced) return;
13354
+ console.warn(
13355
+ "[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."
13356
+ );
13357
+ }
12919
13358
  warnIfUnsafePersistence() {
12920
13359
  if (typeof window === "undefined") return;
12921
13360
  if (this.config.persistence === "localStorage") {
@@ -13250,15 +13689,23 @@ var HttpClient = class {
13250
13689
  if (credential) {
13251
13690
  headers["X-Public-Key"] = credential;
13252
13691
  }
13692
+ const cookieOnly = !this.config.refreshToken;
13253
13693
  const response = await withAbort(
13254
- fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
13255
- method: "POST",
13256
- headers,
13257
- credentials: "include",
13258
- signal
13259
- }),
13694
+ fetch(
13695
+ `${this.config.baseUrl}/v1/auth/re-issue${cookieOnly ? "?quiet=1" : ""}`,
13696
+ {
13697
+ method: "POST",
13698
+ headers,
13699
+ credentials: "include",
13700
+ signal
13701
+ }
13702
+ ),
13260
13703
  signal
13261
13704
  );
13705
+ if (cookieOnly && response.status === 204) {
13706
+ failureKind = "permanent";
13707
+ throw new Error("Token refresh failed (no session)");
13708
+ }
13262
13709
  if (!response.ok) {
13263
13710
  const status = response.status;
13264
13711
  let oauthError;
@@ -14442,21 +14889,7 @@ var GameRoomTransport = class {
14442
14889
  }
14443
14890
  applyChange(change) {
14444
14891
  if (!this._state) return;
14445
- const path = change.path.split(".");
14446
- let current = this._state.state;
14447
- for (let i = 0; i < path.length - 1; i++) {
14448
- const key = path[i];
14449
- if (!(key in current)) {
14450
- current[key] = {};
14451
- }
14452
- current = current[key];
14453
- }
14454
- const lastKey = path[path.length - 1];
14455
- if (change.operation === "delete") {
14456
- delete current[lastKey];
14457
- } else {
14458
- current[lastKey] = change.value;
14459
- }
14892
+ applyGameStateChange(this._state.state, change);
14460
14893
  }
14461
14894
  handlePlayerEvent(msg) {
14462
14895
  const player = {
@@ -14571,14 +15004,15 @@ var ConnectBase = class {
14571
15004
  this.endpoint = new EndpointAPI(this.http);
14572
15005
  this.support = new SupportAPI(this.http);
14573
15006
  this.auth._attachAnalytics(this.analytics);
15007
+ const tokenCallback = typeof window !== "undefined" ? this.oauth.captureCallbackOnBoot() : "none";
14574
15008
  const shouldAutoRestore = config.autoRestoreSession ?? true;
14575
15009
  if (shouldAutoRestore && typeof window !== "undefined") {
14576
- if (this.isOAuthRedirectCallbackUrl()) {
15010
+ if (tokenCallback === "redirect") {
14577
15011
  this.http.setBootRestorePromise(
14578
15012
  this.oauth.consumeRedirectCallbackOnBoot(),
14579
15013
  { alwaysAwait: true }
14580
15014
  );
14581
- } else if (!this.isOAuthCallbackUrl()) {
15015
+ } else if (tokenCallback !== "popup" && !this.isOAuthCallbackUrl()) {
14582
15016
  this.http.setBootRestorePromise(
14583
15017
  this.http.tryRestoreSessionFromCookie()
14584
15018
  );
@@ -14586,34 +15020,19 @@ var ConnectBase = class {
14586
15020
  }
14587
15021
  }
14588
15022
  /**
14589
- * 현재 페이지가 OAuth 리다이렉트 콜백 페이지인지 판별.
15023
+ * 현재 페이지가 앱이 직접 처리할 OAuth 콜백 페이지인지 판별.
14590
15024
  *
14591
- * `?access_token=...&refresh_token=...` (legacy 토큰-in-URL 흐름) 또는
14592
- * `?code=...` (OAUTH_CODE_ONLY 흐름) 또는 OAuth 에러 응답 (`?error=...&state=...`)
14593
- * 패턴이면 true. 콜백 페이지에서 일반 `autoRestoreSession` 을 건너뛰어 rotation race 를 피한다.
15025
+ * `?code=...` (OAUTH_CODE_ONLY 흐름) 또는 OAuth 에러 응답 (`?error=...&state=...`) 패턴이면 true.
15026
+ * 콜백 페이지에서 일반 `autoRestoreSession` 을 건너뛰어 rotation race 를 피한다. 토큰 콜백은
15027
+ * 이미 주소에서 회수됐으므로 여기서 보지 않는다(`oauth.captureCallbackOnBoot`).
14594
15028
  */
14595
15029
  isOAuthCallbackUrl() {
14596
15030
  if (typeof window === "undefined" || !window.location) return false;
14597
15031
  const params = new URLSearchParams(window.location.search);
14598
- if (params.has("access_token") && params.has("refresh_token")) return true;
14599
15032
  if (params.has("code") && params.has("state")) return true;
14600
15033
  if (params.has("error") && params.has("state")) return true;
14601
15034
  return false;
14602
15035
  }
14603
- /**
14604
- * 현재 페이지가 토큰-in-URL OAuth 리다이렉트 콜백(팝업 아님)인지 판별.
14605
- *
14606
- * `?access_token=&refresh_token=` 이 있고 `window.opener` 가 없으면 true — 부팅 안전망
14607
- * (`oauth.consumeRedirectCallbackOnBoot`)으로 토큰을 자동 소비할 대상이다. 팝업 콜백
14608
- * (`window.opener` 존재)은 `getCallbackResult()` 의 postMessage 경로가 전담하므로 제외한다.
14609
- * code-only(`?code=`)·에러(`?error=`) 콜백도 제외(네트워크 교환/에러 분기는 앱이 명시 호출).
14610
- */
14611
- isOAuthRedirectCallbackUrl() {
14612
- if (typeof window === "undefined" || !window.location) return false;
14613
- if (window.opener) return false;
14614
- const params = new URLSearchParams(window.location.search);
14615
- return params.has("access_token") && params.has("refresh_token");
14616
- }
14617
15036
  /**
14618
15037
  * 새로고침/탭 재개 후 cookie 만으로 세션을 복원한다 (브라우저 전용).
14619
15038
  *