@zalkera/client 0.18.0 → 0.20.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.d.ts CHANGED
@@ -127,78 +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
- * ⚠ **섹션은 업무 데이터(상품·갈래)를 가리키지 않는다**(계약 rev 6 · memo142 §1). 종전에는
165
- * `SERVICE_MENU { categorySlug }` 같은 조회형 변형을 여기 예외로 적어 뒀는데, 그 타입들이 어휘에서
166
- * 삭제됐다. 진열은 소스가 [listProducts]·[listProductCategories] 를 **직접 호출**해 그린다 — 어디에
167
- * 뿌릴지도 어떻게 그릴지도 소스의 몫이다.
168
- */
169
- interface PageSection {
170
- type: string;
171
- sortOrder: number;
172
- config: string | null;
173
- }
174
- /** `PublicPageResponse` — 회사 소개 같은 고정 페이지. */
175
- interface PageContent {
176
- id: number;
177
- slug: string;
178
- title: string;
179
- /** 레거시 마크다운 본문. [sections] 가 있으면 무시된다. */
180
- content: string | null;
181
- publishedAt: string | null;
182
- /**
183
- * SEO 오버라이드 JSON 문자열(스키마리스 — 백엔드가 검증하지 않는다). 미설정이면 null.
184
- * 권장 구조: `{"title": "…", "description": "…"}` — 사이트 기본값 [SiteConfig.seoDefaults] 와
185
- * 같은 모양이다. `title` 없으면 소비자가 자연 제목(페이지 제목·상품명)으로 강하한다.
186
- * **파싱 실패해도 죽으면 안 된다** — 패스스루라 어떤 값이든 올 수 있다.
187
- */
188
- seo: string | null;
189
- /** 있으면 이걸 렌더한다(content 는 무시). 비었으면 [content] 폴백. 순서는 서버가 정렬해 준다. */
190
- sections: PageSection[];
191
- }
192
130
  type MenuPosition = "HEADER" | "FOOTER";
193
- /** `PublicMenuResponse` — 재귀 트리(children). */
194
- interface Menu {
195
- id: number;
196
- position: MenuPosition;
197
- label: string;
198
- url: string;
199
- sortOrder: number;
200
- children: Menu[];
201
- }
202
131
  /** `PublicMediaUrlResponse` — presigned 다운로드 URL. `expiresAt` 전까지만 유효하다. */
