@zalkera/client 0.17.1 → 0.19.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.
@@ -6,11 +6,11 @@
6
6
  "canonicalHere": "이 파일이 정본이다. zalkera-client/llms.txt §5.1 은 같은 계약의 **사람/AI용 운반체**이고, 대조는 zalkera-client/scripts/sync-aeo-guarantees.mjs 가 기계로 센다. 채널이 늘어도 사본을 늘리지 않는다 — AGENTS.md·콘솔 문서·MCP 는 참조 링크만 든다(memo119 §5-13).",
7
7
  "floorNotCeiling": "보장은 바닥이지 천장이 아니다. 이 표를 못 채우는 템플릿도 만들 수 있고 적재될 수 있다 — 못 거는 것은 **그 카테고리 간판**뿐이고, 그것도 결함 판정이 아니라 진열 축의 사실 판정이다(§4.1·오너 정정 A).",
8
8
  "whereTheWeightIs": "보장의 무게는 **라우트**에 있다. 현행 보장(Product·Organization·BlogPosting·BreadcrumbList)은 라우트·렌더러의 속성이고, 섹션이 직접 산출하는 것은 FAQPage 하나다. **섹션 어휘를 0개 쓰는 사이트도 라우트 보장은 똑같이 받는다**(§1.0-a).",
9
- "verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 storefront-template/scripts/check-aeo-surfaces.mjs 이고, 실행 자리는 promote 전 스모크 개시다.",
9
+ "verdictFromArtifact": "판정 지점은 소스가 아니라 **산출물**이다(오너 정정 C). '이 섹션을 써라'는 자유를 깎지만 '개시된 페이지에서 이 그래프가 나오는가'는 아무것도 깎지 않는다. 검사기는 storefront-template/scripts/check-aeo-surfaces.mjs 이고, 실행 자리는 promote 전 스모크 개시다. **측정 재료의 출처**(memo142): 목록형 표면의 원소는 업무 데이터에서 온다 — 팩 시드는 상품·갈래를 만들지 않으므로, 시험 테넌트에 파트너 API 로 적재한 뒤 크롤한다. 빈 테넌트에서 나온 원소 0개짜리 통과는 형상만 본 가짜 PASS 다(COMMERCE unblockedNote 의 실측이 그 사례다).",
10
10
  "requiredVsPlanned": "`required` 는 **이 rev 가 지금 강제하는 것**이고, `planned` 는 같은 카테고리의 목표 표면 중 **아직 우리 본보기도 못 내는 것**이다. planned 는 게이트가 아니라 T6 작업 지시서이고, 검사기는 이를 실패가 아니라 PLANNED_MISSING 으로 보고한다. `realized` 는 **그 지시가 실제로 이행됐는가**를 적는 별개 축이다 — 이행됐다고 자동으로 required 가 되지는 않는다. required 로 올리는 것은 이 표의 rev 상향이고, 그 시점은 **이행분이 개시된 산출물에 실제로 나타나는 발행 단위**다 — 코드 병합이 아니다(판정 지점이 산출물이므로 잣대의 상향 시점도 산출물이어야 한다. 병합만으로 표를 올리면 아직 재빌드 안 된 팩·구 SDK 로 빌드된 팩이 통과 못 할 요구를 받는다). rev 2 가 그 실례다: `openingHours` 는 저장 자리(백엔드 컬럼)만 섰고 `@zalkera/client` 0.9.0 미발행·템플릿 소비 0 이라, rev 2 는 T6 병합이 아니라 **client 발행 + 본보기 팩 재빌드**가 끝난 단위에서만 설 수 있다. 이 분리가 없으면 둘 중 하나를 해야 한다 — 규범을 낮추거나(보장이 거짓말이 된다), 오늘 아무도 못 거는 표를 강제하거나(카탈로그가 빈 선반이 된다).",
11
11
  "growth": "표는 자란다. rev 상향 시 **기존 등재물의 소급 탈락은 금지**다 — 구 rev 판정은 판정 당시 rev 를 병기한 배지로 유효하게 지속되고, 신 rev 요구는 신규 등재·재promote 부터 건다. 순수 라벨 카테고리가 나중에 이 표에 편입되면 기존 등재물은 유예한다(§1.0-c). 조용한 소급은 이 설계가 죽이려던 조용한 실패의 재생산이다.",
