@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.
- package/README.md +12 -7
- package/bin/validate-storefront.mjs +540 -10
- package/contracts/aeo-surface-guarantees.json +4 -4
- package/dist/index.cjs +35 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -130
- package/dist/index.d.ts +142 -130
- package/dist/index.js +35 -17
- package/dist/index.js.map +1 -1
- package/llms.txt +163 -78
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -127,76 +127,7 @@ interface ListPostsParams {
|
|
|
127
127
|
/** Spring 정렬 표현. 예: `"publishedAt,desc"`. */
|
|
128
128
|
sort?: string;
|
|
129
129
|
}
|
|
130
|
-
/**
|
|
131
|
-
* `PublicPageSummaryResponse` — 페이지 **목록 카드**. 열거 전용이라 본문·섹션·seo 가 없다.
|
|
132
|
-
*
|
|
133
|
-
* 이 표면의 존재 이유는 sitemap 이다: 상세([PageContent])로 100개를 열거하면 섹션·본문까지 딸려와
|
|
134
|
-
* 크롤러 한 번에 카탈로그 전체를 붓게 된다. 상세가 필요하면 slug 로 [ZalkeraClient.getPage] 를 부른다.
|
|
135
|
-
*/
|
|
136
|
-
interface PageSummary {
|
|
137
|
-
slug: string;
|
|
138
|
-
title: string;
|
|
139
|
-
publishedAt: string | null;
|
|
140
|
-
/** 마지막 수정 시각 — sitemap `lastModified` 의 입력. 없으면 소비자가 필드를 생략한다. */
|
|
141
|
-
modified: string | null;
|
|
142
|
-
}
|
|
143
|
-
/** 페이지 목록 질의. page 는 0-based. 정렬은 서버 고정(제목 오름차순)이라 `sort` 가 없다. */
|
|
144
|
-
interface ListPagesParams {
|
|
145
|
-
page?: number;
|
|
146
|
-
/** 서버 상한 100 — 초과 요청은 100 으로 잘린다. */
|
|
147
|
-
size?: number;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* 페이지 섹션 — 구조화된 페이지 구성 요소.
|
|
151
|
-
*
|
|
152
|
-
* `type` 은 **문자열 그대로 둔다**(union 아님). 백엔드 `SectionType` 이 append-only 계약이라
|
|
153
|
-
* union 으로 못박으면 새 타입이 추가되는 순간 기존 스토어프론트가 타입 에러로 깨진다.
|
|
154
|
-
* 아는 값만 [KnownSectionType] 로 따로 제공한다 — **모르는 타입은 조용히 건너뛰는 게 계약이다.**
|
|
155
|
-
*
|
|
156
|
-
* `config` 는 **파싱하지 않은 raw JSON 문자열**이다(`seo`·`themeColors` 와 같은 사상). 백엔드도
|
|
157
|
-
* 검증하지 않는다 — 사이트 하나 고칠 때마다 백엔드를 배포하지 않으려는 의도다. 파싱은 소비자
|
|
158
|
-
* 몫이고, **깨진 config 는 그 섹션만 건너뛰어야지 페이지를 죽이면 안 된다.**
|
|
159
|
-
*
|
|
160
|
-
* 타입별 config 형상의 **정본은 백엔드 레포 `doc/contracts/section-vocabulary.json`** 이다.
|
|
161
|
-
* 여기에 형상을 다시 적지 않는다 — 두 벌이면 갈라진다(사본 넷이 주석 규약으로 갈라진 사고가 실재).
|
|
162
|
-
* 아는 타입 목록은 [SECTION_CONTRACT] 로 제공한다.
|
|
163
|
-
*
|
|
164
|
-
* `SERVICE_MENU` 의 `{ categorySlug }` 변형만 예외적으로 여기 적어 둔다 — 공개 상품 API 에 카테고리
|
|
165
|
-
* 필터가 없어 **스펙에는 있으나 소비자가 아직 지원하지 않는** 상태라, 스펙만 봐서는 알 수 없다.
|
|
166
|
-
*/
|
|
167
|
-
interface PageSection {
|
|
168
|
-
type: string;
|
|
169
|
-
sortOrder: number;
|
|
170
|
-
config: string | null;
|
|
171
|
-
}
|
|
172
|
-
/** `PublicPageResponse` — 회사 소개 같은 고정 페이지. */
|
|
173
|
-
interface PageContent {
|
|
174
|
-
id: number;
|
|
175
|
-
slug: string;
|
|
176
|
-
title: string;
|
|
177
|
-
/** 레거시 마크다운 본문. [sections] 가 있으면 무시된다. */
|
|
178
|
-
content: string | null;
|
|
179
|
-
publishedAt: string | null;
|
|
180
|
-
/**
|
|
181
|
-
* SEO 오버라이드 JSON 문자열(스키마리스 — 백엔드가 검증하지 않는다). 미설정이면 null.
|
|
182
|
-
* 권장 구조: `{"title": "…", "description": "…"}` — 사이트 기본값 [SiteConfig.seoDefaults] 와
|
|
183
|
-
* 같은 모양이다. `title` 없으면 소비자가 자연 제목(페이지 제목·상품명)으로 강하한다.
|
|
184
|
-
* **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
|
|
185
|
-
*/
|
|
186
|
-
seo: string | null;
|
|
187
|
-
/** 있으면 이걸 렌더한다(content 는 무시). 비었으면 [content] 폴백. 순서는 서버가 정렬해 준다. */
|
|
188
|
-
sections: PageSection[];
|
|
189
|
-
}
|
|
190
130
|
type MenuPosition = "HEADER" | "FOOTER";
|
|
191
|
-
/** `PublicMenuResponse` — 재귀 트리(children). */
|
|
192
|
-
interface Menu {
|
|
193
|
-
id: number;
|
|
194
|
-
position: MenuPosition;
|
|
195
|
-
label: string;
|
|
196
|
-
url: string;
|
|
197
|
-
sortOrder: number;
|
|
198
|
-
children: Menu[];
|
|
199
|
-
}
|
|
200
131
|
/** `PublicMediaUrlResponse` — presigned 다운로드 URL. `expiresAt` 전까지만 유효하다. */
|
|
201
132
|
interface MediaUrl {
|
|
202
133
|
url: string;
|
|
@@ -690,39 +621,28 @@ interface ZalkeraClient {
|
|
|
690
621
|
*
|
|
691
622
|
* 조회 dedup 은 뷰어 IP·UA 를 쓴다. 서버 사이드에서 부를 때는 [RequestContext.clientIp] 로
|
|
692
623
|
* 원 방문자 IP 를 넘겨야 방문자별로 집계된다 — 안 넘기면 전부 서버 IP 하나로 뭉친다.
|
|
624
|
+
* 값은 [visitorIp] 로 뽑아라(첫 홉 직접 추출 금지 — 방문자가 위조할 수 있다).
|
|
693
625
|
*/
|
|
694
626
|
recordPostView(slug: string, context?: RequestContext): Promise<boolean>;
|
|
695
|
-
/** slug 로 고정 페이지. */
|
|
696
|
-
/**
|
|
697
|
-
* slug 로 고정 페이지(섹션 포함). 없으면 404 → [ZalkeraError].
|
|
698
|
-
* ISR 페이지는 [ReadOptions.tags] 로 캐시 태그를 실을 수 있다 — 백엔드가 발행 시 그 태그만
|
|
699
|
-
* 콕 집어 revalidate 한다.
|
|
700
|
-
*/
|
|
701
|
-
getPage(slug: string, options?: ReadOptions): Promise<PageContent>;
|
|
702
|
-
/**
|
|
703
|
-
* 발행된 고정 페이지 **목록**(열거 전용·본문 없음).
|
|
704
|
-
*
|
|
705
|
-
* sitemap 을 위한 API 다 — 이게 없으면 콘솔·AI 로 만든 페이지가 검색엔진에 열거되지 않는다.
|
|
706
|
-
* 목록에 실린 slug 는 [getPage] 가 반드시 200 을 준다(가시성 술어가 서버에서 하나로 공유된다).
|
|
707
|
-
*/
|
|
708
|
-
listPages(params?: ListPagesParams, options?: ReadOptions): Promise<Paginated<PageSummary>>;
|
|
709
|
-
/** 메뉴 트리(HEADER·FOOTER). */
|
|
710
|
-
listMenus(options?: ReadOptions): Promise<Menu[]>;
|
|
711
627
|
/** 미디어 presigned 다운로드 URL(만료 있음). */
|
|
712
628
|
getMediaUrl(id: number): Promise<MediaUrl>;
|
|
713
629
|
/**
|
|
714
630
|
* 문의 접수. 성공 시 생성된 문의 id. 레이트리밋이면 429 → `error.isRateLimited`.
|
|
715
631
|
*
|
|
716
632
|
* ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자
|
|
717
|
-
* IP 를 반드시 넘겨야 한다.**
|
|
718
|
-
*
|
|
633
|
+
* IP 를 반드시 넘겨야 한다.** 안 넘기면 백엔드가 테넌트 서버 IP 하나만 보고 몇 건 뒤 **모든 방문자**
|
|
634
|
+
* 를 429 로 막는다.
|
|
635
|
+
*
|
|
636
|
+
* ⚠️ 그 값은 **[visitorIp] 로 뽑아라.** `x-forwarded-for` 의 **첫 홉을 직접 쓰지 마라** — 첫 엔트리는
|
|
637
|
+
* 방문자가 요청에 손으로 실은 값이라 레이트리밋이 한 줄로 우회된다(백엔드는 첫 홉을 쓰지 않는다.
|
|
638
|
+
* 신뢰 프록시 홉 기반으로 채택한다). 보장 경계·홉 수 선언은 [visitorIp] 문서 참조.
|
|
719
639
|
*/
|
|
720
640
|
submitInquiry(input: InquiryInput, context?: RequestContext): Promise<InquiryCreated>;
|
|
721
641
|
/**
|
|
722
642
|
* 광고 리드 접수. 문의와 달리 이메일이 선택이고 UTM 추적을 함께 보낸다.
|
|
723
643
|
*
|
|
724
644
|
* ⚠️ [submitInquiry] 와 같은 이유로 서버 사이드에서는 [RequestContext.clientIp] 로 원 방문자
|
|
725
|
-
* IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다.
|
|
645
|
+
* IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다. 값은 [visitorIp] 로 뽑는다.
|
|
726
646
|
*/
|
|
727
647
|
submitLead(input: LeadInput, context?: RequestContext): Promise<LeadCreated>;
|
|
728
648
|
/** slug 로 공개 상품(ACTIVE) 상세 — variant·재고 가용여부 포함. 없으면 404. ISR 태그는 [ReadOptions]. */
|
|
@@ -866,13 +786,19 @@ interface OrderAccess {
|
|
|
866
786
|
phone?: string;
|
|
867
787
|
}
|
|
868
788
|
/**
|
|
869
|
-
* IP 민감 엔드포인트(
|
|
789
|
+
* IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.
|
|
870
790
|
*
|
|
871
791
|
* 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가
|
|
872
|
-
* 아니라 테넌트 서버다. 방문자 IP 를
|
|
792
|
+
* 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 — 전송은 전용 헤더
|
|
793
|
+
* `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된
|
|
794
|
+
* 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로
|
|
795
|
+
* 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).
|
|
873
796
|
*/
|
|
874
797
|
interface RequestContext {
|
|
875
|
-
/**
|
|
798
|
+
/**
|
|
799
|
+
* 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고
|
|
800
|
+
* (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).
|
|
801
|
+
*/
|
|
876
802
|
clientIp?: string;
|
|
877
803
|
}
|
|
878
804
|
declare function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient;
|
|
@@ -933,9 +859,10 @@ declare class ZalkeraError extends Error {
|
|
|
933
859
|
* 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).
|
|
934
860
|
*
|
|
935
861
|
* 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로
|
|
936
|
-
* 실어 나르는 **운반체**다.
|
|
937
|
-
*
|
|
938
|
-
*
|
|
862
|
+
* 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —
|
|
863
|
+
* 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래
|
|
864
|
+
* 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —
|
|
865
|
+
* 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).
|
|
939
866
|
*
|
|
940
867
|
* 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —
|
|
941
868
|
* 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,
|
|
@@ -950,15 +877,34 @@ declare class ZalkeraError extends Error {
|
|
|
950
877
|
* 그래서 아래 `SECTION_CONTRACT` 리터럴은 rev 3 과 **바이트 동일**하다(동기 스크립트가 확인한다).
|
|
951
878
|
*
|
|
952
879
|
* rev 5 = **`categorySlug` 를 대등 참조로**(memo139). `SERVICE_MENU`·`BOOKING_CTA` 의 필수성 단위가
|
|
953
|
-
* "productIds 가 있는가"에서 **"참조가 하나라도 있는가"**(`requiredRefsAnyOf`)로 옮겨갔다.
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
880
|
+
* "productIds 가 있는가"에서 **"참조가 하나라도 있는가"**(`requiredRefsAnyOf`)로 옮겨갔다. 그 시대의
|
|
881
|
+
* 판단이었고 rev 6 이 뒤집었다(아래).
|
|
882
|
+
*
|
|
883
|
+
* rev 6 = **`SERVICE_MENU`·`BOOKING_CTA` 완전 삭제 — 12종 → 10종**(memo142 §오너확정2-1). rev 3·5 가
|
|
884
|
+
* "조회형 섹션은 참조를 반드시 실어라"로 조이던 잣대가 **"조회형 섹션을 싣지 마라"로 반전**됐다.
|
|
885
|
+
* 경계 규칙(memo142 §1): 값이 콘텐츠 파일에 사는 저작물은 **선언 섹션**의 소관이고, 값이 업무 DB 에
|
|
886
|
+
* 살고 화면이 비추기만 하는 조회는 **소스가 이 패키지를 직접 호출**해 그린다(`listProducts()`·
|
|
887
|
+
* `listProductCategories()`). 절반 선언(`SERVICE_MENU`)은 "어디에"만 선언에 두고 "어떻게"(카드 그리드·
|
|
888
|
+
* 필드·개수)를 공유 렌더러에 얼려 두는 형태였고, 그것이 자연어로 다양한 디자인을 만든다는 방향과 반대다.
|
|
889
|
+
*
|
|
890
|
+
* **`retired` 표기가 아니라 삭제**인 이유: 실측상 정당한 잔존 소비자가 0이었고(상용 `page_section`·
|
|
891
|
+
* `product`·`product_category` 전부 0행), memo128 이 이미 `page_section` 계열을 퇴역 방향으로 잡아 뒀다.
|
|
892
|
+
* 제3 상태는 계약·검사기·팩 게이트·콘솔이 각자 해석해야 하는 축을 새로 만든다.
|
|
893
|
+
*
|
|
894
|
+
* ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.
|
|
895
|
+
* 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인
|
|
896
|
+
* 팩**에만 선다.
|
|
897
|
+
*
|
|
898
|
+
* rev 7 = **DB 방언 소거 — 거처가 하나 남았다**(memo144). `page`·`page_section`·`menu` 계열이 퇴역하면서
|
|
899
|
+
* 정본의 `dialects.id`(숫자 id 표기)가 가리킬 자리가 없어졌다. rev 4 가 방언을 1급으로 승격하며 rev 를
|
|
900
|
+
* 올렸던 것의 **역연산**이라 서술 정리가 아니라 잣대 변경이다. **아래 리터럴은 rev 6 과 바이트 동일**이다
|
|
901
|
+
* — 섹션 10종·`requiredRefs(AnyOf)` 는 한 글자도 안 바뀐다(동기 스크립트가 확인한다). 이 패키지에서
|
|
902
|
+
* 함께 내려간 것은 그 거처를 읽던 표면이다: `getPage`·`listPages`·`listMenus` 와 그 타입들.
|
|
903
|
+
* 남은 표기는 소스 하나 — `content/pages/*.json` 이 쓰는 참조 표기(`asset` 계열 문자열)이고,
|
|
904
|
+
* 어휘 표의 `assetId` 계열 키 이름은 그 시절 표기가 굳은 것이다(대응은 정본 `dialects.reference`).
|
|
959
905
|
*/
|
|
960
|
-
declare const SECTION_CONTRACT_REV =
|
|
961
|
-
/** 업종 분류 —
|
|
906
|
+
declare const SECTION_CONTRACT_REV = 7;
|
|
907
|
+
/** 업종 분류 — 어휘를 묶어 보여 줄 때의 그룹핑이지 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 쓴다). */
|
|
962
908
|
type SectionVertical = "BEAUTY" | "GENERAL";
|
|
963
909
|
interface SectionSpec {
|
|
964
910
|
readonly type: string;
|
|
@@ -970,14 +916,18 @@ interface SectionSpec {
|
|
|
970
916
|
*
|
|
971
917
|
* 계약 전체를 실어 나르지 않고 이 축만 뽑아 오는 이유: 이 값을 읽는 소비자가 **팩 게이트 하나**이고,
|
|
972
918
|
* 그가 답해야 하는 질문이 정확히 "이 섹션이 아무것도 안 가리킨 채 시드에 들어와 있는가"이기 때문이다.
|
|
973
|
-
*
|
|
919
|
+
* 아무것도 안 가리킨 참조형 섹션은 렌더러가 `return null` 해서 **개시 직후 조용히 사라지는 섹션**이
|
|
974
920
|
* 된다 — 그 결함이 고객 개시 순간이 아니라 우리 터미널에서 죽게 하는 것이 이 필드의 전부다.
|
|
975
921
|
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
922
|
+
* ⚠ **rev 6 기준 이 축을 쓰는 타입은 0 이다.** 그 요구를 갖던 둘이 어휘에서 삭제됐기 때문이다.
|
|
923
|
+
* 필드를 남겨 두는 것은 계약 기계를 유지하기 위해서다 — 참조가 필수인 **저작물** 타입이 앞으로
|
|
924
|
+
* 생길 수 있고(에셋 축), 팩 게이트가 이 선언을 읽는 코드도 그대로 선다. 다만 **조회형 타입의 증설로**
|
|
925
|
+
* 이 축이 되살아나는 일은 없다(memo142 §6-2 가 그 문을 닫았다).
|
|
979
926
|
*
|
|
980
|
-
*
|
|
927
|
+
* 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는
|
|
928
|
+
* 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.
|
|
929
|
+
*
|
|
930
|
+
* **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 그 섹션만 스킵한다).
|
|
981
931
|
*/
|
|
982
932
|
readonly requiredRefs: readonly string[];
|
|
983
933
|
/**
|
|
@@ -987,37 +937,28 @@ interface SectionSpec {
|
|
|
987
937
|
* 역할을 하게 되면서 필수의 단위가 **"참조 존재"**로 옮겨갔다. 막으려는 것은 그대로다 — 아무것도
|
|
988
938
|
* 안 가리킨 채 시드에 들어와 개시 직후 조용히 사라지는 섹션.
|
|
989
939
|
*
|
|
940
|
+
* ⚠ `requiredRefs` 와 같이 **rev 6 기준 이 축을 쓰는 타입도 0 이다**(그 둘이 삭제됐다). 남기는
|
|
941
|
+
* 이유도 같다 — 계약 기계는 유지하고, 되살릴 문은 memo142 §6-2 가 닫았다.
|
|
942
|
+
*
|
|
990
943
|
* `requiredRefs`(무조건 필수)와 **함께** 쓴다: 그룹으로 표현되는 섹션은 `requiredRefs` 가 빈 배열이고,
|
|
991
944
|
* 종전처럼 단일 키가 무조건 필수인 섹션은 이 필드가 빈 배열이다. 둘 다 빈 배열이면 참조 요구가 없다.
|
|
992
945
|
*/
|
|
993
946
|
readonly requiredRefsAnyOf: readonly (readonly string[])[];
|
|
994
947
|
}
|
|
995
948
|
/**
|
|
996
|
-
* 아는 섹션
|
|
997
|
-
* 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
949
|
+
* 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,
|
|
950
|
+
* 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
998
951
|
*
|
|
999
952
|
* `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이
|
|
1000
953
|
* 그 항목을 통째로 못 읽고 "client 누락"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는
|
|
1001
954
|
* 길이 열린다 — 빠뜨림이 침묵이 아니라 실패가 되는 형상을 고른다.
|
|
1002
955
|
*/
|
|
1003
956
|
declare const SECTION_CONTRACT: readonly [{
|
|
1004
|
-
readonly type: "SERVICE_MENU";
|
|
1005
|
-
readonly vertical: "BEAUTY";
|
|
1006
|
-
readonly jsonLd: "ItemList";
|
|
1007
|
-
readonly requiredRefs: readonly [];
|
|
1008
|
-
readonly requiredRefsAnyOf: readonly [readonly ["productIds", "categorySlug"]];
|
|
1009
|
-
}, {
|
|
1010
957
|
readonly type: "BEFORE_AFTER_GALLERY";
|
|
1011
958
|
readonly vertical: "BEAUTY";
|
|
1012
959
|
readonly jsonLd: null;
|
|
1013
960
|
readonly requiredRefs: readonly [];
|
|
1014
961
|
readonly requiredRefsAnyOf: readonly [];
|
|
1015
|
-
}, {
|
|
1016
|
-
readonly type: "BOOKING_CTA";
|
|
1017
|
-
readonly vertical: "BEAUTY";
|
|
1018
|
-
readonly jsonLd: null;
|
|
1019
|
-
readonly requiredRefs: readonly [];
|
|
1020
|
-
readonly requiredRefsAnyOf: readonly [readonly ["productId", "categorySlug"]];
|
|
1021
962
|
}, {
|
|
1022
963
|
readonly type: "DOCTOR_INTRO";
|
|
1023
964
|
readonly vertical: "BEAUTY";
|
|
@@ -1075,11 +1016,77 @@ declare const SECTION_CONTRACT: readonly [{
|
|
|
1075
1016
|
}];
|
|
1076
1017
|
/** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */
|
|
1077
1018
|
type KnownSectionType = (typeof SECTION_CONTRACT)[number]["type"];
|
|
1078
|
-
/** 업종별 필터 —
|
|
1019
|
+
/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */
|
|
1079
1020
|
declare function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[];
|
|
1080
1021
|
|
|
1081
1022
|
declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
1082
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* `X-Forwarded-For` 에서 **원 방문자 IP** 를 뽑는다. 백엔드 `ClientUtils.resolveClientIp` 의 **계약 거울**이다
|
|
1026
|
+
* (같은 입력에 같은 값 — 테스트 벡터를 백엔드에서 그대로 이식해 드리프트를 막는다).
|
|
1027
|
+
*
|
|
1028
|
+
* ```ts
|
|
1029
|
+
* // route handler 안
|
|
1030
|
+
* import {visitorIp} from "@zalkera/client";
|
|
1031
|
+
* const ip = visitorIp(req.headers); // 프록시 1단(기본)
|
|
1032
|
+
* await zalkera.submitInquiry(input, {clientIp: ip});
|
|
1033
|
+
* ```
|
|
1034
|
+
*
|
|
1035
|
+
* ## 왜 이 함수가 있는가 — `xff.split(",")[0]` 는 위조된다
|
|
1036
|
+
*
|
|
1037
|
+
* `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가
|
|
1038
|
+
* 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 프록시는 그 뒤에 진짜 IP 를 붙이므로 헤더는
|
|
1039
|
+
* `9.9.9.9, <진짜IP>` 가 된다 — **첫 엔트리는 공격자가 쓴 문자열**이다. 첫 홉을 채택하는 코드는 그래서
|
|
1040
|
+
* 레이트리밋이 한 줄로 우회되고(요청마다 IP 를 바꾸면 버킷이 매번 새로 생긴다) IP 기록이 오염된다.
|
|
1041
|
+
*
|
|
1042
|
+
* 옳은 채택 지점은 **우리가 통제하는 프록시들이 붙인 블록의 가장 바깥(왼쪽) 엔트리** = 그 방문자가 우리
|
|
1043
|
+
* 최외곽 프록시에 연결할 때 쓴 IP 다. 인덱스로는 `길이 - 신뢰홉수`.
|
|
1044
|
+
*
|
|
1045
|
+
* ## 보장 경계 — **여기까지만 참이다**
|
|
1046
|
+
*
|
|
1047
|
+
* 이 함수는 "부르기만 하면 안전"을 팔지 않는다. 파는 것은 **"선언한 홉 수가 참인 만큼 안전"** 이다.
|
|
1048
|
+
* 자유 변수는 정수 하나([VisitorIpOptions.trustedHops])이고, 그 값이 틀리면 결과도 틀린다:
|
|
1049
|
+
*
|
|
1050
|
+
* | 선언 vs 실제 | 채택되는 값 | 결과 | 드러남 |
|
|
1051
|
+
* |---|---|---|---|
|
|
1052
|
+
* | 선언 **<** 실제(과소) | 안쪽 인프라 IP(CDN 엣지 등) | 방문자 전원이 한 IP 로 뭉침 → 429 폭주 | **가시** — 즉시 눈에 밟힌다 |
|
|
1053
|
+
* | 선언 **=** 실제 | 방문자 IP | 정상 | — |
|
|
1054
|
+
* | 선언 **>** 실제(과대) | **공격자가 넣은 임의 엔트리** | 위조 관통 | **비가시 — 조용히 뚫린다** |
|
|
1055
|
+
*
|
|
1056
|
+
* 그래서 규율은 하나다: **선언은 실제 이하로만.** 기본값 1 은 "항상 안전"이 아니라 **"위험한 방향으로는
|
|
1057
|
+
* 기본값이 데려가지 않는"** 값이다 — 과대 선언에는 당신이 직접 큰 수를 적어야만 도달한다.
|
|
1058
|
+
*
|
|
1059
|
+
* **지원하지 않는 배포**: 리버스 프록시 **0단 직노출**(Node 를 인터넷에 직접 붙인 형태). 거기서는
|
|
1060
|
+
* `X-Forwarded-For` **전체가** 방문자가 쓴 값이라 어떤 홉 수를 넣어도 이 함수는 위조를 돌려준다.
|
|
1061
|
+
* 그 배포에서는 이 함수를 쓰지 말고 소켓 IP 를 플랫폼 수단으로 직접 얻어라 — 이 함수는 그 경우를
|
|
1062
|
+
* 흡수하는 척하지 않는다.
|
|
1063
|
+
*
|
|
1064
|
+
* **우리가 모르는 것**: 당신의 프록시 단 수. 그래서 묻는다(옵션·env). 자동 감지는 **하지 않는다** —
|
|
1065
|
+
* 결정적 신호가 없고(`CF-Connecting-IP` 존재도 신호가 못 된다: CF 밖에서 위조 가능), 추정은 과대 선언과
|
|
1066
|
+
* 같은 위험이다. `x-real-ip` 폴백도 **하지 않는다** — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.
|
|
1067
|
+
*
|
|
1068
|
+
* **잘커라가 서빙하는 사이트라면** 이 값을 고민할 필요가 없다. 서빙 프록시가 방문자 IP **단일 엔트리**로
|
|
1069
|
+
* `X-Forwarded-For` 를 재작성하므로 "신뢰 홉 = 1" 이 구성상 참이고, 그것이 이 함수의 기본값이다.
|
|
1070
|
+
*
|
|
1071
|
+
* @param headers `Headers` 또는 `NextRequest.headers` — `get(name)` 하나만 쓴다.
|
|
1072
|
+
* @param options 홉 수 명시. 우선순위: 명시 > env `ZALKERA_TRUSTED_PROXY_HOPS` > 기본 1.
|
|
1073
|
+
* @returns 원 방문자 IP. 정할 수 없으면 `undefined`(문자열 `"unknown"` 을 지어내지 않는다) —
|
|
1074
|
+
* 그대로 `RequestContext.clientIp` 에 넣으면 되고, 클라이언트가 헤더를 생략한다.
|
|
1075
|
+
*/
|
|
1076
|
+
declare function visitorIp(headers: HeaderReader, options?: VisitorIpOptions): string | undefined;
|
|
1077
|
+
/** `get(name)` 만 요구한다 — `Headers`·`NextRequest.headers`·직접 만든 객체가 전부 들어맞는다. */
|
|
1078
|
+
interface HeaderReader {
|
|
1079
|
+
get(name: string): string | null;
|
|
1080
|
+
}
|
|
1081
|
+
interface VisitorIpOptions {
|
|
1082
|
+
/**
|
|
1083
|
+
* 우리가(=당신이) 통제해 `X-Forwarded-For` 를 append 하는 프록시 **단 수**. 기본 1.
|
|
1084
|
+
* 예: nginx 하나=1 · CDN+로드밸런서=2. **실제보다 크게 적지 마라** — 위 표의 '과대' 행이 조용한 구멍이다.
|
|
1085
|
+
* `0` 이하를 주면 XFF 를 신뢰하지 않겠다는 뜻이라 항상 `undefined` 를 돌려준다.
|
|
1086
|
+
*/
|
|
1087
|
+
trustedHops?: number;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1083
1090
|
/**
|
|
1084
1091
|
* 섹션 config 파싱 — **절대 throw 하지 않는다.**
|
|
1085
1092
|
*
|
|
@@ -1097,12 +1104,13 @@ declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
|
1097
1104
|
*/
|
|
1098
1105
|
declare function parseConfig<T>(config: string | null): T | null;
|
|
1099
1106
|
/**
|
|
1100
|
-
* config 를
|
|
1107
|
+
* config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).
|
|
1101
1108
|
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1109
|
+
* 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는
|
|
1110
|
+
* 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른
|
|
1111
|
+
* 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 로 남긴다.
|
|
1112
|
+
* 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유
|
|
1113
|
+
* 그대로다. 그래서 입구를 하나로 좁힌다.
|
|
1106
1114
|
*
|
|
1107
1115
|
* [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config
|
|
1108
1116
|
* 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).
|
|
@@ -1127,10 +1135,14 @@ declare function mediaSrc(assetId: number): string;
|
|
|
1127
1135
|
/** 상품 handle 하나. 문자열이 아니거나 공백뿐이면 `undefined` — 형식은 검사하지 않는다(런타임은 관용). */
|
|
1128
1136
|
declare function asHandle(value: unknown): string | undefined;
|
|
1129
1137
|
/**
|
|
1130
|
-
* 상품 handle 배열. **배열 순서를 보존한다** —
|
|
1131
|
-
*
|
|
1138
|
+
* 상품 handle 배열. **배열 순서를 보존한다** — 이 배열이 노출 순서의 원장이고, 같은 배열에서 만든
|
|
1139
|
+
* ItemList 가 화면과 같은 순서로 나가야 한다.
|
|
1132
1140
|
* 배열이 아니면 빈 배열, 원소 중 handle 이 아닌 것만 떨군다. 중복은 남긴다 — 같은 상품을 두 번
|
|
1133
1141
|
* 진열하는 것이 계약 위반은 아니다.
|
|
1142
|
+
*
|
|
1143
|
+
* ⚠ **어휘 rev 6 이후 이 헬퍼의 소비자는 섹션 config 가 아니라 소스다.** 조회형 섹션 타입이 삭제되면서
|
|
1144
|
+
* "선언에 적힌 handle 목록"이라는 입력이 계약에서 사라졌다. 헬퍼를 남기는 이유는 소스가 자기 큐레이션
|
|
1145
|
+
* (자기 카탈로그 안의 자기 handle — 정당하다)을 배열로 들고 다닐 때 여전히 쓰이기 때문이다.
|
|
1134
1146
|
*/
|
|
1135
1147
|
declare function asHandleArray(value: unknown): string[];
|
|
1136
1148
|
/**
|
|
@@ -1167,4 +1179,4 @@ interface ParsedTheme {
|
|
|
1167
1179
|
}
|
|
1168
1180
|
declare function parseThemeColors(raw: string | null | undefined): ParsedTheme;
|
|
1169
1181
|
|
|
1170
|
-
export { type ApiErrorBody, type AuthTokens, type AvailabilityParams, type AvailabilitySlot, type Booking, type BookingStatus, type BusinessType, type Cart, type CartLine, type Category, type CheckoutInput, type ConsentInput, type ConsentStatus, type ConsentType, type CreateBookingInput, type CreateReviewInput, type CustomerSummary, type InquiryCreated, type InquiryInput, type KnownSectionType, type LeadCreated, type LeadInput, type LeadTracking, type
|
|
1182
|
+
export { type ApiErrorBody, type AuthTokens, type AvailabilityParams, type AvailabilitySlot, type Booking, type BookingStatus, type BusinessType, type Cart, type CartLine, type Category, type CheckoutInput, type ConsentInput, type ConsentStatus, type ConsentType, type CreateBookingInput, type CreateReviewInput, type CustomerSummary, type HeaderReader, type InquiryCreated, type InquiryInput, type KnownSectionType, type LeadCreated, type LeadInput, type LeadTracking, type ListPostsParams, type ListProductsParams, type ListReviewsParams, type MediaUrl, type MenuPosition, type OrderAccess, type OrderDetail, type OrderHistoryEntry, type OrderItemLine, type OrderStatus, type OrderSummary, type Paginated, type ParsedTheme, type PaymentSession, type PostDetail, type PostSummary, type ProductCategory, type ProductDetail, type ProductSummary, type ProductType, type ProductVariant, type RatingSummary, type ReadOptions, type RequestContext, type Review, SECTION_CONTRACT, SECTION_CONTRACT_REV, type SectionSpec, type SectionVertical, type ShipToInput, type ShipmentEvent, type ShipmentInfo, type ShipmentStatus, type ShopSession, type SiteConfig, type SocialLoginInput, type SocialProvider, type ValidationError, type VisitorIpOptions, type ZalkeraClient, type ZalkeraClientOptions, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical, visitorIp };
|
package/dist/index.js
CHANGED
|
@@ -100,7 +100,10 @@ function createZalkeraClient(options) {
|
|
|
100
100
|
};
|
|
101
101
|
if (options.secretKey) headers["X-Storefront-Key"] = options.secretKey;
|
|
102
102
|
if (init?.body != null) headers["Content-Type"] = "application/json";
|
|
103
|
-
if (init?.context?.clientIp)
|
|
103
|
+
if (init?.context?.clientIp) {
|
|
104
|
+
headers["X-Zalkera-Client-Ip"] = init.context.clientIp;
|
|
105
|
+
headers["X-Forwarded-For"] = init.context.clientIp;
|
|
106
|
+
}
|
|
104
107
|
if (init?.bearer) headers["Authorization"] = `Bearer ${init.bearer}`;
|
|
105
108
|
if (init?.cartSession) headers["X-Cart-Session"] = init.cartSession;
|
|
106
109
|
if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
|
|
@@ -184,12 +187,6 @@ function createZalkeraClient(options) {
|
|
|
184
187
|
method: "POST",
|
|
185
188
|
context
|
|
186
189
|
}),
|
|
187
|
-
getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
|
|
188
|
-
listPages: (params, options2) => request("/public/pages", {
|
|
189
|
-
query: { page: params?.page, size: params?.size },
|
|
190
|
-
...nextInit(options2)
|
|
191
|
-
}),
|
|
192
|
-
listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
|
|
193
190
|
getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
|
|
194
191
|
submitInquiry: (input, context) => request("/public/inquiries", {
|
|
195
192
|
method: "POST",
|
|
@@ -304,17 +301,12 @@ function safeJsonParse(text) {
|
|
|
304
301
|
}
|
|
305
302
|
|
|
306
303
|
// src/sections.ts
|
|
307
|
-
var SECTION_CONTRACT_REV =
|
|
304
|
+
var SECTION_CONTRACT_REV = 7;
|
|
308
305
|
var SECTION_CONTRACT = [
|
|
309
|
-
// 뷰티(memo47) —
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// 정위치는 별도 라우트가 아니라 홈의 이 섹션이라, FAQ_LIST→FAQPage 와 같은 형태로 섹션이 직접 낸다.
|
|
313
|
-
// rev 3 에서 productIds 의 `?` 가 떨어졌다 — 목록을 내겠다고 선언한 섹션이 목록을 안 가리키는 상태가
|
|
314
|
-
// 계약상 성립하지 않게 됐다(BOOKING_CTA.productId 는 rev 1 부터 필수였다·비대칭 해소).
|
|
315
|
-
{ type: "SERVICE_MENU", vertical: "BEAUTY", jsonLd: "ItemList", requiredRefs: [], requiredRefsAnyOf: [["productIds", "categorySlug"]] },
|
|
306
|
+
// 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
|
|
307
|
+
// 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
|
|
308
|
+
// 진열이 낸다(보장표 `service-menu-itemlist` 의 route 는 원래 `any` — 판정 지점은 산출물이다).
|
|
316
309
|
{ type: "BEFORE_AFTER_GALLERY", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
317
|
-
{ type: "BOOKING_CTA", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [["productId", "categorySlug"]] },
|
|
318
310
|
{ type: "DOCTOR_INTRO", vertical: "BEAUTY", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
319
311
|
// 기업 마케팅(memo102 §2)
|
|
320
312
|
{ type: "HERO", vertical: "GENERAL", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: [] },
|
|
@@ -344,6 +336,32 @@ function safeLinkUrl(raw) {
|
|
|
344
336
|
}
|
|
345
337
|
}
|
|
346
338
|
|
|
339
|
+
// src/visitorIp.ts
|
|
340
|
+
function visitorIp(headers, options) {
|
|
341
|
+
const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;
|
|
342
|
+
if (hops <= 0) return void 0;
|
|
343
|
+
const raw = headers?.get?.("x-forwarded-for");
|
|
344
|
+
if (raw == null || raw.trim() === "") return void 0;
|
|
345
|
+
const parts = raw.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
346
|
+
if (parts.length === 0) return void 0;
|
|
347
|
+
const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);
|
|
348
|
+
return parts[index] || void 0;
|
|
349
|
+
}
|
|
350
|
+
var DEFAULT_TRUSTED_HOPS = 1;
|
|
351
|
+
var HOPS_ENV = "ZALKERA_TRUSTED_PROXY_HOPS";
|
|
352
|
+
var envWarned = false;
|
|
353
|
+
function hopsFromEnv() {
|
|
354
|
+
const raw = typeof process !== "undefined" ? process.env?.[HOPS_ENV] : void 0;
|
|
355
|
+
if (raw == null || raw.trim() === "") return void 0;
|
|
356
|
+
const parsed = Number(raw);
|
|
357
|
+
if (Number.isInteger(parsed)) return parsed;
|
|
358
|
+
if (!envWarned) {
|
|
359
|
+
envWarned = true;
|
|
360
|
+
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.`);
|
|
361
|
+
}
|
|
362
|
+
return void 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
347
365
|
// src/sectionConfig.ts
|
|
348
366
|
function parseConfig(config) {
|
|
349
367
|
if (!config) return null;
|
|
@@ -466,6 +484,6 @@ function toRgb(hex) {
|
|
|
466
484
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
467
485
|
}
|
|
468
486
|
|
|
469
|
-
export { SECTION_CONTRACT, SECTION_CONTRACT_REV, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical };
|
|
487
|
+
export { SECTION_CONTRACT, SECTION_CONTRACT_REV, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical, visitorIp };
|
|
470
488
|
//# sourceMappingURL=index.js.map
|
|
471
489
|
//# sourceMappingURL=index.js.map
|