203
132
  interface MediaUrl {
204
133
  url: string;
@@ -692,39 +621,28 @@ interface ZalkeraClient {
692
621
  *
693
622
  * 조회 dedup 은 뷰어 IP·UA 를 쓴다. 서버 사이드에서 부를 때는 [RequestContext.clientIp] 로
694
623
  * 원 방문자 IP 를 넘겨야 방문자별로 집계된다 — 안 넘기면 전부 서버 IP 하나로 뭉친다.
624
+ * 값은 [visitorIp] 로 뽑아라(첫 홉 직접 추출 금지 — 방문자가 위조할 수 있다).
695
625
  */
696
626
  recordPostView(slug: string, context?: RequestContext): Promise<boolean>;
697
- /** slug 로 고정 페이지. */
698
- /**
699
- * slug 로 고정 페이지(섹션 포함). 없으면 404 → [ZalkeraError].
700
- * ISR 페이지는 [ReadOptions.tags] 로 캐시 태그를 실을 수 있다 — 백엔드가 발행 시 그 태그만
701
- * 콕 집어 revalidate 한다.
702
- */
703
- getPage(slug: string, options?: ReadOptions): Promise<PageContent>;
704
- /**
705
- * 발행된 고정 페이지 **목록**(열거 전용·본문 없음).
706
- *
707
- * sitemap 을 위한 API 다 — 이게 없으면 콘솔·AI 로 만든 페이지가 검색엔진에 열거되지 않는다.
708
- * 목록에 실린 slug 는 [getPage] 가 반드시 200 을 준다(가시성 술어가 서버에서 하나로 공유된다).
709
- */
710
- listPages(params?: ListPagesParams, options?: ReadOptions): Promise<Paginated<PageSummary>>;
711
- /** 메뉴 트리(HEADER·FOOTER). */
712
- listMenus(options?: ReadOptions): Promise<Menu[]>;
713
627
  /** 미디어 presigned 다운로드 URL(만료 있음). */
714
628
  getMediaUrl(id: number): Promise<MediaUrl>;
715
629
  /**
716
630
  * 문의 접수. 성공 시 생성된 문의 id. 레이트리밋이면 429 → `error.isRateLimited`.
717
631
  *
718
632
  * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자
719
- * IP 를 반드시 넘겨야 한다.** 백엔드의 문의 레이트리밋·IP 기록은 `X-Forwarded-For` 홉을
720
- * 쓰는데, 안 넘기면 백엔드가 테넌트 서버 IP 하나만 보고 몇 건 뒤 **모든 방문자**를 429 로 막는다.
633
+ * IP 를 반드시 넘겨야 한다.** 넘기면 백엔드가 테넌트 서버 IP 하나만 보고 건 뒤 **모든 방문자**
634
+ * 429 로 막는다.
635
+ *
636
+ * ⚠️ 그 값은 **[visitorIp] 로 뽑아라.** `x-forwarded-for` 의 **첫 홉을 직접 쓰지 마라** — 첫 엔트리는
637
+ * 방문자가 요청에 손으로 실은 값이라 레이트리밋이 한 줄로 우회된다(백엔드는 첫 홉을 쓰지 않는다.
638
+ * 신뢰 프록시 홉 기반으로 채택한다). 보장 경계·홉 수 선언은 [visitorIp] 문서 참조.
721
639
  */
722
640
  submitInquiry(input: InquiryInput, context?: RequestContext): Promise<InquiryCreated>;
723
641
  /**
724
642
  * 광고 리드 접수. 문의와 달리 이메일이 선택이고 UTM 추적을 함께 보낸다.
725
643
  *
726
644
  * ⚠️ [submitInquiry] 와 같은 이유로 서버 사이드에서는 [RequestContext.clientIp] 로 원 방문자
727
- * IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다.
645
+ * IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다. 값은 [visitorIp] 로 뽑는다.
728
646
  */
729
647
  submitLead(input: LeadInput, context?: RequestContext): Promise<LeadCreated>;
730
648
  /** slug 로 공개 상품(ACTIVE) 상세 — variant·재고 가용여부 포함. 없으면 404. ISR 태그는 [ReadOptions]. */
@@ -837,18 +755,18 @@ interface ZalkeraClient {
837
755
  * 리다이렉트형 벤더에 부르면 404(`PG_CONFIG_NOT_FOUND`).
838
756
  */
839
757
  confirmPayment(orderNo: string, providerParams: Record<string, string>, access: OrderAccess): Promise<void>;
840
- /** 주문 조회 — 본인(accessToken) 또는 게스트(phone). */
758
+ /** 주문 조회 — 본인(accessToken) 또는 게스트(phone). 인가 실패가 누적되면 429. */
841
759
  getOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;
842
760
  /** 내 주문 목록(로그인 필수). */
843
761
  listMyOrders(accessToken: string, params?: {
844
762
  page?: number;
845
763
  size?: number;
846
764
  }): Promise<Paginated<OrderSummary>>;
847
- /** 주문 취소(미결제만) — 본인 또는 게스트(phone). */
765
+ /** 주문 취소(미결제만) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */
848
766
  cancelOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;
849
- /** 구매 확정(배송완료 후) — 본인 또는 게스트(phone). */
767
+ /** 구매 확정(배송완료 후) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */
850
768
  completeOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;
851
- /** 배송 조회 — 본인 또는 게스트(phone). */
769
+ /** 배송 조회 — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */
852
770
  getShipment(orderNo: string, access: OrderAccess): Promise<ShipmentInfo>;
853
771
  }
854
772
  /**
@@ -866,15 +784,28 @@ interface ShopSession {
866
784
  interface OrderAccess {
867
785
  accessToken?: string;
868
786
  phone?: string;
787
+ /**
788
+ * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자 IP 를
789
+ * 선언**하라. 게스트 주문 인가(주문번호+phone)에는 실패 rate-limit 이 걸려 있는데, 선언이 없으면
790
+ * 백엔드가 보는 IP 가 **테넌트 서버 하나로 뭉쳐** 한 방문자의 실패가 그 사이트 전체를 잠글 수 있다.
791
+ * 주문번호 축은 그와 무관하게 계속 서므로 방어 자체는 유효하다 — 선언은 **오탐을 줄이는 쪽**이다.
792
+ */
793
+ context?: RequestContext;
869
794
  }
870
795
  /**
871
- * IP 민감 엔드포인트(문의·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.
796
+ * IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.
872
797
  *
873
798
  * 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가
874
- * 아니라 테넌트 서버다. 방문자 IP 를 `X-Forwarded-For` 실어 백엔드가 방문자별로 판단하게 한다.
799
+ * 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 전송은 전용 헤더
800
+ * `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된
801
+ * 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로
802
+ * 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).
875
803
  */
876
804
  interface RequestContext {
877
- /** 원 방문자 IP — route handler 에서 요청 헤더(x-forwarded-for·x-real-ip)로 뽑아 넘긴다. */
805
+ /**
806
+ * 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고
807
+ * (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).
808
+ */
878
809
  clientIp?: string;
879
810
  }
880
811
  declare function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient;
@@ -891,7 +822,7 @@ declare function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClie
891
822
  * `status` 는 거친 분류에만:
892
823
  * - `400` + [validationErrors] — 입력값 문제(폼 필드별 메시지 노출)
893
824
  * - `404` — 없는 slug/리소스
894
- * - `429` — IP 레이트리밋(문의 남발). [isRateLimited] 로 편히 판별
825
+ * - `429` — 레이트리밋(문의·리드 남발, 게스트 주문 인가 실패 누적). [isRateLimited] 로 편히 판별
895
826
  * - `5xx` — 서버 오류
896
827
  *
897
828
  * 네트워크 자체가 실패했거나 응답이 JSON 이 아니면 [status] 가 0 이고 [body] 가 null 이다.
@@ -919,7 +850,13 @@ declare class ZalkeraError extends Error {
919
850
  body?: ApiErrorBody | null;
920
851
  cause?: unknown;
921
852
  });