12
12
  "publication": "`published` 는 그 표면의 규범이 **공개 문서(llms.txt §5.1)에 이미 나가 있는가**다. false = 내부 정본에만 있다 = 아직 공표 전. 내부 정본이 앞서 있는 것은 무해하다(작업 지시서다). 그러나 **공표가 본보기를 앞지르면 안 된다** — '레시피가 본보기를 앞지르지 않는다'(§1.5-a). false→true 뒤집기는 T6 그린과 같은 발행 단위 이후이고(T4b), 대조 스크립트가 양방향으로 센다: published 인데 §5.1 에 없으면 실패, published 가 아닌데 §5.1 에 이미 있으면 **역방향 실패**(공표가 표를 앞질렀다).",
13
- "categoryStorage": "카테고리는 `theme.category` 자유 문자열이다 — enum 으로 못박지 않는다(memo119 §5-6). **여기 항목이 있는 이름만** 게이트가 걸리고, 없는 이름은 순수 라벨이다(예: '미니멀'). 값 이름을 업종어가 아니라 유형어로 두는 이유는 형제 계약의 명명 규약과 같다: 업종어를 박으면 재사용이 죽는다(DOCTOR_INTRO 교훈). 그래서 예약 칸은 `BEAUTY` 가 아니라 `BOOKING` 이다 — 예약을 파는 것은 뷰티만이 아니다."
13
+ "categoryStorage": "카테고리는 `theme.categories` 자유 문자열 **배열**이다 — enum 으로 못박지 않는다(memo119 §5-6 · 배열화는 memo141 §2-1). **여기 항목이 있는 이름만** 게이트가 걸리고, 없는 이름은 순수 라벨이다(예: '미니멀'). 한 팩이 보장 이름을 여럿 걸 수 있고, 그때는 **주장한 이름 전부**가 스냅샷에서 개별 PASS 여야 공개된다(AND·memo141 §2-2) — 절반만 지킨 약속은 거짓이라, 부분 통과를 표기로 흡수하지 않고 promote 를 막는다. 부분만 걸고 싶으면 주장을 줄이면 된다. 값 이름을 업종어가 아니라 유형어로 두는 이유는 형제 계약의 명명 규약과 같다: 업종어를 박으면 재사용이 죽는다(DOCTOR_INTRO 교훈). 그래서 예약 칸은 `BEAUTY` 가 아니라 `BOOKING` 이다 — 예약을 파는 것은 뷰티만이 아니다."
14
14
  },
15
15
 
