@zalkera/client 0.18.0 → 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 +208 -1
- package/dist/index.cjs +32 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +108 -108
- package/dist/index.d.ts +108 -108
- package/dist/index.js +32 -9
- package/dist/index.js.map +1 -1
- package/llms.txt +130 -60
- package/package.json +1 -1
package/dist/index.d.cts
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 를 반드시 넘겨야 한다.**
|
|
720
|
-
*
|
|
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]. */
|
|
@@ -868,13 +786,19 @@ interface OrderAccess {
|
|
|
868
786
|
phone?: string;
|
|
869
787
|
}
|
|
870
788
|
/**
|
|
871
|
-
* IP 민감 엔드포인트(
|
|
789
|
+
* IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.
|
|
872
790
|
*
|
|
873
791
|
* 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가
|
|
874
|
-
* 아니라 테넌트 서버다. 방문자 IP 를
|
|
792
|
+
* 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 — 전송은 전용 헤더
|
|
793
|
+
* `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된
|
|
794
|
+
* 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로
|
|
795
|
+
* 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).
|
|
875
796
|
*/
|
|
876
797
|
interface RequestContext {
|
|
877
|
-
/**
|
|
798
|
+
/**
|
|
799
|
+
* 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고
|
|
800
|
+
* (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).
|
|
801
|
+
*/
|
|
878
802
|
clientIp?: string;
|
|
879
803
|
}
|
|
880
804
|
declare function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient;
|
|
@@ -935,9 +859,10 @@ declare class ZalkeraError extends Error {
|
|
|
935
859
|
* 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).
|
|
936
860
|
*
|
|
937
861
|
* 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로
|
|
938
|
-
* 실어 나르는 **운반체**다.
|
|
939
|
-
*
|
|
940
|
-
*
|
|
862
|
+
* 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —
|
|
863
|
+
* 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래
|
|
864
|
+
* 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —
|
|
865
|
+
* 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).
|
|
941
866
|
*
|
|
942
867
|
* 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —
|
|
943
868
|
* 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,
|
|
@@ -969,9 +894,17 @@ declare class ZalkeraError extends Error {
|
|
|
969
894
|
* ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.
|
|
970
895
|
* 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인
|
|
971
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`).
|
|
972
905
|
*/
|
|
973
|
-
declare const SECTION_CONTRACT_REV =
|
|
974
|
-
/** 업종 분류 —
|
|
906
|
+
declare const SECTION_CONTRACT_REV = 7;
|
|
907
|
+
/** 업종 분류 — 어휘를 묶어 보여 줄 때의 그룹핑이지 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 쓴다). */
|
|
975
908
|
type SectionVertical = "BEAUTY" | "GENERAL";
|
|
976
909
|
interface SectionSpec {
|
|
977
910
|
readonly type: string;
|
|
@@ -994,7 +927,7 @@ interface SectionSpec {
|
|
|
994
927
|
* 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는
|
|
995
928
|
* 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.
|
|
996
929
|
*
|
|
997
|
-
* **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(
|
|
930
|
+
* **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 그 섹션만 스킵한다).
|
|
998
931
|
*/
|
|
999
932
|
readonly requiredRefs: readonly string[];
|
|
1000
933
|
/**
|
|
@@ -1013,8 +946,8 @@ interface SectionSpec {
|
|
|
1013
946
|
readonly requiredRefsAnyOf: readonly (readonly string[])[];
|
|
1014
947
|
}
|
|
1015
948
|
/**
|
|
1016
|
-
* 아는 섹션 전량(rev
|
|
1017
|
-
* 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
949
|
+
* 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,
|
|
950
|
+
* 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
1018
951
|
*
|
|
1019
952
|
* `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이
|
|
1020
953
|
* 그 항목을 통째로 못 읽고 "client 누락"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는
|
|
@@ -1083,11 +1016,77 @@ declare const SECTION_CONTRACT: readonly [{
|
|
|
1083
1016
|
}];
|
|
1084
1017
|
/** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */
|
|
1085
1018
|
type KnownSectionType = (typeof SECTION_CONTRACT)[number]["type"];
|
|
1086
|
-
/** 업종별 필터 —
|
|
1019
|
+
/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */
|
|
1087
1020
|
declare function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[];
|
|
1088
1021
|
|
|
1089
1022
|
declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
1090
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
|
+
|
|
1091
1090
|
/**
|
|
1092
1091
|
* 섹션 config 파싱 — **절대 throw 하지 않는다.**
|
|
1093
1092
|
*
|
|
@@ -1105,12 +1104,13 @@ declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
|
1105
1104
|
*/
|
|
1106
1105
|
declare function parseConfig<T>(config: string | null): T | null;
|
|
1107
1106
|
/**
|
|
1108
|
-
* config 를
|
|
1107
|
+
* config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).
|
|
1109
1108
|
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1109
|
+
* 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는
|
|
1110
|
+
* 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른
|
|
1111
|
+
* 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 로 남긴다.
|
|
1112
|
+
* 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유
|
|
1113
|
+
* 그대로다. 그래서 입구를 하나로 좁힌다.
|
|
1114
1114
|
*
|
|
1115
1115
|
* [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config
|
|
1116
1116
|
* 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).
|
|
@@ -1179,4 +1179,4 @@ interface ParsedTheme {
|
|
|
1179
1179
|
}
|
|
1180
1180
|
declare function parseThemeColors(raw: string | null | undefined): ParsedTheme;
|
|
1181
1181
|
|
|
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
|
|
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.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 를 반드시 넘겨야 한다.**
|
|
720
|
-
*
|
|
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]. */
|
|
@@ -868,13 +786,19 @@ interface OrderAccess {
|
|
|
868
786
|
phone?: string;
|
|
869
787
|
}
|
|
870
788
|
/**
|
|
871
|
-
* IP 민감 엔드포인트(
|
|
789
|
+
* IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.
|
|
872
790
|
*
|
|
873
791
|
* 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가
|
|
874
|
-
* 아니라 테넌트 서버다. 방문자 IP 를
|
|
792
|
+
* 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 — 전송은 전용 헤더
|
|
793
|
+
* `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된
|
|
794
|
+
* 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로
|
|
795
|
+
* 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).
|
|
875
796
|
*/
|
|
876
797
|
interface RequestContext {
|
|
877
|
-
/**
|
|
798
|
+
/**
|
|
799
|
+
* 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고
|
|
800
|
+
* (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).
|
|
801
|
+
*/
|
|
878
802
|
clientIp?: string;
|
|
879
803
|
}
|
|
880
804
|
declare function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient;
|
|
@@ -935,9 +859,10 @@ declare class ZalkeraError extends Error {
|
|
|
935
859
|
* 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).
|
|
936
860
|
*
|
|
937
861
|
* 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로
|
|
938
|
-
* 실어 나르는 **운반체**다.
|
|
939
|
-
*
|
|
940
|
-
*
|
|
862
|
+
* 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —
|
|
863
|
+
* 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래
|
|
864
|
+
* 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —
|
|
865
|
+
* 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).
|
|
941
866
|
*
|
|
942
867
|
* 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —
|
|
943
868
|
* 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,
|
|
@@ -969,9 +894,17 @@ declare class ZalkeraError extends Error {
|
|
|
969
894
|
* ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.
|
|
970
895
|
* 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인
|
|
971
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`).
|
|
972
905
|
*/
|
|
973
|
-
declare const SECTION_CONTRACT_REV =
|
|
974
|
-
/** 업종 분류 —
|
|
906
|
+
declare const SECTION_CONTRACT_REV = 7;
|
|
907
|
+
/** 업종 분류 — 어휘를 묶어 보여 줄 때의 그룹핑이지 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 쓴다). */
|
|
975
908
|
type SectionVertical = "BEAUTY" | "GENERAL";
|
|
976
909
|
interface SectionSpec {
|
|
977
910
|
readonly type: string;
|
|
@@ -994,7 +927,7 @@ interface SectionSpec {
|
|
|
994
927
|
* 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는
|
|
995
928
|
* 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.
|
|
996
929
|
*
|
|
997
|
-
* **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(
|
|
930
|
+
* **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 그 섹션만 스킵한다).
|
|
998
931
|
*/
|
|
999
932
|
readonly requiredRefs: readonly string[];
|
|
1000
933
|
/**
|
|
@@ -1013,8 +946,8 @@ interface SectionSpec {
|
|
|
1013
946
|
readonly requiredRefsAnyOf: readonly (readonly string[])[];
|
|
1014
947
|
}
|
|
1015
948
|
/**
|
|
1016
|
-
* 아는 섹션 전량(rev
|
|
1017
|
-
* 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
949
|
+
* 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,
|
|
950
|
+
* 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.
|
|
1018
951
|
*
|
|
1019
952
|
* `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이
|
|
1020
953
|
* 그 항목을 통째로 못 읽고 "client 누락"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는
|
|
@@ -1083,11 +1016,77 @@ declare const SECTION_CONTRACT: readonly [{
|
|
|
1083
1016
|
}];
|
|
1084
1017
|
/** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */
|
|
1085
1018
|
type KnownSectionType = (typeof SECTION_CONTRACT)[number]["type"];
|
|
1086
|
-
/** 업종별 필터 —
|
|
1019
|
+
/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */
|
|
1087
1020
|
declare function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[];
|
|
1088
1021
|
|
|
1089
1022
|
declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
1090
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
|
+
|
|
1091
1090
|
/**
|
|
1092
1091
|
* 섹션 config 파싱 — **절대 throw 하지 않는다.**
|
|
1093
1092
|
*
|
|
@@ -1105,12 +1104,13 @@ declare function safeLinkUrl(raw: string | null | undefined): string;
|
|
|
1105
1104
|
*/
|
|
1106
1105
|
declare function parseConfig<T>(config: string | null): T | null;
|
|
1107
1106
|
/**
|
|
1108
|
-
* config 를
|
|
1107
|
+
* config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).
|
|
1109
1108
|
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1109
|
+
* 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는
|
|
1110
|
+
* 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른
|
|
1111
|
+
* 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 로 남긴다.
|
|
1112
|
+
* 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유
|
|
1113
|
+
* 그대로다. 그래서 입구를 하나로 좁힌다.
|
|
1114
1114
|
*
|
|
1115
1115
|
* [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config
|
|
1116
1116
|
* 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).
|
|
@@ -1179,4 +1179,4 @@ interface ParsedTheme {
|
|
|
1179
1179
|
}
|
|
1180
1180
|
declare function parseThemeColors(raw: string | null | undefined): ParsedTheme;
|
|
1181
1181
|
|
|
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
|
|
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 };
|