922
- /** IP 레이트리밋(429) — 문의 폼에서 "잠시 후 다시" 를 띄울 때 쓴다. */
853
+ /**
854
+ * 레이트리밋(429) — "잠시 후 다시" 를 띄울 때 쓴다.
855
+ *
856
+ * 두 갈래다: ⑴ 문의·리드 폼 남발(IP 축) ⑵ **게스트 주문 인가 실패 누적** — 주문번호+연락처로
857
+ * 여는 주문 조회·취소·구매확정·배송조회·결제세션은 연락처 대입을 막으려고 실패를 센다.
858
+ * 정상 조회는 세지 않으므로, 이 코드가 뜨면 연락처를 여러 번 틀렸거나 같은 주문에 시도가 몰린 것이다.
859
+ */
923
860
  get isRateLimited(): boolean;
924
861
  /**
925
862
  * 스토어프론트 시크릿 키 문제(memo78) — 개발자 오배선 신호. `true` 면 `secretKey` 옵션을
@@ -935,9 +872,10 @@ declare class ZalkeraError extends Error {
935
872
  * 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).
936
873
  *
937
874
  * 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로
938
- * 실어 나르는 **운반체**다. 템플릿 렌더러와 콘솔 zod 스키마가 이 상수를 읽어 자기 커버리지를
939
- * 기계로 검사한다 — 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은
940
- * 아니다(계약이 원래 스큐 내성으로 설계돼 있다: 미지 타입은 스킵).
875
+ * 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —
876
+ * 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래
877
+ * 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —
878
+ * 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).
941
879
  *
942
880
  * 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —
943
881
  * 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,
@@ -969,9 +907,17 @@ declare class ZalkeraError extends Error {
969
907
  * ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.
970
908
  * 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인
971
909
  * 팩**에만 선다.
910
+ *
911
+ * rev 7 = **DB 방언 소거 — 거처가 하나 남았다**(memo144). `page`·`page_section`·`menu` 계열이 퇴역하면서
912
+ * 정본의 `dialects.id`(숫자 id 표기)가 가리킬 자리가 없어졌다. rev 4 가 방언을 1급으로 승격하며 rev 를
913
+ * 올렸던 것의 **역연산**이라 서술 정리가 아니라 잣대 변경이다. **아래 리터럴은 rev 6 과 바이트 동일**이다
914
+ * — 섹션 10종·`requiredRefs(AnyOf)` 는 한 글자도 안 바뀐다(동기 스크립트가 확인한다). 이 패키지에서
915
+ * 함께 내려간 것은 그 거처를 읽던 표면이다: `getPage`·`listPages`·`listMenus` 와 그 타입들.
916
+ * 남은 표기는 소스 하나 — `content/pages/*.json` 이 쓰는 참조 표기(`asset` 계열 문자열)이고,
917
+ * 어휘 표의 `assetId` 계열 키 이름은 그 시절 표기가 굳은 것이다(대응은 정본 `dialects.reference`).
972
918
  */
