@bstage-sdk/core 0.8.7 → 0.9.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.cjs CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  dirNameToSlotId: () => dirNameToSlotId,
39
39
  fetchTranslations: () => fetchTranslations,
40
40
  getAdminEventsByDomain: () => getAdminEventsByDomain,
41
+ getFrameBridge: () => getFrameBridge,
41
42
  getSlotsByTarget: () => getSlotsByTarget,
42
43
  getSlotsByTargetV2: () => getSlotsByTargetV2,
43
44
  interpolateTranslation: () => interpolateTranslation,
@@ -45,6 +46,7 @@ __export(index_exports, {
45
46
  lookupElementName: () => lookupElementName,
46
47
  normalizeLanguage: () => normalizeLanguage,
47
48
  observeLocale: () => observeLocale,
49
+ observeTranslations: () => observeTranslations,
48
50
  parseRichText: () => parseRichText,
49
51
  pickTranslation: () => pickTranslation,
50
52
  readLocale: () => readLocale,
@@ -174,10 +176,26 @@ var HttpTimeoutError = class extends Error {
174
176
  };
175
177
 
176
178
  // src/version.ts
177
- var VERSION = "0.8.7";
179
+ var VERSION = "0.9.0";
180
+
181
+ // src/frameBridge.ts
182
+ function getFrameBridge() {
183
+ const candidate = globalThis.__bstage_bridge__;
184
+ if (typeof candidate !== "object" || candidate === null) return void 0;
185
+ const b = candidate;
186
+ if (typeof b.hostOrigin !== "string" || typeof b.getInit !== "function" || typeof b.request !== "function" || typeof b.on !== "function" || typeof b.fetch !== "function") {
187
+ return void 0;
188
+ }
189
+ return candidate;
190
+ }
178
191
 
179
192
  // src/client/BstageClient.ts
180
193
  function resolveBaseUrl() {
194
+ const bridge = getFrameBridge();
195
+ if (bridge) {
196
+ const gatewayBase = bridge.getInit()?.gatewayBase;
197
+ return typeof gatewayBase === "string" ? gatewayBase : `${bridge.hostOrigin}/gw`;
198
+ }
181
199
  if (typeof globalThis.location !== "undefined") {
182
200
  return `${globalThis.location.origin}/gw`;
183
201
  }
@@ -192,6 +210,8 @@ function resolvePath(template, params) {
192
210
  });
193
211
  }
194
212
  var resolveBstageFetch = (input, init) => {
213
+ const bridge = getFrameBridge();
214
+ if (bridge) return bridge.fetch(input, init);
195
215
  const hook = globalThis.__bstage_fetch__;
196
216
  return hook ? hook(input, init) : globalThis.fetch(input, init);
197
217
  };
@@ -290,7 +310,12 @@ function readLanguageCookie(target) {
290
310
  }
291
311
  return void 0;
292
312
  }