16
16
  "routes": {
@@ -131,10 +131,10 @@
131
131
  "shipNote": "**축소 보장표로 등재된 상태다**(§4.2). ItemList·openingHours 를 지금 required 로 올리면 오늘 아무 팩도 이 칸을 못 걸고, 낮춰 적으면 보장이 거짓이 된다. 그래서 지금 참인 것만 강제하고 나머지는 planned 로 rev 관리한다 — rev 2 에서 required 로 올라간다. **그 rev 2 는 T6 병합 시점이 아니다**: openingHours 는 저장 자리만 서 있고 `@zalkera/client` 0.9.0(타입 추가)이 미발행이라 템플릿이 그 값을 소비하지 못해 그래프에 아무것도 안 나간다. rev 2 가 서는 단위는 **client 발행 + 본보기 팩 재빌드**다(conventions.requiredVsPlanned).",
132
132
  "required": [
133
133
  {"id": "home-localbusiness", "route": "home", "jsonLd": ["LocalBusiness", "BeautySalon", "HealthAndBeautyBusiness"], "ssr": true, "published": false, "publishTranche": "T4b", "why": "예약을 파는 곳은 **물리 점포**다. Organization 으로 두면 '어디로 가면 되는가'가 기계에 안 읽힌다. organizationJsonLd 는 config.businessType==BEAUTY 일 때 BeautySalon 으로 좁힌다 — 즉 이 요구는 렌더러가 아니라 **테넌트 설정**이 만족시킨다."},
134
- {"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면은 시드가 상품을 만들 수 있어야 약속이 아니다**(T1 products 시드가 전제)."}
134
+ {"id": "service-detail-product", "route": "productDetail", "jsonLd": ["Product"], "requiredChildren": ["Offer"], "ssr": true, "published": true, "llmsMarker": "상품 상세 = `Product` + variant 마다 `Offer`", "why": "시술 한 건이 상품 한 행이다. 가격이 Offer 로 나가야 '얼마인가'가 답변 엔진에 읽힌다. **이 표면을 재려면 카탈로그에 상품이 있어야 한다** — 종전 문면은 '시드가 상품을 만들 수 있어야'였으나 memo142 시드가 상품을 만들지 않는다. 전제는 사라진 것이 아니라 **거처가 바뀌었다**: 측정은 시험 테넌트에 파트너 API(콘솔과 같은 엔드포인트)로 상품을 적재한 뒤 크롤한다. 빈 테넌트에서 잰 값은 재료가 없는 것이지 통과가 아니다."}
135
135
  ],
136
136
  "planned": [
137
- {"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "T6 SERVICE_MENU ItemList 산출을 구현했고 정본 section-vocabulary.json jsonLd 열이 null→ItemList(contractRev 2) 함께 올라갔다. 여기서 required 로 올리는 것은 이 표의 **rev 2** 이고, 그 단위는 T6 병합이 아니라 **본보기 팩을 다시 빌드해 개시한 뒤**다 — 같은 rev 의 openingHours 는 거기에 더해 client 발행까지 필요하다.", "why": "시술 목록의 정위치는 **별도 라우트가 아니라 홈의 SERVICE_MENU 섹션**이다(FAQ_LIST↔FAQPage 선례). 뷰티 사이트에 /products 목록 라우트를 강제하는 것은 과잉이라 비대칭을 이렇게 해소했다(§4.2 W-F5). 구현 section-vocabulary.json SERVICE_MENU jsonLd 열이 null→\"ItemList\" 올라가고 rev동반된다."},
137
+ {"id": "service-menu-itemlist", "route": "any", "jsonLd": ["ItemList"], "tranche": "T6-ⓒ", "realized": "DONE", "realizedNote": "**운반체가 바뀌었다**(memo142 · contractRev 6). 종전 운반체이던 `SERVICE_MENU` 섹션은 어휘에서 삭제됐다 진열은 선언이 아니라 소스가 `@zalkera/client` 를 직접 호출해 그리는 일이기 때문이다. 그래서 이 ItemList 를 내는 자리는 **`/products` 목록 라우트**( 팩 공유·직접 호출) 소스가 홈에 조합하는 진열 레일이다. 산출 자체는 이미 돈다(라우트가 낸다). 여기서 required 로 올리는 것은 이 표의 **rev 2** 이고, 그 단위는 **본보기 팩을 다시 빌드해 개시하고 시험 테넌트에 상품을 적재해 잰 뒤**다 — 같은 rev 의 openingHours 는 거기에 더해 client 발행까지 필요하다. id 를 그대로 두는 것은 append-only 로그의 추적성 때문이다(운반체가 바뀌었지 약속이 바뀐 것이 아니다).", "why": "시술 목록의 정위치를 홈의 섹션 타입으로 못박았던 것이 rev 1 의 판단이었다(§4.2 W-F5). memo142 판단을 뒤집는다 목록을 **어디에 어떻게** 그릴지는 어휘가 아니라 소스의 몫이고, 어휘에 고정하면 그 진열의 디자인이 얼어붙어 해자(자연어로 다양한 디자인) 반대로 간다. 그래서 `route` 여전히 `any` 다: 홈이든 `/products` **개시된 페이지 어딘가에서 ItemList 나오는가**만 잰다(판정 지점은 소스가 아니라 산출물 — `conventions.verdictFromArtifact`)."},
138
138
  {"id": "opening-hours", "route": "home", "jsonLdProperty": "openingHours", "tranche": "T6-ⓓ", "realized": "PARTIAL", "realizedNote": "**절반이다.** 저장 자리는 섰다(백엔드 site_config 확장 + 전체교체 경로 4곳). 템플릿 렌더는 @zalkera/client 0.9.0 발행 뒤라 아직 못 나간다(발행은 2FA 라 오너 몫). 저장 자리만으로는 그래프가 안 나가므로 이 항목이 여전히 red 로 잡히는 것이 맞다.", "why": "**전 레포에 저장할 필드 자체가 없다** — 렌더러 문제가 아니라 스키마 문제다. 거처는 예약 척추 위저드의 영업시간 입력(§2.4)이고, site_config 확장 마이그레이션이 선행한다."}
139
139
  ],
140
140
  "why": "§4.2 예약 칸. 상세는 지금 나가고, 목록(ItemList)·영업시간은 T6 이후다."
package/dist/index.cjs CHANGED
@@ -102,7 +102,10 @@ function createZalkeraClient(options) {
102
102
  };
103
103
  if (options.secretKey) headers["X-Storefront-Key"] = options.secretKey;
104
104
  if (init?.body != null) headers["Content-Type"] = "application/json";
105
- if (init?.context?.clientIp) headers["X-Forwarded-For"] = init.context.clientIp;
105
+ if (init?.context?.clientIp) {
106
+ headers["X-Zalkera-Client-Ip"] = init.context.clientIp;
107
+ headers["X-Forwarded-For"] = init.context.clientIp;
108
+ }
106
109
  if (init?.bearer) headers["Authorization"] = `Bearer ${init.bearer}`;
107
110
  if (init?.cartSession) headers["X-Cart-Session"] = init.cartSession;
108
111
  if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
@@ -186,12 +189,6 @@ function createZalkeraClient(options) {
186
189
  method: "POST",
187
190
  context
188
191
  }),