973
- declare const SECTION_CONTRACT_REV = 6;
974
- /** 업종 분류 — 콘솔 섹션 픽커의 그룹핑에 쓴다(테넌트 업종 필드는 아직 없다·memo102 §5). */
919
+ declare const SECTION_CONTRACT_REV = 7;
920
+ /** 업종 분류 — 어휘를 묶어 보여 때의 그룹핑이지 사용 제한이 아니다(GENERAL 뷰티 사이트도 쓴다). */
975
921
  type SectionVertical = "BEAUTY" | "GENERAL";
976
922
  interface SectionSpec {
977
923
  readonly type: string;
@@ -994,7 +940,7 @@ interface SectionSpec {
994
940
  * 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는
995
941
  * 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.
996
942
  *
997
- * **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러 스킵·콘솔 raw 편집 허용).
943
+ * **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 섹션만 스킵한다).
998
944
  */
999
945
  readonly requiredRefs: readonly string[];
1000
946
  /**
@@ -1013,8 +959,8 @@ interface SectionSpec {
1013
959
  readonly requiredRefsAnyOf: readonly (readonly string[])[];
1014
960
  }
1015
961
  /**
1016
- * 아는 섹션 전량(rev 6 기준 **10종**). **순서는 콘솔 픽커의 노출 순서**(관행 아크: 주목→가치→신뢰→행동).
1017
- * 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
962
+ * 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,
963
+ * 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
1018
964
  *
1019
965
  * `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이
1020
966
  * 그 항목을 통째로 못 읽고 "client 누락"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는
@@ -1083,11 +1029,77 @@ declare const SECTION_CONTRACT: readonly [{
1083
1029
  }];
1084
1030
  /** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */
1085
1031
  type KnownSectionType = (typeof SECTION_CONTRACT)[number]["type"];
1086
- /** 업종별 필터 — 콘솔 픽커 그룹핑용. */
1032
+ /** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */
1087
1033
  declare function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[];
1088
1034
 
1089
1035
  declare function safeLinkUrl(raw: string | null | undefined): string;
1090
1036
 