313
+ function readBridgeLocale(bridge) {
314
+ return normalizeLanguage(bridge.getInit()?.locale) ?? DEFAULT_LANGUAGE;
315
+ }
293
316
  function readLocale(target = "user") {
317
+ const bridge = getFrameBridge();
318
+ if (bridge) return readBridgeLocale(bridge);
294
319
  if (typeof document === "undefined") return DEFAULT_LANGUAGE;
295
320
  const htmlLang = normalizeLanguage(document.documentElement.lang);
296
321
  if (target === "admin") {
@@ -299,6 +324,17 @@ function readLocale(target = "user") {
299
324
  return htmlLang ?? readLanguageCookie("user") ?? DEFAULT_LANGUAGE;
300
325
  }
301
326
  function observeLocale(onChange, target = "user") {
327
+ const bridge = getFrameBridge();
328
+ if (bridge) {
329
+ let current2 = readBridgeLocale(bridge);
330
+ return bridge.on("locale", () => {
331
+ const next = readBridgeLocale(bridge);
332
+ if (next !== current2) {
333
+ current2 = next;
334
+ onChange(next);
335
+ }
336
+ });
337
+ }
302
338
  if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
303
339
  return () => {
304
340
  };
@@ -434,6 +470,8 @@ function fetchVersionBase(source, fetchFn) {
434
470
  return promise;
435
471
  }
436
472
  function fetchTranslations(locale, system) {
473
+ const bridge = getFrameBridge();
474
+ if (bridge) return Promise.resolve(readBridgeTranslations(bridge, locale));
437
475
  const source = resolveSource(system);
438
476
  const key = `${sourceKey(source)}|${locale}`;
439
477
  const cached = translationCache.get(key);
@@ -450,6 +488,26 @@ function fetchTranslations(locale, system) {
450
488
  translationCache.set(key, promise);
451
489
  return promise;
452
490
  }
491
+ function observeTranslations(onChange) {
492
+ const bridge = getFrameBridge();
493
+ if (!bridge) return () => {
494
+ };
495
+ let last = bridge.getInit()?.translations;
496
+ return bridge.on("locale", () => {
497
+ const next = bridge.getInit()?.translations;
498
+ if (next !== last) {
499
+ last = next;
500
+ onChange();
501
+ }
502
+ });
503
+ }
504
+ function readBridgeTranslations(bridge, locale) {
505
+ const init = bridge.getInit();
506
+ if (!init || normalizeLanguage(init.locale) !== locale) return {};
507
+ const dict = init.translations;
508
+ if (typeof dict !== "object" || dict === null) return {};
509
+ return Object.fromEntries(Object.entries(dict).filter(([, v]) => typeof v === "string"));
510
+ }
453
511
  var DEFAULT_FALLBACK_CHAIN = ["en", "ko"];
454
512
  function resolveTranslationChain(current) {
455
513
  const tail = i18nConfig.fallbackChain ?? DEFAULT_FALLBACK_CHAIN;
@@ -927,6 +985,7 @@ function __resetElementRegistry__() {
927
985
  dirNameToSlotId,
928
986
  fetchTranslations,
929
987
  getAdminEventsByDomain,
988
+ getFrameBridge,
930
989
  getSlotsByTarget,
931
990
  getSlotsByTargetV2,
932
991
  interpolateTranslation,
@@ -934,6 +993,7 @@ function __resetElementRegistry__() {
934
993
  lookupElementName,
935
994
  normalizeLanguage,
936
995
  observeLocale,
996
+ observeTranslations,
937
997
  parseRichText,
938
998
  pickTranslation,
939
999
  readLocale,
package/dist/index.d.cts CHANGED
@@ -116,11 +116,14 @@ interface BstageConfig {
116
116
  * 특정 게이트웨이를 직접 지정하려면 `baseUrl`을 쓴다. (예약 필드)
117
117
  */
118
118
  phase?: 'dev' | 'qa' | 'real' | 'sandbox';
119
- /** base URL 직접 지정. 미지정 시 resolveBaseUrl()로 자동 결정. */
119
+ /** base URL 직접 지정. 미지정 시 `location.origin + /gw`(격리 프레임이면 init의 `gatewayBase`). */
120
120
  baseUrl?: string;
121
121
  /** 요청 타임아웃 (ms). */
122
122
  timeout?: number;
123
- /** 커스텀 fetch 함수. 미지정 시 `globalThis.__bstage_fetch__` → `globalThis.fetch` 순으로 사용. */
123
+ /**
124
+ * 커스텀 fetch 함수. 미지정 시 격리 프레임이면 브리지 fetch, 아니면 `globalThis.__bstage_fetch__` →
125
+ * `globalThis.fetch` 순으로 사용.
126
+ */
124
127
  fetch?: FetchFunction;
125
128
  }
126
129
 
@@ -303,6 +306,9 @@ declare function normalizeLanguage(value: string | null | undefined): LanguageCo
303
306
  * 어드민은 `<html lang>`이 SSR 기본값으로 고정(런타임 미갱신)이라 쿠키를 1차로 신뢰한다.
304
307
  *
305
308
  * DOM이 없는 환경(SSR 등)에서는 기본값을 돌려준다.
309
+ *
310
+ * 격리 프레임(브리지 있음)에서는 대상과 무관하게 호스트가 init·`locale` 이벤트로 준 값을 쓴다
311
+ * (프레임 문서의 쿠키·`<html lang>`은 호스트 것이 아니다).
306
312
  */
307
313
  declare function readLocale(target?: LocaleTarget): LanguageCode;
308
314
  /**
@@ -317,6 +323,8 @@ declare function readLocale(target?: LocaleTarget): LanguageCode;
317
323
  * 그 변경을 감지해 `readLocale('admin')`이 쿠키를 다시 읽는다.
318
324
  *
319
325
  * DOM/MutationObserver가 없는 환경에서는 아무 동작도 하지 않는 해제 함수를 반환한다.
326
+ *
327
+ * 격리 프레임에서는 브리지 `locale` 이벤트를 구독한다.
320
328
  */
321
329
  declare function observeLocale(onChange: (locale: LanguageCode) => void, target?: LocaleTarget): () => void;
322
330
  /** 플랫폼 번역 사전 — 로케일별 `Bxxxxx` 키 → 문자열(flat). */
@@ -390,10 +398,19 @@ declare function configureBstageI18n(config: BstageI18nConfig): void;
390
398
  * fetch는 `config.fetch` → `globalThis.fetch` 순으로 해석한다(공개 CDN이라 인증 fetch를 쓰지 않는다).
391
399
  * 실패 시 캐시를 비워 재시도를 허용한다.
392
400
  *
401
+ * 격리 프레임에서는 CDN을 부르지 않는다(프레임은 직접 네트워크가 막혀 있다). 호스트가 init·`locale`
402
+ * 이벤트에 실어 준 **현재 locale 사전**만 돌려주고, 다른 locale·사전이 없으면 빈 사전이다.
403
+ *
393
404
  * @param locale 받아올 로케일
394
405
  * @param system 번역 시스템(`user`\|`admin`). React에서는 `BstageLocaleProvider`의 `target`이 넘긴다.
395
406
  */
396
407
  declare function fetchTranslations(locale: LanguageCode, system?: LocaleTarget): Promise<TranslationDict>;
408
+ /**
409
+ * @internal 격리 프레임에서 호스트가 번역 사전을 새로 주면 알린다 — locale이 같고 사전만 늦게 와도.
410
+ * (`observeLocale`은 locale 값이 바뀔 때만 알리므로, 사전이 비동기로 뒤늦게 오는 흐름을 놓친다.)
411
+ * 브리지가 없으면 아무 동작도 하지 않는 해제 함수를 반환한다.
412
+ */
413
+ declare function observeTranslations(onChange: () => void): () => void;
397
414
  /**
398
415
  * 현재 로케일부터 시작하는 조회 순서를 만든다. 중복은 제거한다.
399
416
  *
@@ -1152,10 +1169,40 @@ declare function lookupElementName(url: string): string | undefined;
1152
1169
  */
1153
1170
  declare function __resetElementRegistry__(): void;
1154
1171
 
1172
+ /**
1173
+ * 격리 프레임 브리지의 init 중 core가 읽는 부분.
1174
+ * 전체 모양은 `@bstage-sdk/bridge`의 `BridgeInit`이다 — core는 그 패키지에 의존하지 않고
1175
+ * 필요한 필드만 구조적으로 읽는다.
1176
+ */
1177
+ interface FrameBridgeInit {
1178
+ readonly gatewayBase?: string;
1179
+ readonly locale?: string;
1180
+ readonly translations?: Readonly<Record<string, string>>;
1181
+ }
1182
+ /**
1183
+ * 격리 프레임 안에서 브리지 클라이언트가 설치하는 `globalThis.__bstage_bridge__`의 최소 모양.
1184
+ *
1185
+ * 템플릿이 sandbox iframe에서 돌 때만 존재한다. 없으면 모든 어댑터는 기존 경로(inline)로 동작한다.
1186
+ */
1187
+ interface FrameBridgeLike {
1188
+ readonly hostOrigin: string;
1189
+ getInit(): FrameBridgeInit | undefined;
1190
+ request(type: string, payload: unknown): Promise<unknown>;
1191
+ on(type: string, fn: (payload: unknown) => void): () => void;
1192
+ readonly fetch: FetchFunction;
1193
+ }
1194
+ /**
1195
+ * @internal 현재 실행 환경의 프레임 브리지를 돌려준다. 격리 프레임이 아니면 `undefined`.
1196
+ *
1197
+ * 모양이 맞을 때만 인정한다 — 템플릿이나 다른 스크립트가 같은 이름에 다른 값을 둬도
1198
+ * 기존 경로가 깨지지 않게 한다. 요청마다 호출해 늦은 설치도 반영한다.
1199
+ */
1200
+ declare function getFrameBridge(): FrameBridgeLike | undefined;
1201
+
1155
1202
  /**
1156
1203
  * 번들에 심긴 core 버전. `BstageClient`가 모든 요청에 `x-bmf-sdk-version` 헤더로 실어
1157
1204
  * 게이트웨이 로그에서 어느 SDK 버전이 쓰이는지 볼 수 있게 한다.
1158
1205
  */
1159
1206
  declare const VERSION: string;
1160
1207
 
1161
- export { ADMIN_EVENT_CATALOG, type AdminConnectPayload, type AdminDomainEventMap, type AdminDomainEventType, type BoardInfo, type BoardPostInfo, type BodyRequestOptions, BstageClient, type BstageConfig, type BstageI18nConfig, type Content, type ContentsSectionInfo, type CustomSlots, type CustomSlotsV2, DEFAULT_LANGUAGE, type DigitalTicket, type FetchFunction, type FrameworkAdapter, HttpClient, type HttpClientConfig, HttpError, type HttpMethod, type HttpResponse, HttpTimeoutError, LANGUAGES, type LanguageCode, type LocaleTarget, type PathParams, PlatformBridge, type PlatformEventHandler, type PlatformEventMap, type PlatformEventType, type Product, type QueryRequestOptions, type RequestConfig, type RequestInterceptor, type RequestOptions, type ResponseInterceptor, type RichTextToken, SLOT_CATALOG, SLOT_CATALOG_V2, type SlotCatalogEntryV2, type SlotContextMap, type SlotContextOf, type SlotContextOverrides, type SlotContextPayload, type SlotIdV2, type TemplateEventMap, type TemplateEventType, type TemplateOptions, type TemplateSlot, type TicketCreateAfterPayload, type TranslateParams, type TranslationDict, type TranslationKey, type TranslationKeyRegistry, type TranslationPhase, VERSION, __resetElementRegistry__, announceElementName, configureBstageI18n, createWebComponent, dirNameToSlotId, fetchTranslations, getAdminEventsByDomain, getSlotsByTarget, getSlotsByTargetV2, interpolateTranslation, isSlotIdV2, lookupElementName, normalizeLanguage, observeLocale, parseRichText, pickTranslation, readLocale, resolveTranslationChain, slotIdToDirName };
1208
+ export { ADMIN_EVENT_CATALOG, type AdminConnectPayload, type AdminDomainEventMap, type AdminDomainEventType, type BoardInfo, type BoardPostInfo, type BodyRequestOptions, BstageClient, type BstageConfig, type BstageI18nConfig, type Content, type ContentsSectionInfo, type CustomSlots, type CustomSlotsV2, DEFAULT_LANGUAGE, type DigitalTicket, type FetchFunction, type FrameBridgeInit, type FrameBridgeLike, type FrameworkAdapter, HttpClient, type HttpClientConfig, HttpError, type HttpMethod, type HttpResponse, HttpTimeoutError, LANGUAGES, type LanguageCode, type LocaleTarget, type PathParams, PlatformBridge, type PlatformEventHandler, type PlatformEventMap, type PlatformEventType, type Product, type QueryRequestOptions, type RequestConfig, type RequestInterceptor, type RequestOptions, type ResponseInterceptor, type RichTextToken, SLOT_CATALOG, SLOT_CATALOG_V2, type SlotCatalogEntryV2, type SlotContextMap, type SlotContextOf, type SlotContextOverrides, type SlotContextPayload, type SlotIdV2, type TemplateEventMap, type TemplateEventType, type TemplateOptions, type TemplateSlot, type TicketCreateAfterPayload, type TranslateParams, type TranslationDict, type TranslationKey, type TranslationKeyRegistry, type TranslationPhase, VERSION, __resetElementRegistry__, announceElementName, configureBstageI18n, createWebComponent, dirNameToSlotId, fetchTranslations, getAdminEventsByDomain, getFrameBridge, getSlotsByTarget, getSlotsByTargetV2, interpolateTranslation, isSlotIdV2, lookupElementName, normalizeLanguage, observeLocale, observeTranslations, parseRichText, pickTranslation, readLocale, resolveTranslationChain, slotIdToDirName };
package/dist/index.d.ts CHANGED
@@ -116,11 +116,14 @@ interface BstageConfig {
116
116
  * 특정 게이트웨이를 직접 지정하려면 `baseUrl`을 쓴다. (예약 필드)
117
117
  */
118
118
  phase?: 'dev' | 'qa' | 'real' | 'sandbox';
119
- /** base URL 직접 지정. 미지정 시 resolveBaseUrl()로 자동 결정. */
119
+ /** base URL 직접 지정. 미지정 시 `location.origin + /gw`(격리 프레임이면 init의 `gatewayBase`). */
120
120
  baseUrl?: string;
121
121
  /** 요청 타임아웃 (ms). */
122
122
  timeout?: number;
123
- /** 커스텀 fetch 함수. 미지정 시 `globalThis.__bstage_fetch__` → `globalThis.fetch` 순으로 사용. */
123
+ /**
124
+ * 커스텀 fetch 함수. 미지정 시 격리 프레임이면 브리지 fetch, 아니면 `globalThis.__bstage_fetch__` →
125
+ * `globalThis.fetch` 순으로 사용.
126
+ */
124
127
  fetch?: FetchFunction;
125
128
  }
126
129
 
@@ -303,6 +306,9 @@ declare function normalizeLanguage(value: string | null | undefined): LanguageCo
303
306
  * 어드민은 `<html lang>`이 SSR 기본값으로 고정(런타임 미갱신)이라 쿠키를 1차로 신뢰한다.
304
307
  *
305
308
  * DOM이 없는 환경(SSR 등)에서는 기본값을 돌려준다.
309
+ *
310
+ * 격리 프레임(브리지 있음)에서는 대상과 무관하게 호스트가 init·`locale` 이벤트로 준 값을 쓴다
311
+ * (프레임 문서의 쿠키·`<html lang>`은 호스트 것이 아니다).
306
312
  */
307
313
  declare function readLocale(target?: LocaleTarget): LanguageCode;
308
314
  /**
@@ -317,6 +323,8 @@ declare function readLocale(target?: LocaleTarget): LanguageCode;
317
323
  * 그 변경을 감지해 `readLocale('admin')`이 쿠키를 다시 읽는다.
318
324
  *
319
325
  * DOM/MutationObserver가 없는 환경에서는 아무 동작도 하지 않는 해제 함수를 반환한다.
326
+ *
327
+ * 격리 프레임에서는 브리지 `locale` 이벤트를 구독한다.
320
328
  */
321
329
  declare function observeLocale(onChange: (locale: LanguageCode) => void, target?: LocaleTarget): () => void;
322
330
  /** 플랫폼 번역 사전 — 로케일별 `Bxxxxx` 키 → 문자열(flat). */
@@ -390,10 +398,19 @@ declare function configureBstageI18n(config: BstageI18nConfig): void;
390
398
  * fetch는 `config.fetch` → `globalThis.fetch` 순으로 해석한다(공개 CDN이라 인증 fetch를 쓰지 않는다).
391
399
  * 실패 시 캐시를 비워 재시도를 허용한다.
392
400
  *
401
+ * 격리 프레임에서는 CDN을 부르지 않는다(프레임은 직접 네트워크가 막혀 있다). 호스트가 init·`locale`
402
+ * 이벤트에 실어 준 **현재 locale 사전**만 돌려주고, 다른 locale·사전이 없으면 빈 사전이다.
403
+ *
393
404
  * @param locale 받아올 로케일
394
405
  * @param system 번역 시스템(`user`\|`admin`). React에서는 `BstageLocaleProvider`의 `target`이 넘긴다.
395
406
  */
396
407
  declare function fetchTranslations(locale: LanguageCode, system?: LocaleTarget): Promise<TranslationDict>;
408
+ /**
409
+ * @internal 격리 프레임에서 호스트가 번역 사전을 새로 주면 알린다 — locale이 같고 사전만 늦게 와도.
410
+ * (`observeLocale`은 locale 값이 바뀔 때만 알리므로, 사전이 비동기로 뒤늦게 오는 흐름을 놓친다.)
411
+ * 브리지가 없으면 아무 동작도 하지 않는 해제 함수를 반환한다.
412
+ */
413
+ declare function observeTranslations(onChange: () => void): () => void;
397
414
  /**
398
415
  * 현재 로케일부터 시작하는 조회 순서를 만든다. 중복은 제거한다.
399
416
  *
@@ -1152,10 +1169,40 @@ declare function lookupElementName(url: string): string | undefined;
1152
1169
  */
1153
1170
  declare function __resetElementRegistry__(): void;
1154
1171
 
1172
+ /**
1173
+ * 격리 프레임 브리지의 init 중 core가 읽는 부분.
1174
+ * 전체 모양은 `@bstage-sdk/bridge`의 `BridgeInit`이다 — core는 그 패키지에 의존하지 않고
1175
+ * 필요한 필드만 구조적으로 읽는다.
1176
+ */
1177
+ interface FrameBridgeInit {
1178
+ readonly gatewayBase?: string;
1179
+ readonly locale?: string;
1180
+ readonly translations?: Readonly<Record<string, string>>;
1181
+ }
1182
+ /**
1183
+ * 격리 프레임 안에서 브리지 클라이언트가 설치하는 `globalThis.__bstage_bridge__`의 최소 모양.
1184
+ *
1185
+ * 템플릿이 sandbox iframe에서 돌 때만 존재한다. 없으면 모든 어댑터는 기존 경로(inline)로 동작한다.
1186
+ */
1187
+ interface FrameBridgeLike {
1188
+ readonly hostOrigin: string;
1189
+ getInit(): FrameBridgeInit | undefined;
1190
+ request(type: string, payload: unknown): Promise<unknown>;
1191
+ on(type: string, fn: (payload: unknown) => void): () => void;
1192
+ readonly fetch: FetchFunction;
1193
+ }
1194
+ /**
1195
+ * @internal 현재 실행 환경의 프레임 브리지를 돌려준다. 격리 프레임이 아니면 `undefined`.
1196
+ *
1197
+ * 모양이 맞을 때만 인정한다 — 템플릿이나 다른 스크립트가 같은 이름에 다른 값을 둬도
1198
+ * 기존 경로가 깨지지 않게 한다. 요청마다 호출해 늦은 설치도 반영한다.
1199
+ */
1200
+ declare function getFrameBridge(): FrameBridgeLike | undefined;
1201
+
1155
1202
  /**
1156
1203
  * 번들에 심긴 core 버전. `BstageClient`가 모든 요청에 `x-bmf-sdk-version` 헤더로 실어
1157
1204
  * 게이트웨이 로그에서 어느 SDK 버전이 쓰이는지 볼 수 있게 한다.
1158
1205
  */
1159
1206
  declare const VERSION: string;
1160
1207
 
1161
- export { ADMIN_EVENT_CATALOG, type AdminConnectPayload, type AdminDomainEventMap, type AdminDomainEventType, type BoardInfo, type BoardPostInfo, type BodyRequestOptions, BstageClient, type BstageConfig, type BstageI18nConfig, type Content, type ContentsSectionInfo, type CustomSlots, type CustomSlotsV2, DEFAULT_LANGUAGE, type DigitalTicket, type FetchFunction, type FrameworkAdapter, HttpClient, type HttpClientConfig, HttpError, type HttpMethod, type HttpResponse, HttpTimeoutError, LANGUAGES, type LanguageCode, type LocaleTarget, type PathParams, PlatformBridge, type PlatformEventHandler, type PlatformEventMap, type PlatformEventType, type Product, type QueryRequestOptions, type RequestConfig, type RequestInterceptor, type RequestOptions, type ResponseInterceptor, type RichTextToken, SLOT_CATALOG, SLOT_CATALOG_V2, type SlotCatalogEntryV2, type SlotContextMap, type SlotContextOf, type SlotContextOverrides, type SlotContextPayload, type SlotIdV2, type TemplateEventMap, type TemplateEventType, type TemplateOptions, type TemplateSlot, type TicketCreateAfterPayload, type TranslateParams, type TranslationDict, type TranslationKey, type TranslationKeyRegistry, type TranslationPhase, VERSION, __resetElementRegistry__, announceElementName, configureBstageI18n, createWebComponent, dirNameToSlotId, fetchTranslations, getAdminEventsByDomain, getSlotsByTarget, getSlotsByTargetV2, interpolateTranslation, isSlotIdV2, lookupElementName, normalizeLanguage, observeLocale, parseRichText, pickTranslation, readLocale, resolveTranslationChain, slotIdToDirName };
1208
+ export { ADMIN_EVENT_CATALOG, type AdminConnectPayload, type AdminDomainEventMap, type AdminDomainEventType, type BoardInfo, type BoardPostInfo, type BodyRequestOptions, BstageClient, type BstageConfig, type BstageI18nConfig, type Content, type ContentsSectionInfo, type CustomSlots, type CustomSlotsV2, DEFAULT_LANGUAGE, type DigitalTicket, type FetchFunction, type FrameBridgeInit, type FrameBridgeLike, type FrameworkAdapter, HttpClient, type HttpClientConfig, HttpError, type HttpMethod, type HttpResponse, HttpTimeoutError, LANGUAGES, type LanguageCode, type LocaleTarget, type PathParams, PlatformBridge, type PlatformEventHandler, type PlatformEventMap, type PlatformEventType, type Product, type QueryRequestOptions, type RequestConfig, type RequestInterceptor, type RequestOptions, type ResponseInterceptor, type RichTextToken, SLOT_CATALOG, SLOT_CATALOG_V2, type SlotCatalogEntryV2, type SlotContextMap, type SlotContextOf, type SlotContextOverrides, type SlotContextPayload, type SlotIdV2, type TemplateEventMap, type TemplateEventType, type TemplateOptions, type TemplateSlot, type TicketCreateAfterPayload, type TranslateParams, type TranslationDict, type TranslationKey, type TranslationKeyRegistry, type TranslationPhase, VERSION, __resetElementRegistry__, announceElementName, configureBstageI18n, createWebComponent, dirNameToSlotId, fetchTranslations, getAdminEventsByDomain, getFrameBridge, getSlotsByTarget, getSlotsByTargetV2, interpolateTranslation, isSlotIdV2, lookupElementName, normalizeLanguage, observeLocale, observeTranslations, parseRichText, pickTranslation, readLocale, resolveTranslationChain, slotIdToDirName };
package/dist/index.js CHANGED
@@ -119,10 +119,26 @@ var HttpTimeoutError = class extends Error {
119
119
  };
120
120
 
121
121
  // src/version.ts
122
- var VERSION = "0.8.7";
122
+ var VERSION = "0.9.0";
123
+
124
+ // src/frameBridge.ts
125
+ function getFrameBridge() {
126
+ const candidate = globalThis.__bstage_bridge__;
127
+ if (typeof candidate !== "object" || candidate === null) return void 0;
128
+ const b = candidate;
129
+ if (typeof b.hostOrigin !== "string" || typeof b.getInit !== "function" || typeof b.request !== "function" || typeof b.on !== "function" || typeof b.fetch !== "function") {
130
+ return void 0;
131
+ }
132
+ return candidate;
133
+ }
123
134
 
124
135
  // src/client/BstageClient.ts
125
136
  function resolveBaseUrl() {
137
+ const bridge = getFrameBridge();
138
+ if (bridge) {
139
+ const gatewayBase = bridge.getInit()?.gatewayBase;
140
+ return typeof gatewayBase === "string" ? gatewayBase : `${bridge.hostOrigin}/gw`;
141
+ }
126
142
  if (typeof globalThis.location !== "undefined") {
127
143
  return `${globalThis.location.origin}/gw`;
128
144
  }
@@ -137,6 +153,8 @@ function resolvePath(template, params) {
137
153
  });
138
154
  }
139
155
  var resolveBstageFetch = (input, init) => {
156
+ const bridge = getFrameBridge();
157
+ if (bridge) return bridge.fetch(input, init);
140
158
  const hook = globalThis.__bstage_fetch__;
141
159
  return hook ? hook(input, init) : globalThis.fetch(input, init);
142
160
  };
@@ -235,7 +253,12 @@ function readLanguageCookie(target) {
235
253
  }
236
254
  return void 0;
237
255
  }
256
+ function readBridgeLocale(bridge) {
257
+ return normalizeLanguage(bridge.getInit()?.locale) ?? DEFAULT_LANGUAGE;
258
+ }
238
259
  function readLocale(target = "user") {
260
+ const bridge = getFrameBridge();
261
+ if (bridge) return readBridgeLocale(bridge);
239
262
  if (typeof document === "undefined") return DEFAULT_LANGUAGE;
240
263
  const htmlLang = normalizeLanguage(document.documentElement.lang);
241
264
  if (target === "admin") {
@@ -244,6 +267,17 @@ function readLocale(target = "user") {
244
267
  return htmlLang ?? readLanguageCookie("user") ?? DEFAULT_LANGUAGE;
245
268
  }
246
269
  function observeLocale(onChange, target = "user") {
270
+ const bridge = getFrameBridge();
271
+ if (bridge) {
272
+ let current2 = readBridgeLocale(bridge);
273
+ return bridge.on("locale", () => {
274
+ const next = readBridgeLocale(bridge);
275
+ if (next !== current2) {
276
+ current2 = next;
277
+ onChange(next);
278
+ }
279
+ });
280
+ }
247
281
  if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
248
282
  return () => {
249
283
  };
@@ -379,6 +413,8 @@ function fetchVersionBase(source, fetchFn) {
379
413
  return promise;
380
414
  }
381
415
  function fetchTranslations(locale, system) {
416
+ const bridge = getFrameBridge();
417
+ if (bridge) return Promise.resolve(readBridgeTranslations(bridge, locale));
382
418
  const source = resolveSource(system);
383
419
  const key = `${sourceKey(source)}|${locale}`;
384
420
  const cached = translationCache.get(key);
@@ -395,6 +431,26 @@ function fetchTranslations(locale, system) {
395
431
  translationCache.set(key, promise);
396
432
  return promise;
397
433
  }
434
+ function observeTranslations(onChange) {
435
+ const bridge = getFrameBridge();
436
+ if (!bridge) return () => {
437
+ };
438
+ let last = bridge.getInit()?.translations;
439
+ return bridge.on("locale", () => {
440
+ const next = bridge.getInit()?.translations;
441
+ if (next !== last) {
442
+ last = next;
443
+ onChange();
444
+ }
445
+ });
446
+ }
447
+ function readBridgeTranslations(bridge, locale) {
448
+ const init = bridge.getInit();
449
+ if (!init || normalizeLanguage(init.locale) !== locale) return {};
450
+ const dict = init.translations;
451
+ if (typeof dict !== "object" || dict === null) return {};
452
+ return Object.fromEntries(Object.entries(dict).filter(([, v]) => typeof v === "string"));
453
+ }
398
454
  var DEFAULT_FALLBACK_CHAIN = ["en", "ko"];
399
455
  function resolveTranslationChain(current) {
400
456
  const tail = i18nConfig.fallbackChain ?? DEFAULT_FALLBACK_CHAIN;
@@ -871,6 +927,7 @@ export {
871
927
  dirNameToSlotId,
872
928
  fetchTranslations,
873
929
  getAdminEventsByDomain,
930
+ getFrameBridge,
874
931
  getSlotsByTarget,
875
932
  getSlotsByTargetV2,
876
933
  interpolateTranslation,
@@ -878,6 +935,7 @@ export {
878
935
  lookupElementName,
879
936
  normalizeLanguage,
880
937
  observeLocale,
938
+ observeTranslations,
881
939
  parseRichText,
882
940
  pickTranslation,
883
941
  readLocale,
@@ -124,7 +124,7 @@ createTemplate(MyTemplate, { name: 'my-space-hello', styles: css })
124
124
  - `loadTemplate`이 로드 직후 이 값을 조회합니다.
125
125
  - 조회에 실패하면 **옛 번들 폴백**을 탑니다. 옛 산출물은 디렉토리 이름이 곧 태그명이었으므로(`dist/{name}/`) URL의 디렉토리 세그먼트를 후보로 삼되, 추측으로 쓰지 않고 `customElements.get()`으로 실제 등록됐는지 확인합니다. 확인까지 실패하면 던집니다. 옛 번들이 전부 재배포되면 걷어낼 수 있습니다.
126
126
 
127
- > 예전에는 `manifest.json`이 이 값을 날랐습니다. 실어 나르던 정보가 태그명 하나뿐이었고 `entry`는 늘 `template.js`로 고정이라 파일을 없앴습니다. 함께 담기던 `sdkVersion`·`framework`·`type`은 호스트가 쓰지 않던 값입니다.
127
+ > 예전에는 `manifest.json`이 이 값을 날랐습니다. 호스트 로더는 지금도 manifest를 읽지 않습니다 — 태그명은 위 전역으로 충분합니다. 다만 **포털**이 iframe 격리 판단에 manifest를 쓰므로 0.9.0부터 다시 생성합니다(아래 "manifest.json").
128
128
 
129
129
  `spaceId` / `type`(user·admin) / `tag`(버전) 같은 상위 경로 세그먼트는 관리도구가 CDN 업로드 단계에서 주입하며, SDK는 관여하지 않습니다. 슬롯 배치와 풀페이지 레이아웃은 관리도구가 소유합니다.
130
130
 
@@ -136,15 +136,59 @@ createTemplate(MyTemplate, { name: 'my-space-hello', styles: css })
136
136
  dist/
137
137
  landing/
138
138
  template.js ← 페이지 /landing
139
+ manifest.json
139
140
  settings/
140
141
  custom/
141
142
  template.js ← 페이지 /settings/custom
143
+ manifest.json
142
144
  user.contents-home.curation--after/
143
145
  template.js ← 위젯
146
+ manifest.json
144
147
  ```
145
148
 
146
149
  `template.js`는 IIFE 번들 하나입니다(React 런타임 + 컴포넌트 + CSS 인라인).
147
150
 
151
+ ### manifest.json
152
+
153
+ `template.js` 옆에 놓입니다. 키 순서·들여쓰기가 고정이라 같은 입력이면 같은 바이트가 나옵니다(앞 네 필드는 예전 CLI가 만들던 것과 같은 모양, `type`만 빠짐. `bridgeProtocol`이 뒤에 붙음).
154
+
155
+ ```json
156
+ {
157
+ "elementName": "my-space-hello",
158
+ "entry": "template.js",
159
+ "sdkVersion": "0.9.0",
160
+ "framework": "react",
161
+ "bridgeProtocol": 1
162
+ }
163
+ ```
164
+
165
+ | 필드 | 값 |
166
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
167
+ | `elementName` | `createTemplate`의 `name` — 등록되는 Custom Element 태그 |
168
+ | `entry` | manifest 디렉토리 기준 번들 경로. 항상 `template.js` |
169
+ | `sdkVersion` | **번들에 실제로 실린** `@bstage-sdk/core` 버전(CLI 버전 아님). 두 벌 실렸으면 낮은 쪽. 정보용 |
170
+ | `framework` | `react` 등. 소스에서 못 찾으면 번들에 bstage-react가 실렸는지로 정함 |
171
+ | `bridgeProtocol` | 번들에 실린 core가 구현한 격리 브리지 프로토콜 버전. core `package.json`의 `bstage.bridgeProtocol`에서 읽음. 없을 수 있음 |
172
+
173
+ 읽는 쪽은 포털입니다. 게시 빌드를 전개할 때 산출물 디렉토리마다 `bridgeProtocol`·`elementName`을 기록하고, 둘 다 있고 `bridgeProtocol`이 1 이상일 때만 그 템플릿의 iframe 격리를 허용합니다. `sdkVersion`은 게이트에 쓰지 않습니다(정보용). 포털 빌더는 manifest가 있으면 앞 네 필드를 검증해 틀리면 빌드를 실패시킵니다.
174
+
175
+ > manifest는 **파트너 빌드가 스스로 적는 값**입니다. `bridgeProtocol`은 "이 번들은 격리 프레임에서 돌 수 있다"는 신고일 뿐 보안 근거가 아닙니다 — 격리 자체는 호스트(sandbox iframe·CSP·브리지 정책)가 강제하고, 브리지 호스트 정책은 manifest를 읽지 않습니다. 값이 틀리면 그 템플릿이 프레임 안에서 동작하지 않을 뿐입니다. 반대로 manifest가 없거나 값이 없으면 inline(호스트 origin에서 실행)으로만 배치되므로, 격리를 요구해야 하는 배치는 포털이 fail closed로 다룹니다(배치 거부).
176
+
177
+ 그래서 CLI는 **검증을 통과할 수 없는 manifest는 쓰지 않고 경고만** 냅니다 — 빌드는 성공하고 그 템플릿은 inline으로만 배치됩니다.
178
+
179
+ - `name`이 포털 규칙 `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`(100자 이하)에 맞지 않을 때 — CLI의 `name` 검증보다 좁습니다(`a--b`, `a-`는 빌드는 되지만 격리 불가)
180
+ - 번들에서 bstage-core 버전을 찾지 못했을 때
181
+
182
+ `bridgeProtocol`은 core가 package.json에 선언합니다.
183
+
184
+ ```json
185
+ { "name": "@bstage-sdk/core", "bstage": { "bridgeProtocol": 1 } }
186
+ ```
187
+
188
+ - core의 `bridgeProtocol`은 SDK 런타임 묶음(core·react 어댑터) 전체를 대표합니다. react는 core 버전을 고정하므로 옛 react가 새 core와 실리면 core가 두 벌이 되어 아래 최솟값 규칙이 덮습니다.
189
+ - 번들에 core가 여러 벌 실렸으면 **가장 작은 값**을 적습니다.
190
+ - 한 벌이라도 값을 모르면(필드가 없는 옛 core, 양의 정수가 아닌 값, package.json으로 귀속하지 못한 사본. 다른 패키지 dist에 미리 번들돼 들어온 core는 구분하지 못합니다) 필드를 **쓰지 않습니다** — 모르는 사본보다 높은 값을 주장하지 않기 위해서입니다. 이때 빌드는 경고만 내고 그 템플릿은 inline으로만 배치됩니다.
191
+
148
192
  페이지 산출물은 경로만큼 중첩됩니다 — 단일 계층이 아닙니다. `spaceId` / `type`(user·admin) / `tag`(버전) 등 상위 경로 세그먼트는 관리도구가 CDN 업로드 시점에 주입하며, SDK는 관여하지 않습니다.
149
193
 
150
194
  ---
@@ -4,7 +4,7 @@
4
4
 
5
5
  `bstage dev`는 Vite 개발 서버에 인증 프록시를 내장하여, 로컬에서도 b.stage API를 인증된 상태로 호출할 수 있게 합니다. 별도 프록시 서버 없이 Vite 플러그인 미들웨어 하나로 동작합니다.
6
6
 
7
- **왜 프록시가 필요한가:** b.stage gateway는 인증 토큰을 httpOnly 쿠키로 관리합니다. 프로덕션에서는 동일 도메인이므로 쿠키가 자동 첨부되지만, localhost에서는 도메인이 달라 쿠키가 전달되지 않습니다. 인증 프록시가 이 문제를 해결합니다.
7
+ **왜 프록시가 필요한가:** b.stage 로그인 토큰은 쿠키로 관리됩니다(리프레시 토큰은 HttpOnly, 액세스 토큰 쿠키는 현재 HttpOnly가 아닙니다 — 전환 계획). 프로덕션에서는 동일 도메인이므로 쿠키가 자동 첨부되고 호스트가 `/gw` 호출에 Bearer 헤더를 붙이지만, localhost에서는 도메인이 달라 쿠키가 전달되지 않습니다. 인증 프록시가 로그인 쿠키를 읽어 `/gw` 호출에 `Authorization: Bearer` 헤더를 붙여 이 문제를 해결합니다.
8
8
 
9
9
  > **liquid 템플릿 레포는 다릅니다.** `public/{user|admin}/{이름}/template.liquid`로 만든 레포에서는 같은 명령이 Vite·인증 프록시 없이 liquidjs 프리뷰를 띄웁니다 — [7절](#7-liquid-프리뷰-liquid-레포) 참조. 1~6절은 React(SDK) 레포 이야기입니다.
10
10
 
@@ -30,7 +30,7 @@ b.stage 3rd-party 템플릿은 **Web Component** 기반으로 동작합니다. S
30
30
  | `sdk` | React 컴포넌트를 Web Component로 빌드해 페이지·위젯 자리에 올립니다 (이 문서 전체) |
31
31
  | `liquid` | 플랫폼이 서버에서 데이터를 넣어 렌더하는 템플릿을 페이지 자리에만 올립니다 |
32
32
 
33
- 이 문서는 `sdk` 방식을 기준으로 씁니다. liquid 방식은 [LIQUID.md](./LIQUID.md)에 따로 정리돼 있습니다. 한 레포에 두 방식을 섞으면 포털 빌더가 패키징하지 못합니다.
33
+ 이 문서는 `sdk` 방식을 기준으로 설명합니다. liquid 방식은 [LIQUID.md](./LIQUID.md)에 따로 정리되어 있습니다.
34
34
 
35
35
  ---
36
36
 
@@ -165,19 +165,19 @@ createTemplate(BmfHelloTemplate, {
165
165
  | `type` | `string` | | 관리도구에서 사용하는 템플릿 타입 식별자 |
166
166
  | `shadow` | `boolean` | | Shadow DOM 사용 여부 (기본값: `true`) |
167
167
 
168
- > 배치는 소스 위치가 정합니다(페이지는 `src/pages/` 폴더 구조, 위젯은 `slot` 옵션). 풀페이지 레이아웃(topBar/bottomBar)은 관리도구가 소유하므로 템플릿 코드에서 선언하지 않습니다. 자세한 내용은 [BUILD_SYSTEM.md](./BUILD_SYSTEM.md)·[SLOT_CATALOG_V2.md](./SLOT_CATALOG_V2.md)를 참고하세요.
168
+ > 배치 위치는 소스 위치로 정해집니다(페이지는 `src/pages/` 폴더 구조, 위젯은 `slot` 옵션). 상단·하단 바(topBar/bottomBar) 같은 페이지 레이아웃은 관리도구에서 설정하므로, 템플릿 코드에서 선언하지 않습니다. 자세한 내용은 [BUILD_SYSTEM.md](./BUILD_SYSTEM.md)·[SLOT_CATALOG_V2.md](./SLOT_CATALOG_V2.md)를 참고하세요.
169
169
 
170
170
  > - 컴포넌트는 반드시 `export default`로 내보내야 합니다
171
171
  > - `createTemplate()`은 반드시 모듈 최상위 레벨에서 호출해야 합니다. 함수 내부에서 호출하면 빌드 시 메타데이터를 추출할 수 없습니다.
172
- > - `name`은 Custom Element 스펙상 하이픈 포함 필수 — 위반 시 런타임 에러. 관례적으로 `{space}-{suffix}` 형태를 권장합니다.
172
+ > - `name`에는 Custom Element 규격에 따라 하이픈이 반드시 들어가야 하며, 없으면 런타임 에러가 발생합니다. 관례적으로 `{space}-{suffix}` 형태를 권장합니다.
173
173
 
174
174
  호출 시 `<name>` 그대로가 Custom Element 태그로 등록됩니다. 예를 들어 위 코드는 `<bmf-hello>` 요소를 등록합니다.
175
175
 
176
176
  ### 새 템플릿 추가
177
177
 
178
- 폴더를 만들고 `template.tsx`를 작성하면 자동으로 인식됩니다. **폴더 이름의 뜻이 페이지와 위젯에서 다릅니다.**
178
+ 폴더를 만들고 `template.tsx`를 작성하면 자동으로 인식됩니다. **폴더 이름의 역할이 페이지와 위젯에서 다릅니다.**
179
179
 
180
- 페이지는 폴더 구조가 곧 배포 경로입니다. `/profile`에 붙일 페이지라면:
180
+ 페이지는 폴더 구조가 그대로 배포 경로가 됩니다. 예를 들어 `/profile` 경로에 배포할 페이지는 다음과 같이 만듭니다.
181
181
 
182
182
  ```bash
183
183
  mkdir -p src/pages/profile
@@ -196,7 +196,7 @@ createTemplate(BmfProfileTemplate, {
196
196
  })
197
197
  ```
198
198
 
199
- 위젯은 폴더 이름이 자유입니다. 어느 자리에 붙을지는 `slot` 옵션만 정합니다.
199
+ 위젯 폴더에는 위젯 이름을 붙이면 됩니다. 위젯이 들어갈 자리는 폴더 이름이 아니라 `slot` 옵션으로만 정해집니다.
200
200
 
201
201
  ```bash
202
202
  mkdir -p src/slots/curation-banner
@@ -216,7 +216,7 @@ createTemplate(CurationBanner, {
216
216
  })
217
217
  ```
218
218
 
219
- `slot`에 넣을 수 있는 값은 편집기 자동완성으로 볼 수 있고 전체 목록은 [SLOT_CATALOG_V2.md](./SLOT_CATALOG_V2.md)에 있습니다. 오타가 있으면 `bstage build`가 후보와 함께 막아줍니다.
219
+ `slot`에 넣을 수 있는 값은 편집기 자동완성으로 볼 수 있고 전체 목록은 [SLOT_CATALOG_V2.md](./SLOT_CATALOG_V2.md)에 있습니다. 값에 오타가 있으면 `bstage build`가 사용할 수 있는 후보를 보여 주고 빌드를 중단합니다.
220
220
 
221
221
  > 동적 경로(`src/pages/artist/[id]/`)는 아직 지원하지 않습니다.
222
222
 
@@ -556,7 +556,7 @@ npx @bstage-sdk/cli@latest init --target admin
556
556
 
557
557
  ### 한 레포는 유저 아니면 어드민 하나입니다
558
558
 
559
- `target`이 **프로젝트 단위**인 이유는 페이지의 배포 경로가 `src/pages/` 폴더 구조이기 때문입니다. 유저 `/settings`와 어드민 `/settings`를 한 레포에 두려면 둘 다 `src/pages/settings/template.tsx`여야 하는데 파일이 하나뿐이라 부딪힙니다. 그래서 유저 템플릿과 어드민 템플릿은 **레포를 나눠서** 개발합니다.
559
+ 유저 템플릿과 어드민 템플릿은 **레포를 나눠서** 개발합니다. `target`을 **프로젝트 단위**로 정하는 것도 이 때문입니다. 페이지의 배포 경로는 `src/pages/` 폴더 구조로 정해지므로, 유저 `/settings`와 어드민 `/settings`를 한 레포에 두면 두 페이지가 같은 파일(`src/pages/settings/template.tsx`)을 써야 해서 충돌합니다.
560
560
 
561
561
  ### `target`이 정하는 것
562
562
 
@@ -575,18 +575,18 @@ npx @bstage-sdk/cli@latest init --target admin
575
575
 
576
576
  ### API 호출
577
577
 
578
- 어드민 템플릿도 **같은 `BstageClient`로 호출합니다.** `baseUrl`을 주지 않으면 `location.origin + '/gw'`가 되므로 어드민 도메인에 임베드된 화면은 자동으로 **어드민 게이트웨이**를 향합니다. 코드는 [6.1 BstageClient 설정](#61-bstageclient-설정)과 같고, 다른 점은 게이트웨이가 **두 가지를 함께 확인**한다는 것뿐입니다.
578
+ 어드민 템플릿도 **같은 `BstageClient`로 호출합니다.** `baseUrl`을 주지 않으면 `location.origin + '/gw'`가 되므로 어드민 도메인에 임베드된 화면은 자동으로 **어드민 게이트웨이**를 호출합니다. 코드는 [6.1 BstageClient 설정](#61-bstageclient-설정)과 같고, 다른 점은 게이트웨이가 **두 가지를 함께 확인**한다는 것뿐입니다.
579
579
 
580
- | 확인하는 것 | 어디서 오나 | 템플릿이 할 일 |
580
+ | 확인하는 것 | 전달 방식 | 템플릿이 할 일 |
581
581
  | --- | --- | --- |
582
- | 로그인한 운영자인가 | 어드민 로그인 쿠키 — 같은 도메인이라 브라우저가 자동으로 실어 보냅니다 | 없음 |
583
- | 허가된 앱인가 | `X-BSTAGE-APP-ID`·`X-BSTAGE-APP-KEY` 헤더 — **관리자 콘솔용(MANAGEMENT) 키**(`bsm_`)만 통과합니다 | `.env`와 포털 빌드 환경변수에 MANAGEMENT 키를 넣습니다 |
584
-
585
- - **키 발급**: 포털 `조직 › API 키`에서 키 타입 **MANAGEMENT**, 스테이지는 이 화면이 들어갈 **하나**, 허용 Origin은 **어드민 콘솔 도메인**(예: sandbox는 `https://{스테이지ID}.mysandstage.in`)으로 발급합니다. 허용 Origin에 자동으로 채워지는 값은 유저 도메인이라 그대로 두면 어드민에서 온 호출이 거부됩니다. 변수 이름은 유저 템플릿과 같고 값만 다릅니다 — `VITE_BSTAGE_APP_KEY=bsm_…`.
586
- - **Surface와 키 타입은 짝입니다** — USER ↔ PUBLIC(`bsp_`), ADMIN ↔ MANAGEMENT(`bsm_`). PUBLIC 키로 어드민 게이트웨이를 부르면 `401 KEY_TYPE_NOT_ALLOWED`, 어드민에 로그인하지 않았거나 어드민 도메인 밖에서 부르면 `401 MISSING_ADMIN_ACCESS_TOKEN`, 허용 Origin이 유저 도메인으로 남아 있으면 `403`입니다. 반대로 MANAGEMENT 키를 유저 템플릿에 넣으면 유저 게이트웨이가 거부하고 키만 노출됩니다 — 그래서 포털은 사용자(USER) 레포의 빌드 환경변수에 `bsm_` 값을 저장하지 않습니다(400). 게시한 뒤에는 `bstage doctor`가 키 타입 불일치(`appkey.type_surface`)를 짚어 줍니다.
587
- - **MANAGEMENT 키도 빌드 결과물에 평문으로 실립니다.** 어드민 화면을 열 수 있는 사람은 개발자 도구로 볼 수 있으므로, 허용 Origin과 스테이지 범위를 좁히는 발급 조건이 곧 방어선입니다. 의심되면 포털에서 회전(새 키 발급 + 옛 키 즉시 만료)한 뒤 빌드 환경변수를 바꾸고 다시 빌드합니다 — 회전하면 APP KEY뿐 아니라 **APP ID도 새 값**이므로 둘 다 바꿉니다. 서버용(SIGNING) 키는 어떤 템플릿에도 넣지 않습니다.
588
- - **호출할 수 있는 API**는 포털 개발자 문서의 **API 레퍼런스 › 운영자 탭**에 있는 것뿐입니다. 거기 없는 경로를 추측해 만들지 말고, 필요한 API가 목록에 없으면 b.stage에 요청하세요.
589
- - **포털은 빌드 환경변수 저장 시점에 거부합니다**(400): 키 이름이 `^VITE_[A-Z0-9_]+$`가 아니거나 32개 초과·이름 128자 초과·값 2048자 초과, 앞뒤 공백을 뺀 값이 `bss_`로 시작(모든 레포)하거나 `bsm_`로 시작(사용자 레포), APP ID·APP KEY가 그 스테이지 키와 맞지 않음(공백 포함·없는 키·만료/폐기·스테이지 범위 밖·짝이 아닌 KEY — 파트너 조회가 실패하면 이 검사는 건너뜁니다).
582
+ | 운영자 로그인 여부 | 어드민 로그인 쿠키(같은 도메인이므로 브라우저가 자동으로 보냅니다) | 없음 |
583
+ | 허가된 앱 여부 | `X-BSTAGE-APP-ID`·`X-BSTAGE-APP-KEY` 헤더(**관리자 콘솔용(MANAGEMENT) 키**(`bsm_`)만 통과합니다) | `.env`와 포털 빌드 환경변수에 MANAGEMENT 키를 넣습니다 |
584
+
585
+ - **키 발급**: 포털 `조직 › API 키`에서 키 타입 **MANAGEMENT**, 스테이지는 이 화면이 들어갈 **하나**, 허용 Origin은 **어드민 콘솔 도메인**(예: sandbox는 `https://{스테이지ID}.mysandstage.in`)으로 발급합니다. 허용 Origin에 자동으로 채워지는 값은 유저 도메인이므로, 그대로 두면 어드민에서 보낸 호출이 거부됩니다. 변수 이름은 유저 템플릿과 같고 값만 다릅니다(`VITE_BSTAGE_APP_KEY=bsm_…`).
586
+ - **Surface마다 사용할 키 타입이 정해져 있습니다.** USER에는 PUBLIC(`bsp_`), ADMIN에는 MANAGEMENT(`bsm_`) 키를 사용합니다. PUBLIC 키로 어드민 게이트웨이를 호출하면 `401 KEY_TYPE_NOT_ALLOWED`, 어드민에 로그인하지 않았거나 어드민 도메인 밖에서 호출하면 `401 MISSING_ADMIN_ACCESS_TOKEN`, 허용 Origin이 유저 도메인으로 남아 있으면 `403`입니다. 반대로 MANAGEMENT 키를 유저 템플릿에 넣으면 유저 게이트웨이가 거부하고 키만 노출됩니다. 그래서 포털은 유저(USER) 레포의 빌드 환경변수에 `bsm_` 값을 저장하지 않습니다(400). 게시한 뒤에는 `bstage doctor`가 키 타입 불일치(`appkey.type_surface`)를 알려 줍니다.
587
+ - **MANAGEMENT 키도 빌드 결과물에 평문으로 포함됩니다.** 어드민 화면을 열 수 있는 사람은 개발자 도구로 키를 볼 수 있으므로, 허용 Origin과 스테이지 범위를 좁히는 발급 조건이 키를 보호하는 유일한 수단입니다. 유출이 의심되면 포털에서 키를 회전(새 키 발급 + 옛 키 즉시 만료)한 뒤 빌드 환경변수를 바꾸고 다시 빌드합니다. 회전하면 APP KEY뿐 아니라 **APP ID도 새 값**이므로 둘 다 바꿉니다. 서버용(SIGNING) 키는 어떤 템플릿에도 넣지 않습니다.
588
+ - **호출할 수 있는 API**는 포털 개발자 문서의 **API 레퍼런스 › 운영자 탭**에 있는 것뿐입니다. 목록에 없는 경로를 임의로 만들어 호출하지 말고, 필요한 API가 목록에 없으면 b.stage에 요청하세요.
589
+ - **포털은 빌드 환경변수 저장 시점에 거부합니다**(400): 키 이름이 `^VITE_[A-Z0-9_]+$`가 아니거나 32개 초과·이름 128자 초과·값 2048자 초과, 앞뒤 공백을 뺀 값이 `bss_`로 시작(모든 레포)하거나 `bsm_`로 시작(유저 레포), APP ID·APP KEY가 그 스테이지 키와 맞지 않음(공백 포함·없는 키·만료/폐기·스테이지 범위 밖·짝이 아닌 KEY. 파트너 조회가 실패하면 이 검사는 건너뜁니다).
590
590
  - **로컬·미리보기에서는 응답이 오지 않습니다.** `bstage dev`는 어드민 게이트웨이를 프록시하지 않고(어드민 모드에서 어드민 호스트로 가는 것은 로그인뿐), 포털 미리보기는 템플릿의 직접 게이트웨이 호출을 막습니다. 어드민 API 응답은 게시한 뒤 실제 어드민 화면에서 확인하세요.
591
591
 
592
592
  ---
package/docs/I18N.md CHANGED
@@ -154,7 +154,7 @@ configureBstageI18n({ tier: 'my-tier' }) // origin·phase·system·tier·fetch
154
154
 
155
155
  > **CDN이 죽어도 문구가 뜬다** — 성공한 응답을 Cache Storage에 남겨두고, 실패 시 그것으로 렌더한다. **번들 크기는 늘지 않는다.** 그 오리진에서 한 번도 성공한 적 없는 첫 방문이 장애와 겹치면 키가 노출된다.
156
156
 
157
- > **인증 fetch를 쓰지 않는다** — 번역 CDN은 공개 호스트다. 플랫폼이 주입하는 `__bstage_fetch__`는 URL을 가리지 않고 `Authorization`(+ dev/qa는 CF Access) 헤더를 붙이는데, 그러면 크로스 오리진 요청이 CORS preflight 대상이 되고 이 CDN은 `OPTIONS`에 403을 준다. 그래서 사전은 기본 `fetch`로 받는다(`BstageClient`는 게이트웨이를 부르므로 그쪽은 그대로 인증 fetch를 쓴다).
157
+ > **인증 fetch를 쓰지 않는다** — 번역 CDN은 공개 호스트다. 플랫폼이 주입하는 `__bstage_fetch__`는 허용된 목적지(같은 origin의 `/gw`, 플랫폼 허용 목록)에만 `Authorization`(+ dev/qa는 CF Access) 헤더를 붙이는데, 번역 CDN은 그 밖이고 인증 헤더가 붙으면 크로스 오리진 요청이 CORS preflight 대상이 되어 이 CDN은 `OPTIONS`에 403을 준다. 그래서 사전은 기본 `fetch`로 받는다(`BstageClient`는 게이트웨이를 부르므로 그쪽은 그대로 인증 fetch를 쓴다).
158
158
 
159
159
  > **알려진 오차** — `tier`는 런타임에 알 수 있는 신호가 없다(플랫폼은 서버에서 HTTP 헤더로 받는다). 그래서 기본 `inhouse`이며, `inhouse`가 아닌 tier를 쓰는 스페이스에 올라간 위젯은 위처럼 지정하지 않으면 inhouse 사전을 본다. 그 스페이스의 tier 값은 운영자에게 확인한다.
160
160
 
package/docs/LIQUID.md CHANGED
@@ -4,18 +4,17 @@
4
4
 
5
5
  ## 1. liquid 방식이란
6
6
 
7
- 플랫폼(서버)이 데이터를 넣어 렌더하는 [liquidjs](https://liquidjs.com/) 템플릿입니다. 브라우저에서 번들을 실행하지 않으므로 **빌드 산출물이 없습니다** — 포털이 push된 커밋의 템플릿 파일을 그대로 패키징합니다.
7
+ liquid 페이지는 플랫폼(서버)이 데이터를 채워 렌더링하는 [liquidjs](https://liquidjs.com/) 템플릿으로, **목록을 보여 주기만 하는 정적 페이지**에 알맞습니다. 브라우저에서 번들을 실행하지 않으므로 **빌드 산출물이 없습니다.** 포털은 push된 커밋의 템플릿 파일을 그대로 패키징합니다.
8
8
 
9
9
  | 항목 | sdk | liquid |
10
10
  | ----------- | ------------------------------------------- | ----------------------------------------- |
11
11
  | 작성 대상 | React 컴포넌트 (`template.tsx`) | `.liquid` 템플릿 (`template.liquid`) |
12
- | 렌더 시점 | 브라우저 (Web Component) | 서버 (플랫폼이 데이터를 넣어 렌더) |
12
+ | 렌더링 위치 | 브라우저 (Web Component) | 서버 (플랫폼이 데이터를 넣어 렌더) |
13
13
  | 배치 가능 | 페이지(PAGE) · 위젯(SLOT) | 페이지(PAGE)만 |
14
14
  | 로컬 산출물 | `dist/{경로}/template.js` | 없음 (검증만) |
15
15
  | 의존성 | core · react · design + Vite · 타입스크립트 | 저작 도구 `@bstage-sdk/cli` 하나 |
16
16
 
17
- - **페이지 단위로만** 배치합니다. 슬롯(위젯) 자리에는 쓸 수 없습니다 — `bstage deploy`가 위젯 배치를 발견하면 빌드를 만들기 전에 사전조건 오류(종료 코드 2)로 끊습니다.
18
- - **한 레포에 sdk와 섞지 않습니다.** `src/**/template.tsx`와 liquid 템플릿이 함께 있으면 포털 빌더가 패키징하지 못합니다.
17
+ - **페이지 단위로만** 배치합니다. 슬롯(위젯) 자리에는 쓸 수 없습니다. 위젯 배치가 있으면 `bstage deploy`가 빌드를 만들기 전에 사전 조건 오류(종료 코드 2)로 중단합니다.
19
18
 
20
19
  ## 2. 시작
21
20
 
@@ -33,9 +32,9 @@ public/
33
32
  └── data.json # 로컬 미리보기용 샘플 값 (배포에 나가지 않음)
34
33
  ```
35
34
 
36
- - **이름 규칙**: 소문자로 시작하고 소문자·숫자·하이픈만 씁니다(`^[a-z][a-z0-9-]*$`). CDN 경로 세그먼트가 되므로 대문자를 쓰면 대소문자를 구분하지 않는 파일시스템에서 로컬과 배포가 갈립니다.
37
- - **한 세그먼트**여야 합니다. `public/user/events/summer/`처럼 한 단계 더 깊으면 포털이 조용히 무시합니다.
38
- - `data.json`은 로컬 전용입니다. 실제 값은 플랫폼이 서버에서 넣습니다. **키 이름은 플랫폼 계약을 따라야 합니다** — 유저 화면 최상위는 `lounges`·`stories`·`contentSections`·`latestContents`·`shopCategories` 다섯 개입니다. 다른 이름을 쓰면 프리뷰에서만 그려지고 배포하면 빕니다. 각 항목의 필드는 계약이 아니라 모양을 보기 위한 예시이므로 실제 렌더 결과로 확인하세요. 어드민 화면이 받는 값은 확인되지 않았습니다.
35
+ - **이름 규칙**: 소문자로 시작하고 소문자·숫자·하이픈만 씁니다(`^[a-z][a-z0-9-]*$`). CDN 경로 세그먼트가 되므로 대문자를 쓰면 대소문자를 구분하지 않는 파일시스템에서 로컬과 배포 결과가 달라집니다.
36
+ - **한 세그먼트**여야 합니다. `public/user/events/summer/`처럼 한 단계 더 깊으면 포털이 별도 경고 없이 무시합니다.
37
+ - `data.json`은 로컬 전용입니다. 실제 값은 플랫폼이 서버에서 넣습니다. **키 이름은 플랫폼이 정한 이름을 따라야 합니다.** 유저 화면 최상위는 `lounges`·`stories`·`contentSections`·`latestContents`·`shopCategories` 다섯 개입니다. 다른 이름을 쓰면 미리보기에서만 보이고, 배포하면 빈 값이 됩니다. 각 항목의 필드는 계약이 아니라 모양을 보기 위한 예시이므로 실제 렌더 결과로 확인하세요. 어드민 화면이 받는 값은 확인되지 않았습니다.
39
38
  - 스캐폴드는 Vite·TypeScript·ESLint·`.env`를 만들지 않습니다. 번들링도 타입 검사도 하지 않기 때문입니다.
40
39
 
41
40
  ## 3. 로컬 미리보기 — `bstage dev`
@@ -45,7 +44,7 @@ npm run dev # = bstage dev
45
44
  bstage dev -p 3000 # 포트 지정 (기본 5173)
46
45
  ```
47
46
 
48
- 레포 종류는 파일 구조로 자동 판정하므로 옵션을 따로 주지 않습니다. liquid 레포면 Vite 대신 Node `http` + liquidjs 프리뷰가 뜨고, 항상 루프백(`127.0.0.1`)에만 바인딩합니다.
47
+ 레포 종류는 파일 구조로 자동 판정하므로, liquid 전용 레포는 옵션 없이 실행합니다(혼합 레포는 `bstage dev --kind liquid`). liquid 레포면 Vite 대신 Node `http` + liquidjs 미리보기 서버가 실행되고, 항상 루프백(`127.0.0.1`)에만 바인딩합니다.
49
48
 
50
49
  | 경로 | 화면 |
51
50
  | ------------------- | -------------------------------------------- |
@@ -55,11 +54,11 @@ bstage dev -p 3000 # 포트 지정 (기본 5173)
55
54
  | `/{surface}/{이름}` | 같은 폴더의 `data.json`으로 렌더한 결과 |
56
55
  | 그 밖의 경로 | `public/` 아래 정적 파일 (없으면 404) |
57
56
 
58
- - 파일을 고치면 **자동으로 새로고침**됩니다(프리뷰가 넣는 SSE 스크립트 — 배포물과 무관합니다).
59
- - 렌더가 실패하면 파일·줄 번호·메시지를 보여 주는 **오류 페이지**가 200으로 뜹니다. 고치는 즉시 리로드로 복구됩니다.
57
+ - 파일을 고치면 **자동으로 새로고침**됩니다(미리보기 서버가 넣는 SSE 스크립트로, 배포물과는 무관합니다).
58
+ - 렌더가 실패하면 파일·줄 번호·메시지를 보여 주는 **오류 페이지**가 HTTP 200 상태로 표시됩니다. 파일을 고치면 자동으로 새로고침되어 바로 정상 화면으로 돌아갑니다.
60
59
  - 규약을 벗어난 `template.liquid`도 목록에 보여 주되 "포털이 무시합니다"라고 표시합니다.
61
60
 
62
- **프리뷰에 없는 것**: 인증 프록시 · API 프록시 · phase · 실데이터. `--phase`를 줘도 무시한다는 안내만 출력합니다. 상세는 [DEV_SERVER.md §7](./DEV_SERVER.md#7-liquid-프리뷰-liquid-레포)을 참고하세요.
61
+ **미리보기에 없는 기능**: 인증 프록시 · API 프록시 · phase · 실데이터. `--phase`를 지정해도 무시되며, 그렇다는 안내만 출력됩니다. 상세는 [DEV_SERVER.md §7](./DEV_SERVER.md#7-liquid-프리뷰-liquid-레포)을 참고하세요.
63
62
 
64
63
  ## 4. 검증 — `bstage build` · `bstage doctor`
65
64
 
@@ -72,27 +71,28 @@ bstage build --json # { "kind": "liquid", "issues": [...] } 한 덩어리
72
71
 
73
72
  | 등급 | 검사 |
74
73
  | ----- | ------------------------------------------------------------------------------------------------------------------------------------ |
75
- | error | `template.liquid` 문법 오류(줄 번호 표시) · `data.json` JSON 파싱 실패 · 폴더 이름 규칙 위반 · sdk 템플릿과 혼재 · liquid 템플릿 0개 |
74
+ | error | `template.liquid` 문법 오류(줄 번호 표시) · `data.json` JSON 파싱 실패 · 폴더 이름 규칙 위반 · (혼합 레포) sdk 페이지 이름 `user`·`admin` · liquid 템플릿 0개 |
76
75
  | warn | 규약 밖 위치의 `template.liquid`(포털이 무시) · `layout.json` 존재(포털이 지움) · 폴더에 `template.liquid`가 아닌 파일만 있음 |
77
76
 
78
- 같은 결과를 `bstage doctor`가 "liquid 검증" 절에서 보여 줍니다(`--json`에서는 `liquid.issues`). doctor의 liquid 표는 sdk와 다릅니다 — 기대 파일은 템플릿 루트(`public/user` 또는 `public/admin`)와 `AGENTS.md`, 기대 스크립트는 `dev`·`build`이고, 권장 의존성 절은 나오지 않습니다. 버전 표도 `@bstage-sdk/cli` 한 줄뿐입니다.
77
+ 같은 결과를 `bstage doctor`가 "liquid 검증" 절에서 보여 줍니다(`--json`에서는 `liquid.issues`). doctor의 liquid 표는 sdk와 다릅니다. 기대 파일은 템플릿 루트(`public/user` 또는 `public/admin`)와 `AGENTS.md`, 기대 스크립트는 `dev`·`build`이고, 권장 의존성 절은 나오지 않습니다. 버전 표도 `@bstage-sdk/cli` 한 줄뿐입니다.
79
78
 
80
79
  ## 5. 배포
81
80
 
82
- 포털이 push된 커밋의 `public/{user|admin}/{이름}/`을 **그대로 패키징합니다**(`data.json`·`layout.json`은 제외 — 배치는 관리도구가 소유합니다). 로컬 산출물은 올라가지 않으므로 커밋·push가 먼저입니다.
81
+ 포털이 push된 커밋의 `public/{user|admin}/{이름}/`을 **그대로 패키징합니다**(`data.json`·`layout.json`은 제외합니다. 배치는 관리도구에서 설정합니다). 로컬 산출물은 업로드되지 않으므로, 먼저 커밋하고 push해야 합니다.
83
82
 
84
83
  1. **레포 연결만 포털 화면에서** 합니다. 이어서 `bstage login`·`bstage link` 뒤 `bstage placement create --template <이름>`으로 페이지 배치를 만듭니다(`<이름>`은 `public/{user|admin}/<이름>/`, 성공한 빌드가 없으면 빌드부터 합니다).
85
- 2. 이후에는 터미널에서 `bstage deploy`(빌드 → 라이브 적용) · `bstage rollback`(이전 성공 빌드로 되돌리기) · `bstage publish on|off`(게시 토글)를 씁니다. 로그인·링크·종료 코드 등 공통 절차는 sdk와 같습니다 — [GETTING_STARTED.md §9](./GETTING_STARTED.md#9-배포)를 참고하세요.
84
+ 2. 이후에는 터미널에서 `bstage deploy`(빌드 → 라이브 적용) · `bstage rollback`(이전 성공 빌드로 되돌리기) · `bstage publish on|off`(게시 토글)를 씁니다. 로그인·링크·종료 코드 등 공통 절차는 sdk와 같습니다. 자세한 내용은 [GETTING_STARTED.md §9](./GETTING_STARTED.md#9-배포)를 참고하세요.
85
+ 3. `bstage list`는 최근 빌드의 산출물 종류(`sdk`·`liquid`) 열을 함께 보여 줍니다. 옛 빌드에는 이 값이 없어 `-`로 나옵니다.
86
86
 
87
- > liquid 레포에는 `.env`가 없어 `bstage login`·`bstage link`가 phase를 읽을 곳이 없습니다. sandbox가 아닌 포털이면 두 명령 모두에 `--phase <phase>`를 주세요(생략하면 sandbox). 3. `bstage list`는 최근 빌드의 산출물 종류(`sdk`·`liquid`) 열을 함께 보여 줍니다. 옛 빌드에는 이 값이 없어 `-`로 나옵니다.
87
+ > liquid 레포에는 `.env`가 없어 `bstage login`·`bstage link`가 phase를 읽을 곳이 없습니다. sandbox가 아닌 포털이면 두 명령 모두에 `--phase <phase>`를 주세요(생략하면 sandbox).
88
88
 
89
89
  ## 6. 이미지·영상 넣기
90
90
 
91
91
  **포털 화면의 스테이지 > 미디어에 올리고, 받은 URL을 템플릿에 절대 주소로 넣습니다.** 이미지와 영상만 올릴 수 있고, 허용 형식·용량 한도는 업로드할 때 화면이 알려 줍니다.
92
92
 
93
- 레포에 파일을 두고 상대 경로로 참조하면 **배포 후 깨집니다.** 포털은 템플릿 폴더를 그대로 복사하지만, 렌더된 HTML이 스페이스 도메인 페이지 안으로 들어가므로 상대 경로는 그 도메인 기준으로 풀리고 산출물이 놓인 주소를 가리키지 않습니다.
93
+ 레포에 파일을 두고 상대 경로로 참조하면 **배포 후 깨집니다.** 포털은 템플릿 폴더를 그대로 복사하지만, 렌더된 HTML이 스페이스 도메인 페이지 안으로 들어가므로 상대 경로는 그 도메인 기준으로 해석되어 산출물이 놓인 주소를 가리키지 않습니다.
94
94
 
95
- 로컬 프리뷰는 이 점에서 배포와 **반대로** 동작합니다. 프리뷰는 템플릿 폴더의 파일을 함께 서빙하므로 상대 경로 이미지가 잘 보입니다. 프리뷰에서 보였다는 것을 배포에서도 되리라는 근거로 삼지 마세요.
95
+ 로컬 미리보기는 이 점에서 배포와 **반대로** 동작합니다. 미리보기는 템플릿 폴더의 파일을 함께 서빙하므로 상대 경로 이미지가 잘 보입니다. 미리보기에서 보인다고 해서 배포 후에도 보인다고 판단하지 마세요.
96
96
 
97
97
  ## 7. liquidjs 문법 요점
98
98
 
@@ -107,16 +107,16 @@ bstage build --json # { "kind": "liquid", "issues": [...] } 한 덩어리
107
107
  ```
108
108
 
109
109
  - 없는 값은 오류가 아니라 **빈 문자열**로 렌더됩니다(플랫폼과 같은 설정). 화면이 비어 보이면 값 이름을 먼저 의심하세요.
110
- - **`{% render %}`·`{% include %}`(파셜)는 배포에서 동작하지 않습니다.** 배포 뷰어의 엔진은 레포의 파일을 찾지 못해 그 자리를 빈 문자열로 렌더합니다. 로컬 프리뷰(탐색 루트 `public/`)에서만 동작하므로, 프리뷰에서 보였다는 것이 근거가 되지 않습니다. 템플릿은 `template.liquid` **한 파일**에 담고, `bstage build`는 파셜 태그를 경고로 짚어 줍니다.
110
+ - **`{% render %}`·`{% include %}`(파셜)는 배포에서 동작하지 않습니다.** 배포 뷰어의 엔진은 레포의 파일을 찾지 못해 그 자리를 빈 문자열로 렌더합니다. 로컬 미리보기(탐색 루트 `public/`)에서만 동작하므로, 미리보기에서 보인다고 해서 배포 후에도 보인다고 판단하지 마세요. 템플릿은 `template.liquid` **한 파일**에 담고, `bstage build`는 파셜 태그가 있으면 경고를 표시합니다.
111
111
  - 전체 문법은 [liquidjs 공식 문서](https://liquidjs.com/)를 참고하세요.
112
112
 
113
- ## 8. 함정
113
+ ## 8. 주의할 점
114
114
 
115
- - **sdk와 함께 둘 수 있습니다(혼합 레포).** `bstage build`는 sdk 템플릿만 번들하고 liquid는 검증만 하며(`dist/`에 liquid를 넣지 않습니다), 포털은 sdk를 빌드한 뒤 liquid를 함께 패키징합니다. 단 `src/pages/user`·`src/pages/admin` 페이지는 산출물이 liquid 자리(`dist/{user|admin}/`)와 겹쳐 `bstage build`가 종료 코드 2로 멈춥니다 — 폴더 이름을 바꾸세요. `bstage dev`는 혼합 레포에서 기본으로 sdk 프리뷰를 띄우고, `--kind liquid`로 liquid 프리뷰를 띄웁니다.
116
- - **깊이가 규약입니다.** `public/{user|admin}/{이름}/template.liquid`, 정확히 이 자리만 인정됩니다. 어긋나면 빌드는 성공하는데 화면에 나오지 않습니다.
117
- - **페이지(PAGE) 배치만** 됩니다. 위젯 자리에 붙이면 포털이 400을 냅니다.
115
+ - **sdk와 함께 둘 수 있습니다(혼합 레포).** `bstage build`는 sdk 템플릿만 번들하고 liquid는 검증만 하며(`dist/`에 liquid를 넣지 않습니다), 포털은 sdk를 빌드한 뒤 liquid를 함께 패키징합니다. 단 `src/pages/user`·`src/pages/admin` 페이지는 산출물이 liquid 자리(`dist/{user|admin}/`)와 겹쳐 `bstage build`가 종료 코드 2로 멈춥니다. 폴더 이름을 바꾸세요. `bstage dev`는 혼합 레포에서 기본으로 sdk 미리보기를 실행하고, `--kind liquid`를 주면 liquid 미리보기를 실행합니다.
116
+ - **폴더 깊이가 정해져 있습니다.** `public/{user|admin}/{이름}/template.liquid`, 정확히 이 자리만 인정됩니다. 어긋나면 빌드는 성공하는데 화면에 나오지 않습니다.
117
+ - **페이지(PAGE) 배치만** 됩니다. 위젯 자리에 배치하면 포털이 400 오류를 반환합니다.
118
118
  - **`data.json`·`layout.json`은 로컬 전용입니다.** 포털이 패키징에서 지우므로 배포에 필요한 값을 여기에 담지 마세요.
119
119
  - **파일명은 `template.liquid` 고정입니다.** `index.html`처럼 다른 이름이면 포털이 그 폴더를 템플릿으로 보지 않습니다.
120
- - **파셜(`{% render %}`·`{% include %}`)을 쓰지 않습니다.** 배포에서 빈 문자열이 됩니다 — 7절을 참고하세요.
121
- - **에셋을 레포에 두고 상대 경로로 참조하지 않습니다.** 배포 후 깨집니다 — 6절을 참고하세요. 로컬 프리뷰에서는 보이므로 프리뷰 결과가 근거가 되지 않습니다.
122
- - **플랫폼이 넣는 값의 이름을 지어내지 않습니다.** 유저 화면 최상위는 `lounges`·`stories`·`contentSections`·`latestContents`·`shopCategories` 다섯 개뿐이고, 그 밖의 이름은 빈 문자열이 됩니다(어드민 화면이 받는 값은 확인되지 않았습니다). `data.json` 샘플도 같은 이름을 써야 프리뷰가 배포와 같은 모양이 됩니다.
120
+ - **파셜(`{% render %}`·`{% include %}`)을 쓰지 않습니다.** 배포에서 빈 문자열이 됩니다. 7절을 참고하세요.
121
+ - **에셋을 레포에 두고 상대 경로로 참조하지 않습니다.** 배포 후 깨집니다. 6절을 참고하세요. 로컬 미리보기에서는 보이므로, 미리보기 결과만으로 판단하지 마세요.
122
+ - **플랫폼이 제공하는 값의 이름을 임의로 만들지 마세요.** 유저 화면 최상위는 `lounges`·`stories`·`contentSections`·`latestContents`·`shopCategories` 다섯 개뿐이고, 그 밖의 이름은 빈 문자열이 됩니다(어드민 화면이 받는 값은 확인되지 않았습니다). `data.json` 샘플도 같은 이름을 써야 미리보기가 배포와 같은 모양이 됩니다.
@@ -35,7 +35,7 @@ Shadow DOM으로 서드파티 CSS가 플랫폼 UI에 영향을 주거나 플랫
35
35
 
36
36
  ### 토큰 비노출
37
37
 
38
- 서드파티 코드는 플랫폼 사용자의 인증 토큰에 직접 접근하지 않습니다. API 호출은 BstageClient가 `appId`/`appKey`로 처리합니다.
38
+ API 호출은 BstageClient가 `appId`/`appKey`로 처리하므로 템플릿 코드가 플랫폼 사용자의 인증 토큰을 직접 다룰 필요가 없습니다. 다만 이는 설계 원칙이지 격리 보장이 아닙니다 — 템플릿은 호스트 페이지와 같은 JS 실행 환경에서 돌아가므로, 쿠키를 읽거나 회원 토큰을 제3자에게 전달해서는 안 됩니다([7. 보안 모델](#7-보안-모델) 참조).
39
39
 
40
40
  ---
41
41
 
@@ -145,11 +145,11 @@ Custom Element 등록(`customElements.define`)은 BstageElement 자체가 하지
145
145
 
146
146
  파트너에게 공개된 API에 접근하는 HTTP 클라이언트입니다.
147
147
 
148
- **설계 의도:** 서드파티가 플랫폼 API를 호출할 때, 플랫폼 사용자의 인증 토큰 대신 `appId`/`appKey`/`tenantId`를 사용합니다. 이로써 서드파티 코드가 사용자 토큰에 접근할 수 없으며 API 접근 범위를 앱 단위로 제어할 수 있습니다.
148
+ **설계 의도:** 서드파티가 플랫폼 API를 호출할 때, 플랫폼 사용자의 인증 토큰 대신 `appId`/`appKey`/`tenantId`를 사용합니다. 이로써 서드파티 코드가 사용자 토큰을 직접 다루지 않아도 되며 API 접근 범위를 앱 단위로 제어할 수 있습니다. 다만 템플릿은 호스트와 같은 JS 실행 환경에서 돌아가므로 이것이 토큰 접근을 기술적으로 막는 것은 아닙니다([7. 보안 모델](#7-보안-모델) 참조).
149
149
 
150
150
  **base URL 결정:** `resolveBaseUrl()` 함수가 `location.origin/gw`를 반환하여 현재 호스트 기반으로 요청합니다. 로컬 개발 시에는 devVitePlugin이 이 함수를 치환해 localhost 프록시로 라우팅합니다.
151
151
 
152
- **커스텀 fetch 주입:** 플랫폼은 `globalThis.__bstage_fetch__`를 통해 인증 헤더(Authorization, CF Access)를 포함하는 fetch를 주입할 수 있습니다. 그러면 템플릿이 플랫폼과 동일한 인증 컨텍스트로 API를 호출합니다.
152
+ **커스텀 fetch 주입:** 플랫폼은 `globalThis.__bstage_fetch__`를 통해 인증 fetch를 주입할 수 있습니다. 이 fetch는 허용된 목적지(같은 origin의 `/gw`, 플랫폼 허용 목록)에만 Authorization 헤더(real 외 phase에서는 CF Access 헤더도)를 붙이고, 그 밖의 요청은 해당 헤더 없이 그대로 통과시킵니다. 그러면 템플릿이 플랫폼과 동일한 인증 컨텍스트로 게이트웨이 API를 호출합니다.
153
153
 
154
154
  > API 시그니처와 사용 예시는 [API_REFERENCE.md](./API_REFERENCE.md#14-bstageclient)를 참고하세요.
155
155
 
@@ -233,16 +233,25 @@ flowchart TD
233
233
 
234
234
  ## 7. 보안 모델
235
235
 
236
- Shadow DOM은 **스타일 격리**이지 **보안 격리**가 아닙니다. JavaScript 실행 환경은 플랫폼과 공유됩니다.
236
+ Shadow DOM은 **스타일 격리**이지 **보안 격리**가 아닙니다. 템플릿은 호스트 페이지와 같은 JavaScript 실행 환경(Web Components, open Shadow DOM)에서 돌아갑니다.
237
237
 
238
- | 위협 | 대응 |
239
- | --------------- | ------------------------------------------- |
240
- | 악성 코드 포함 | 번들 제출 시 정적 분석 (금지 API 패턴 탐지) |
241
- | 플랫폼 DOM 조작 | CSP 헤더로 제한 |
242
- | 토큰/쿠키 탈취 | 민감 정보는 HttpOnly 쿠키로만 관리 |
243
- | API 남용 | API Gateway에서 rate limiting |
238
+ | 위협 | 대응 |
239
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
240
+ | 악성 코드 포함 | 번들 제출 시 정적 분석 (금지 API 패턴 탐지) — **계획**, 아직 미구현 |
241
+ | 플랫폼 DOM 조작 | CSP 헤더로 제한 |
242
+ | 토큰/쿠키 탈취 | 리프레시 토큰은 HttpOnly 쿠키. 액세스 토큰 쿠키는 현재 HttpOnly가 아님 — HttpOnly 전환 **계획** |
243
+ | 인증 헤더 유출 | `__bstage_fetch__`는 허용된 목적지(같은 origin의 `/gw`, 플랫폼 허용 목록)에만 Authorization(real 외 phase는 CF Access 포함) 헤더를 붙이고, 그 밖의 요청은 헤더 없이 통과 |
244
+ | API 남용 | API Gateway에서 rate limiting |
244
245
 
245
- 서드파티는 BstageClient를 통해 `appId`/`appKey`로 API에 접근하고 플랫폼 사용자의 인증 토큰에는 직접 접근하지 않습니다.
246
+ 서드파티는 BstageClient를 통해 `appId`/`appKey`로 API에 접근합니다. 같은 실행 환경을 공유하므로 토큰 접근이 기술적으로 차단되지는 않습니다 — 템플릿은 쿠키를 읽거나 회원 토큰을 제3자에게 전달해서는 안 됩니다.
247
+
248
+ ### iframe 격리 (placement 단위 선택)
249
+
250
+ 위 한계를 닫기 위해 템플릿을 **sandbox iframe**(`sandbox="allow-scripts"`, opaque origin)에서 돌리는 경로가 있습니다(`@bstage-sdk/bridge`). **기본은 inline**이고, 격리는 placement마다 켜는 선택 기능입니다. 켜지 않은 placement는 지금과 같은 inline 경로로 동작합니다.
251
+
252
+ - 프레임 문서는 CSP로 직접 네트워크를 막습니다(`connect-src 'none'`). API 요청은 브리지 메시지로 호스트에 가고, 호스트가 목적지(같은 origin `/gw`·승인된 파트너 origin)·메서드·헤더·본문 크기·분당 요청 수를 검사한 뒤 플랫폼 fetch로 대신 보냅니다.
253
+ - 토큰(쿠키·Authorization)은 프레임에 들어가지 않습니다. 프레임은 응답의 상태·content-type·본문만 받습니다. 쿠키·`localStorage`·호스트 DOM에도 접근할 수 없습니다.
254
+ - 템플릿 코드는 바꿀 필요 없이 브리지를 지원하는 SDK로 다시 빌드하면 됩니다(옛 번들은 inline으로만 돕니다). core·react가 프레임 안인지 스스로 판별해 `BstageClient`·로케일·`useNavigation`을 브리지로 보냅니다. 단, 쿠키·`localStorage`·`location`을 직접 쓰는 템플릿은 격리 환경에서 동작이 달라집니다.
246
255
 
247
256
  ---
248
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bstage-sdk/core",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "bstage SDK core — HTTP client for b.stage API",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -26,6 +26,9 @@
26
26
  "main": "./dist/index.cjs",
27
27
  "module": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
29
+ "bstage": {
30
+ "bridgeProtocol": 1
31
+ },
29
32
  "files": [
30
33
  "dist",
31
34
  "docs"