189
- getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
190
- listPages: (params, options2) => request("/public/pages", {
191
- query: { page: params?.page, size: params?.size },
192
- ...nextInit(options2)
193
- }),
194
- listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
195
192
  getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
196
193
  submitInquiry: (input, context) => request("/public/inquiries", {
197
194
  method: "POST",
@@ -306,17 +303,12 @@ function safeJsonParse(text) {
306
303
  }
307
304
 
308
305
  // src/sections.ts
309
- var SECTION_CONTRACT_REV = 5;
306
+ var SECTION_CONTRACT_REV = 7;
310
307
  var SECTION_CONTRACT = [
311
- // 뷰티(memo47) — append-only 계약상 불변
312
- // SERVICE_MENU ItemList 는 rev 2 에서 올라왔다(memo119 T6-ⓒ): 쇼핑몰 유형엔 상품 목록 표면을
313
- // 요구하면서 예약 유형엔 시술 목록 표면이 없던 비대칭의 해소다. 뷰티 사이트에서 시술 목록의
314
- // 정위치는 별도 라우트가 아니라 홈의 이 섹션이라, FAQ_LIST→FAQPage 와 같은 형태로 섹션이 직접 낸다.
315
- // rev 3 에서 productIds 의 `?` 가 떨어졌다 — 목록을 내겠다고 선언한 섹션이 목록을 안 가리키는 상태가
316
- // 계약상 성립하지 않게 됐다(BOOKING_CTA.productId 는 rev 1 부터 필수였다·비대칭 해소).
317
- { type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: "ItemList", requiredRefs: [], requiredRefsAnyOf: [["productIds", "categorySlug"]] },
308
+ // 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
309
+ // 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
310
+ // 진열이 낸다(보장표 `service-menu-itemlist` route 원래 `any` 판정 지점은 산출물이다).
318
311
  { type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
319
- { type: "BOOKING_CTA", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [["productId", "categorySlug"]] },
320
312
  { type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
321
313
  // 기업 마케팅(memo102 §2)
322
314
  { type: "HERO", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
@@ -346,6 +338,32 @@ function safeLinkUrl(raw) {
346
338
  }
347
339
  }
348
340
 
341
+ // src/visitorIp.ts
342
+ function visitorIp(headers, options) {
343
+ const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;
344
+ if (hops <= 0) return void 0;
345
+ const raw = headers?.get?.("x-forwarded-for");
346
+ if (raw == null || raw.trim() === "") return void 0;
347
+ const parts = raw.split(",").map((part) => part.trim()).filter((part) => part !== "");
348
+ if (parts.length === 0) return void 0;
349
+ const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);
350
+ return parts[index] || void 0;
351
+ }
352
+ var DEFAULT_TRUSTED_HOPS = 1;
353
+ var HOPS_ENV = "ZALKERA_TRUSTED_PROXY_HOPS";
354
+ var envWarned = false;
355
+ function hopsFromEnv() {
356
+ const raw = typeof process !== "undefined" ? process.env?.[HOPS_ENV] : void 0;
357
+ if (raw == null || raw.trim() === "") return void 0;
358
+ const parsed = Number(raw);
359
+ if (Number.isInteger(parsed)) return parsed;
360
+ if (!envWarned) {
361
+ envWarned = true;
362
+ console.warn(`@zalkera/client: ${HOPS_ENV}="${raw}" \uB294 \uC815\uC218\uAC00 \uC544\uB2D9\uB2C8\uB2E4 \u2014 \uAE30\uBCF8\uAC12 ${DEFAULT_TRUSTED_HOPS} \uB85C \uC9C4\uD589\uD569\uB2C8\uB2E4.`);
363
+ }
364
+ return void 0;
365
+ }
366
+
349
367
  // src/sectionConfig.ts
350
368
  function parseConfig(config) {
351
369
  if (!config) return null;
@@ -485,5 +503,6 @@ exports.parseThemeColors = parseThemeColors;
485
503
  exports.readConfig = readConfig;
486
504
  exports.safeLinkUrl = safeLinkUrl;
487
505
  exports.sectionsOfVertical = sectionsOfVertical;
506
+ exports.visitorIp = visitorIp;
488
507
  //# sourceMappingURL=index.cjs.map
489
508
  //# sourceMappingURL=index.cjs.map