1037
+ /**
1038
+ * `X-Forwarded-For` 에서 **원 방문자 IP** 를 뽑는다. 백엔드 `ClientUtils.resolveClientIp` 의 **계약 거울**이다
1039
+ * (같은 입력에 같은 값 — 테스트 벡터를 백엔드에서 그대로 이식해 드리프트를 막는다).
1040
+ *
1041
+ * ```ts
1042
+ * // route handler 안
1043
+ * import {visitorIp} from "@zalkera/client";
1044
+ * const ip = visitorIp(req.headers); // 프록시 1단(기본)
1045
+ * await zalkera.submitInquiry(input, {clientIp: ip});
1046
+ * ```
1047
+ *
1048
+ * ## 왜 이 함수가 있는가 — `xff.split(",")[0]` 는 위조된다
1049
+ *
1050
+ * `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가
1051
+ * 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 프록시는 그 뒤에 진짜 IP 를 붙이므로 헤더는
1052
+ * `9.9.9.9, <진짜IP>` 가 된다 — **첫 엔트리는 공격자가 쓴 문자열**이다. 첫 홉을 채택하는 코드는 그래서
1053
+ * 레이트리밋이 한 줄로 우회되고(요청마다 IP 를 바꾸면 버킷이 매번 새로 생긴다) IP 기록이 오염된다.
1054
+ *
1055
+ * 옳은 채택 지점은 **우리가 통제하는 프록시들이 붙인 블록의 가장 바깥(왼쪽) 엔트리** = 그 방문자가 우리
1056
+ * 최외곽 프록시에 연결할 때 쓴 IP 다. 인덱스로는 `길이 - 신뢰홉수`.
1057
+ *
1058
+ * ## 보장 경계 — **여기까지만 참이다**
1059
+ *
1060
+ * 이 함수는 "부르기만 하면 안전"을 팔지 않는다. 파는 것은 **"선언한 홉 수가 참인 만큼 안전"** 이다.
1061
+ * 자유 변수는 정수 하나([VisitorIpOptions.trustedHops])이고, 그 값이 틀리면 결과도 틀린다:
1062
+ *
1063
+ * | 선언 vs 실제 | 채택되는 값 | 결과 | 드러남 |
1064
+ * |---|---|---|---|
1065
+ * | 선언 **<** 실제(과소) | 안쪽 인프라 IP(CDN 엣지 등) | 방문자 전원이 한 IP 로 뭉침 → 429 폭주 | **가시** — 즉시 눈에 밟힌다 |
1066
+ * | 선언 **=** 실제 | 방문자 IP | 정상 | — |
1067
+ * | 선언 **>** 실제(과대) | **공격자가 넣은 임의 엔트리** | 위조 관통 | **비가시 — 조용히 뚫린다** |
1068
+ *
1069
+ * 그래서 규율은 하나다: **선언은 실제 이하로만.** 기본값 1 은 "항상 안전"이 아니라 **"위험한 방향으로는
1070
+ * 기본값이 데려가지 않는"** 값이다 — 과대 선언에는 당신이 직접 큰 수를 적어야만 도달한다.
1071
+ *
1072
+ * **지원하지 않는 배포**: 리버스 프록시 **0단 직노출**(Node 를 인터넷에 직접 붙인 형태). 거기서는
1073
+ * `X-Forwarded-For` **전체가** 방문자가 쓴 값이라 어떤 홉 수를 넣어도 이 함수는 위조를 돌려준다.
1074
+ * 그 배포에서는 이 함수를 쓰지 말고 소켓 IP 를 플랫폼 수단으로 직접 얻어라 — 이 함수는 그 경우를
1075
+ * 흡수하는 척하지 않는다.
1076
+ *
1077
+ * **우리가 모르는 것**: 당신의 프록시 단 수. 그래서 묻는다(옵션·env). 자동 감지는 **하지 않는다** —
1078
+ * 결정적 신호가 없고(`CF-Connecting-IP` 존재도 신호가 못 된다: CF 밖에서 위조 가능), 추정은 과대 선언과
1079
+ * 같은 위험이다. `x-real-ip` 폴백도 **하지 않는다** — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.
1080
+ *
1081
+ * **잘커라가 서빙하는 사이트라면** 이 값을 고민할 필요가 없다. 서빙 프록시가 방문자 IP **단일 엔트리**로
1082
+ * `X-Forwarded-For` 를 재작성하므로 "신뢰 홉 = 1" 이 구성상 참이고, 그것이 이 함수의 기본값이다.
1083
+ *
1084
+ * @param headers `Headers` 또는 `NextRequest.headers` — `get(name)` 하나만 쓴다.
1085
+ * @param options 홉 수 명시. 우선순위: 명시 > env `ZALKERA_TRUSTED_PROXY_HOPS` > 기본 1.
1086
+ * @returns 원 방문자 IP. 정할 수 없으면 `undefined`(문자열 `"unknown"` 을 지어내지 않는다) —
1087
+ * 그대로 `RequestContext.clientIp` 에 넣으면 되고, 클라이언트가 헤더를 생략한다.
1088
+ */
1089
+ declare function visitorIp(headers: HeaderReader, options?: VisitorIpOptions): string | undefined;
1090
+ /** `get(name)` 만 요구한다 — `Headers`·`NextRequest.headers`·직접 만든 객체가 전부 들어맞는다. */
1091
+ interface HeaderReader {
1092
+ get(name: string): string | null;
1093
+ }
1094
+ interface VisitorIpOptions {
1095
+ /**
1096
+ * 우리가(=당신이) 통제해 `X-Forwarded-For` 를 append 하는 프록시 **단 수**. 기본 1.
1097
+ * 예: nginx 하나=1 · CDN+로드밸런서=2. **실제보다 크게 적지 마라** — 위 표의 '과대' 행이 조용한 구멍이다.
1098
+ * `0` 이하를 주면 XFF 를 신뢰하지 않겠다는 뜻이라 항상 `undefined` 를 돌려준다.
1099
+ */
1100
+ trustedHops?: number;
1101
+ }
1102
+
1091
1103
  /**
1092
1104
  * 섹션 config 파싱 — **절대 throw 하지 않는다.**
1093
1105
  *
@@ -1105,12 +1117,13 @@ declare function safeLinkUrl(raw: string | null | undefined): string;
1105
1117
  */
1106
1118
  declare function parseConfig<T>(config: string | null): T | null;
1107
1119
  /**
1108
- * config 를 **거처와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).
1120
+ * config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).
1109
1121
  *
1110
- * 거처가 둘이 됐다: DB(`page_section.config` JSON **문자열**)와 소스(`content/pages/*.json` 의
1111
- * `sections[].config` **객체**). 문자열인 것은 컬럼 저장 잔재이지 계약이 아니라(어휘 계약 rev 4
1112
- * `dialects`), 소비자가 갈래로 갈리면 섹션 컴포넌트가 벌이 된다 패키지가 사본을 안 만드는
1113
- * 이유 그대로다. 그래서 입구를 하나로 좁힌다.
1122
+ * 계약이 말하는 config **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는
1123
+ * 둘이다: ⑴ 손으로 고치는 파일이라 config 통째 문자열로 적어 넣는 일이 실제로 있고 종전의 다른
1124
+ * 거처(DB 컬럼) 문자열을 줬다 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 남긴다.
1125
+ * 소비자가 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유
1126
+ * 그대로다. 그래서 입구를 하나로 좁힌다.
1114
1127
  *
1115
1128
  * [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config
1116
1129
  * 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).
@@ -1179,4 +1192,4 @@ interface ParsedTheme {
1179
1192
  }
1180
1193
  declare function parseThemeColors(raw: string | null | undefined): ParsedTheme;
1181
1194
 
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 InquiryCreated, type InquiryInput, type KnownSectionType, type LeadCreated, type LeadInput, type LeadTracking, type ListPagesParams, type ListPostsParams, type ListProductsParams, type ListReviewsParams, type MediaUrl, type Menu, type MenuPosition, type OrderAccess, type OrderDetail, type OrderHistoryEntry, type OrderItemLine, type OrderStatus, type OrderSummary, type PageContent, type PageSection, type PageSummary, 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 ZalkeraClient, type ZalkeraClientOptions, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical };
1195
+ 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
@@ -23,7 +23,13 @@ var ZalkeraError = class _ZalkeraError extends Error {
23
23
  this.validationErrors = options.validationErrors ?? [];
24
24
  this.body = options.body ?? null;
25
25
  }
26
- /** IP 레이트리밋(429) — 문의 폼에서 "잠시 후 다시" 를 띄울 때 쓴다. */
26
+ /**
27
+ * 레이트리밋(429) — "잠시 후 다시" 를 띄울 때 쓴다.
28
+ *
29
+ * 두 갈래다: ⑴ 문의·리드 폼 남발(IP 축) ⑵ **게스트 주문 인가 실패 누적** — 주문번호+연락처로
30
+ * 여는 주문 조회·취소·구매확정·배송조회·결제세션은 연락처 대입을 막으려고 실패를 센다.
31
+ * 정상 조회는 세지 않으므로, 이 코드가 뜨면 연락처를 여러 번 틀렸거나 같은 주문에 시도가 몰린 것이다.
32
+ */
27
33
  get isRateLimited() {
28
34
  return this.status === 429;
29
35
  }
@@ -100,7 +106,10 @@ function createZalkeraClient(options) {
100
106
  };
101
107
  if (options.secretKey) headers["X-Storefront-Key"] = options.secretKey;
102
108
  if (init?.body != null) headers["Content-Type"] = "application/json";
103
- if (init?.context?.clientIp) headers["X-Forwarded-For"] = init.context.clientIp;
109
+ if (init?.context?.clientIp) {
110
+ headers["X-Zalkera-Client-Ip"] = init.context.clientIp;
111
+ headers["X-Forwarded-For"] = init.context.clientIp;
112
+ }
104
113
  if (init?.bearer) headers["Authorization"] = `Bearer ${init.bearer}`;
105
114
  if (init?.cartSession) headers["X-Cart-Session"] = init.cartSession;
106
115
  if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
@@ -184,12 +193,6 @@ function createZalkeraClient(options) {
184
193
  method: "POST",
185
194
  context
186
195
  }),
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
196
  getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
194
197
  submitInquiry: (input, context) => request("/public/inquiries", {
195
198
  method: "POST",
@@ -289,7 +292,8 @@ function accessInit(access, extra) {
289
292
  return {
290
293
  ...extra,
291
294
  bearer: access.accessToken,
292
- query: access.phone ? { phone: access.phone } : void 0
295
+ query: access.phone ? { phone: access.phone } : void 0,
296
+ context: access.context
293
297
  };
294
298
  }
295
299
  function isRedirectResponse(response) {
@@ -304,7 +308,7 @@ function safeJsonParse(text) {
304
308
  }
305
309
 
306
310
  // src/sections.ts
307
- var SECTION_CONTRACT_REV = 6;
311
+ var SECTION_CONTRACT_REV = 7;
308
312
  var SECTION_CONTRACT = [
309
313
  // 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
310
314
  // 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
@@ -339,6 +343,32 @@ function safeLinkUrl(raw) {
339
343
  }
340
344
  }
341
345
 
346
+ // src/visitorIp.ts
347
+ function visitorIp(headers, options) {
348
+ const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;
349
+ if (hops <= 0) return void 0;
350
+ const raw = headers?.get?.("x-forwarded-for");
351
+ if (raw == null || raw.trim() === "") return void 0;
352
+ const parts = raw.split(",").map((part) => part.trim()).filter((part) => part !== "");
353
+ if (parts.length === 0) return void 0;
354
+ const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);
355
+ return parts[index] || void 0;
356
+ }
357
+ var DEFAULT_TRUSTED_HOPS = 1;
358
+ var HOPS_ENV = "ZALKERA_TRUSTED_PROXY_HOPS";
359
+ var envWarned = false;
360
+ function hopsFromEnv() {
361
+ const raw = typeof process !== "undefined" ? process.env?.[HOPS_ENV] : void 0;
362
+ if (raw == null || raw.trim() === "") return void 0;
363
+ const parsed = Number(raw);
364
+ if (Number.isInteger(parsed)) return parsed;
365
+ if (!envWarned) {
366
+ envWarned = true;
367
+ 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.`);
368
+ }
369
+ return void 0;
370
+ }
371
+
342
372
  // src/sectionConfig.ts
343
373
  function parseConfig(config) {
344
374
  if (!config) return null;
@@ -461,6 +491,6 @@ function toRgb(hex) {
461
491
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
462
492
  }
463
493
 
464
- export { SECTION_CONTRACT, SECTION_CONTRACT_REV, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical };
494
+ export { SECTION_CONTRACT, SECTION_CONTRACT_REV, ZalkeraError, asHandle, asHandleArray, asId, asIdArray, asObjectArray, asString, assetPath, createZalkeraClient, mediaSrc, parseConfig, parseThemeColors, readConfig, safeLinkUrl, sectionsOfVertical, visitorIp };
465
495
  //# sourceMappingURL=index.js.map
466
496
  //# sourceMappingURL=index.js.map