@zalkera/client 0.21.5 → 0.21.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -329,15 +329,37 @@ function sectionsOfVertical(vertical) {
329
329
 
330
330
  // src/safeUrl.ts
331
331
  var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
332
+ var DUMMY_ORIGIN = "https://zalkera.invalid";
333
+ function internalPath(raw) {
334
+ let out;
335
+ try {
336
+ const parsed = new URL(raw, DUMMY_ORIGIN);
337
+ if (parsed.origin !== DUMMY_ORIGIN) return null;
338
+ out = parsed.pathname + parsed.search + parsed.hash;
339
+ } catch {
340
+ return null;
341
+ }
342
+ try {
343
+ if (new URL(out, DUMMY_ORIGIN).origin !== DUMMY_ORIGIN) return null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ return out;
348
+ }
332
349
  function safeLinkUrl(raw) {
333
350
  if (!raw) return "#";
334
351
  const url = raw.trim();
335
- if (url.startsWith("/") && !url.startsWith("//")) return url;
352
+ if (url.startsWith("#") || url.startsWith("?")) return url;
353
+ if (url.startsWith("/")) return internalPath(url) ?? "#";
336
354
  try {
337
355
  const parsed = new URL(url);
338
356
  return ALLOWED_SCHEMES.has(parsed.protocol) ? url : "#";
339
357
  } catch {
340
- return url.startsWith("//") ? "#" : url;
358
+ try {
359
+ return internalPath(url) ?? "#";
360
+ } catch {
361
+ return "#";
362
+ }
341
363
  }
342
364
  }
343
365
 
@@ -409,13 +431,23 @@ function asHandleArray(value) {
409
431
  if (!Array.isArray(value)) return [];
410
432
  return value.map(asHandle).filter((h) => h !== void 0);
411
433
  }
434
+ var ASSET_DUMMY_ORIGIN = "https://zalkera.invalid";
412
435
  function assetPath(value) {
413
436
  if (typeof value !== "string") return void 0;
414
437
  const path = value.trim();
415
438
  if (!path.startsWith("/") || path.startsWith("//")) return void 0;
416
439
  if (path.includes("\\") || path.includes("\0")) return void 0;
417
440
  if (path.split("/").includes("..")) return void 0;
418
- return path;
441
+ let parsed;
442
+ try {
443
+ parsed = new URL(path, ASSET_DUMMY_ORIGIN);
444
+ } catch {
445
+ return void 0;
446
+ }
447
+ if (parsed.origin !== ASSET_DUMMY_ORIGIN) return void 0;
448
+ const out = parsed.pathname + parsed.search + parsed.hash;
449
+ if (!out.startsWith("/") || out.startsWith("//")) return void 0;
450
+ return out;
419
451
  }
420
452
 
421
453
  // src/theme.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/pathSegment.ts","../src/client.ts","../src/sections.ts","../src/safeUrl.ts","../src/visitorIp.ts","../src/sectionConfig.ts","../src/theme.ts"],"names":["options"],"mappings":"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","file":"index.cjs","sourcesContent":["import type {ApiErrorBody, ValidationError} from \"./types\";\n\n/**\n * zalkera API 호출 실패.\n *\n * 백엔드의 `ErrorResponse` 를 그대로 담는다.\n *\n * **원인 분기는 [code] 로 한다** — 한 엔드포인트가 같은 상태코드로 여러 원인을 낸다(checkout 의 409 =\n * `OUT_OF_STOCK`·`ITEM_NOT_PURCHASABLE`·`IDEMPOTENCY_CONFLICT` — 처방이 전부 다르다). `message` 는\n * 사람용 한국어라 **문자열 매칭 금지**.\n *\n * `status` 는 거친 분류에만:\n * - `400` + [validationErrors] — 입력값 문제(폼 필드별 메시지 노출)\n * - `404` — 없는 slug/리소스\n * - `429` — 레이트리밋(문의·리드 남발, 게스트 주문 인가 실패 누적). [isRateLimited] 로 편히 판별\n * - `5xx` — 서버 오류\n *\n * 네트워크 자체가 실패했거나 응답이 JSON 이 아니면 [status] 가 0 이고 [body] 가 null 이다.\n */\nexport class ZalkeraError extends Error {\n /** HTTP 상태. 네트워크 실패·비JSON 응답이면 0. */\n readonly status: number;\n /**\n * **기계 판독 에러 코드** — `ErrorResponse.errorCode`(백엔드 `ErrorCode` enum 이름).\n * 예: `OUT_OF_STOCK`·`IDEMPOTENCY_CONFLICT`·`CART_NOT_FOUND`. 이걸로 분기한다.\n *\n * 코드를 안 싣는 구버전 백엔드 응답에서는 `error`(HTTP 사유구, \"Conflict\")로 **폴백**한다 —\n * 그때는 원인을 가를 수 없으니, 폴백 값에 의존하는 분기를 짜지 마라.\n * 열린 계약이라 문자열이다(서버가 코드를 추가해도 SDK 릴리스가 필요 없다).\n */\n readonly code: string | null;\n /** 검증 실패 시 필드별 메시지. 그 외엔 빈 배열. */\n readonly validationErrors: ValidationError[];\n /** 파싱된 원본 에러 본문(있으면). */\n readonly body: ApiErrorBody | null;\n\n constructor(\n message: string,\n options: {\n status: number;\n code?: string | null;\n validationErrors?: ValidationError[];\n body?: ApiErrorBody | null;\n cause?: unknown;\n },\n ) {\n super(message, {cause: options.cause});\n this.name = \"ZalkeraError\";\n this.status = options.status;\n this.code = options.code ?? null;\n this.validationErrors = options.validationErrors ?? [];\n this.body = options.body ?? null;\n }\n\n /**\n * 레이트리밋(429) — \"잠시 후 다시\" 를 띄울 때 쓴다.\n *\n * 두 갈래다: ⑴ 문의·리드 폼 남발(IP 축) ⑵ **게스트 주문 인가 실패 누적** — 주문번호+연락처로\n * 여는 주문 조회·취소·구매확정·배송조회·결제세션은 연락처 대입을 막으려고 실패를 센다.\n * 정상 조회는 세지 않으므로, 이 코드가 뜨면 연락처를 여러 번 틀렸거나 같은 주문에 시도가 몰린 것이다.\n */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /**\n * 스토어프론트 시크릿 키 문제(memo78) — 개발자 오배선 신호. `true` 면 `secretKey` 옵션을\n * 점검하라(미설정·불일치·폐기). [code] 로 정밀 분기: `STOREFRONT_KEY_REQUIRED`(401·키 필요)·\n * `TENANT_MISMATCH`(403·키↔tenant 불일치).\n */\n get isStorefrontKeyError(): boolean {\n return this.code === \"STOREFRONT_KEY_REQUIRED\" || this.code === \"TENANT_MISMATCH\";\n }\n\n /** 응답 본문(파싱된 것)으로부터 에러를 만든다. */\n static fromBody(status: number, body: unknown): ZalkeraError {\n if (isApiErrorBody(body)) {\n const code = body.errorCode ?? body.error;\n return new ZalkeraError(\n // 스토어프론트 키 오배선(memo78)은 개발자용 안내로 메시지를 덮는다 — 백엔드는 정보 누출\n // 최소화로 두루뭉술한 401 을 주므로, SDK 가 \"무엇을 고쳐야 하는지\"를 또렷이 알려준다.\n STOREFRONT_KEY_MESSAGES[code as string] ?? body.message,\n {\n // errorCode(기계 판독)를 우선, 없으면 error(HTTP 사유구)로 폴백 — 구버전 백엔드 안전.\n status,\n code,\n validationErrors: body.errors ?? [],\n body,\n },\n );\n }\n // 비JSON 에러 바디(게이트웨이 HTML·빈 바디). 502/503/504 는 상류 장애로 안내 메시지 + 코드 부여.\n const gateway = status === 502 || status === 503 || status === 504;\n return new ZalkeraError(\n gateway\n ? \"서비스에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.\"\n : `요청이 실패했습니다 (HTTP ${status})`,\n {status, code: gateway ? \"UPSTREAM_UNAVAILABLE\" : null},\n );\n }\n}\n\n/**\n * 스토어프론트 시크릿 키(memo78) 관련 에러코드 → 개발자용 안내 메시지. 백엔드 원문 메시지를 이걸로\n * 덮는다(백엔드는 열거 방지로 두루뭉술하게 답한다 — SDK 가 처방을 또렷이 준다).\n */\nconst STOREFRONT_KEY_MESSAGES: Record<string, string> = {\n STOREFRONT_KEY_REQUIRED:\n \"스토어프론트 시크릿 키가 필요합니다. createZalkeraClient 의 secretKey 옵션을 설정하세요 \" +\n \"(파트너 콘솔에서 발급 → 서버 .env 의 ZALKERA_STOREFRONT_KEY). 브라우저 번들에 넣지 마세요.\",\n TENANT_MISMATCH: \"secretKey 가 tenant 옵션과 다른 테넌트의 키입니다. 두 값이 같은 테넌트인지 확인하세요.\",\n};\n\nfunction isApiErrorBody(value: unknown): value is ApiErrorBody {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"message\" in value &&\n typeof (value as {message: unknown}).message === \"string\"\n );\n}\n","/**\n * URL 경로 조각 인코딩 — **경로에 값을 끼우는 유일한 관문**(내부 모듈·공개 export 아님).\n *\n * 이 결손의 본질은 \"URL 경로에 값을 인코딩 없이 보간했다\"이지 \"숫자가 아닌 값이 왔다\"가 아니다.\n * 선언이 `number` 라도 실제 호출자는 스토어프론트 BFF 라우트 핸들러이고, 입력은\n * `Request.json()`(`Promise<any>`)·`params`(문자열)라 **strict 타입검사를 그대로 통과한다.**\n * 인코딩을 빠뜨린 `..` 이 들어오면 `new URL()` 이 경로를 정규화해 `/api` 접두 밖으로 나가고,\n * 그 요청에는 테넌트 시크릿 키(`X-Storefront-Key`)가 붙어 있다.\n *\n * 그래서 처방은 값 검증(정수 강제·throw)이 아니다 — JS 소비자가 `\"42\"` 를 넘기는 정상 사용을 깨고,\n * \"이 id 가 유효한가\"의 판정은 어차피 백엔드 몫이다. 정석은 **모든 보간을 이 함수 하나에 수렴시켜\n * 빠뜨릴 수 없게 만드는 것**이고, `contract.test.ts` 의 소스 불변식 테스트가 그 수렴을 CI 로 강제한다\n * (경로 템플릿의 모든 `${` 가 `${seg(` 로 시작해야 한다).\n *\n * `String(v)` 를 먼저 거치는 이유: 템플릿 보간(`${v}`)은 Symbol 에서 TypeError 를 던지지만\n * `String(v)` 는 던지지 않는다 — 절대 throw 하지 않는 [mediaSrc] 의 계약을 지키기 위해서다.\n */\nexport function seg(v: string | number): string {\n return encodeURIComponent(String(v));\n}\n","import {ZalkeraError} from \"./errors\";\nimport {seg} from \"./pathSegment\";\nimport type {\n ApiResponse,\n AuthTokens,\n AvailabilityParams,\n AvailabilitySlot,\n Booking,\n Cart,\n Category,\n CheckoutInput,\n CreateBookingInput,\n CreateReviewInput,\n ConsentInput,\n ConsentStatus,\n CustomerSummary,\n InquiryCreated,\n InquiryInput,\n LeadCreated,\n LeadInput,\n ListPostsParams,\n ListReviewsParams,\n MediaUrl,\n OrderDetail,\n OrderSummary,\n ListProductsParams,\n Paginated,\n PaymentSession,\n PostDetail,\n PostSummary,\n ProductCategory,\n ProductDetail,\n ProductSummary,\n RatingSummary,\n Review,\n ShipmentInfo,\n SiteConfig,\n SocialLoginInput,\n} from \"./types\";\n\n/**\n * ISR 읽기 옵션 — Next.js 캐시 태그(memo31 §0-1). RSC/ISR 페이지에서 읽기 메서드에 넘기면\n * 그 fetch 에 `next.tags` 가 실려, 백엔드가 `revalidateTag(tag)` 로 온디맨드 무효화할 수 있다.\n * 태그 컨벤션: `site-config`(사이트 설정·테마·레이아웃), `products`(카탈로그), `product:{slug}`(특정 상품).\n * 넘기지 않으면 세그먼트 기본 캐시(페이지의 `revalidate` 주기)만 적용된다 — 하위호환 유지.\n */\nexport interface ReadOptions {\n /** Next ISR 캐시 태그. 이 태그로 백엔드가 온디맨드 revalidate 한다. */\n tags?: string[];\n}\n\nexport interface ZalkeraClientOptions {\n /**\n * 백엔드 베이스 URL — `/api` 접두사는 붙이지 않는다. 예: `http://localhost:8100`.\n * 클라이언트가 경로에 `/api/public/...` 를 붙인다.\n */\n baseUrl: string;\n /**\n * 테넌트 코드. 모든 요청에 `X-Tenant` 헤더로 실린다.\n *\n * 공개 API 는 이 헤더를 그대로 믿는다(비인증). 그래서 이 클라이언트는 **서버 사이드**\n * (RSC·route handler·server action)에서 쓰는 것을 전제로 한다 — 브라우저에서 직접 부르면\n * baseUrl 이 노출되고 CORS 를 열어야 한다. README 참고.\n *\n * [secretKey] 를 함께 주면 백엔드는 키로 테넌트를 결정하고(키가 정본), 이 값은 대조용으로만\n * 쓰인다. 이행기(dual)에는 둘 다 보내도 무방하며 일치해야 한다(불일치 시 403 `TENANT_MISMATCH`).\n */\n tenant: string;\n /**\n * 스토어프론트 서버 시크릿 키(선택·`oqsk_…`). 주면 모든 요청에 `X-Storefront-Key` 헤더로 실려,\n * 백엔드가 이 키로 테넌트 신원을 증명한다(memo78 — [tenant] 무인증 신뢰의 보안 승격).\n *\n * ⚠️ **진짜 비밀이다.** 오직 서버 `.env`(예: `ZALKERA_STOREFRONT_KEY`)에만 두고, 브라우저 번들에\n * 절대 넣지 마라 — `NEXT_PUBLIC_*` 접두사·클라이언트 컴포넌트 import 금지. 이 클라이언트가 서버\n * 전용인 이유가 그것이다. 유출 시 콘솔에서 revoke·재발급.\n *\n * 안 주면 종전대로 [tenant](`X-Tenant`)만으로 동작한다(dual 이행기 하위호환). 백엔드가 `required`\n * 모드인데 키가 없으면 401 `STOREFRONT_KEY_REQUIRED` → [ZalkeraError].\n */\n secretKey?: string;\n /**\n * `fetch` 구현 주입(선택). 기본은 전역 `fetch`(Node 18+·브라우저). 테스트·커스텀 에이전트·\n * Next.js 의 `fetch` 캐시 옵션을 감싸는 래퍼를 넣을 때 쓴다.\n *\n * (내부 `request` 전송부는 이 주입점을 데이터 소스 seam 으로 유지한다 — 미래의 로컬 픽스처(mock)\n * 모드를 갈아엎지 않고 얹기 위한 여지다. mock 구현은 현재 없다 — memo78 §14 후속.)\n */\n fetch?: typeof fetch;\n /** 모든 요청에 추가할 헤더(선택). */\n headers?: Record<string, string>;\n /** 요청 타임아웃(ms). 기본 10초. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * zalkera 공개 API 클라이언트.\n *\n * 테넌트 사이트(credium 등)가 백엔드의 공개 엔드포인트를 타입 안전하게 부르기 위한 얇은 래퍼다.\n * 런타임 의존성이 없다 — 전역 `fetch` 만 쓴다.\n *\n * ```ts\n * const cms = createZalkeraClient({ baseUrl: process.env.API_BASE_URL!, tenant: \"credium\" });\n * const posts = await cms.listPosts({ size: 10, sort: \"publishedAt,desc\" });\n * await cms.submitInquiry({ name, email, subject, message });\n * ```\n *\n * 모든 메서드는 성공 시 envelope 안쪽 `data` 를 돌려주고, 실패 시 [ZalkeraError] 를 던진다.\n */\nexport interface ZalkeraClient {\n /** 회사 정보·테마·SEO 기본값. ISR 페이지는 [ReadOptions.tags] 로 캐시 태그를 실을 수 있다. */\n getSiteConfig(options?: ReadOptions): Promise<SiteConfig>;\n /** 살아 있는 카테고리 전부(페이징 없음). */\n listCategories(): Promise<Category[]>;\n /** 발행된 글 목록(페이징). */\n listPosts(params?: ListPostsParams): Promise<Paginated<PostSummary>>;\n /** slug 로 글 상세(본문 포함). 없으면 404 → [ZalkeraError]. */\n getPost(slug: string): Promise<PostDetail>;\n /**\n * 조회 비콘 — 같은 뷰어의 같은 날 재조회는 집계되지 않는다(반환 false).\n * 화면 렌더를 막지 않도록 fire-and-forget 으로 부르길 권한다(실패해도 페이지는 살아야 한다).\n *\n * 조회 dedup 은 뷰어 IP·UA 를 쓴다. 서버 사이드에서 부를 때는 [RequestContext.clientIp] 로\n * 원 방문자 IP 를 넘겨야 방문자별로 집계된다 — 안 넘기면 전부 서버 IP 하나로 뭉친다.\n * 값은 [visitorIp] 로 뽑아라(첫 홉 직접 추출 금지 — 방문자가 위조할 수 있다).\n */\n recordPostView(slug: string, context?: RequestContext): Promise<boolean>;\n /** 미디어 presigned 다운로드 URL(만료 있음). */\n getMediaUrl(id: number): Promise<MediaUrl>;\n /**\n * 문의 접수. 성공 시 생성된 문의 id. 레이트리밋이면 429 → `error.isRateLimited`.\n *\n * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자\n * IP 를 반드시 넘겨야 한다.** 안 넘기면 백엔드가 테넌트 서버 IP 하나만 보고 몇 건 뒤 **모든 방문자**\n * 를 429 로 막는다.\n *\n * ⚠️ 그 값은 **[visitorIp] 로 뽑아라.** `x-forwarded-for` 의 **첫 홉을 직접 쓰지 마라** — 첫 엔트리는\n * 방문자가 요청에 손으로 실은 값이라 레이트리밋이 한 줄로 우회된다(백엔드는 첫 홉을 쓰지 않는다.\n * 신뢰 프록시 홉 기반으로 채택한다). 보장 경계·홉 수 선언은 [visitorIp] 문서 참조.\n */\n submitInquiry(input: InquiryInput, context?: RequestContext): Promise<InquiryCreated>;\n /**\n * 광고 리드 접수. 문의와 달리 이메일이 선택이고 UTM 추적을 함께 보낸다.\n *\n * ⚠️ [submitInquiry] 와 같은 이유로 서버 사이드에서는 [RequestContext.clientIp] 로 원 방문자\n * IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다. 값은 [visitorIp] 로 뽑는다.\n */\n submitLead(input: LeadInput, context?: RequestContext): Promise<LeadCreated>;\n\n // ── 커머스: 카탈로그(공개) ──────────────────────────────────\n /** slug 로 공개 상품(ACTIVE) 상세 — variant·재고 가용여부 포함. 없으면 404. ISR 태그는 [ReadOptions]. */\n getProduct(slug: string, options?: ReadOptions): Promise<ProductDetail>;\n /**\n * 공개 상품 목록(ACTIVE) — 카드용 요약(최저가·재고). ISR 태그는 [ReadOptions].\n * `params.categoryId` 로 카테고리별 목록을 그린다([ListProductsParams]).\n */\n listProducts(params?: ListProductsParams, options?: ReadOptions): Promise<Paginated<ProductSummary>>;\n /** 커머스 카테고리 목록(노출 순서). ISR 태그는 [ReadOptions]. */\n listProductCategories(options?: ReadOptions): Promise<ProductCategory[]>;\n\n // ── 커머스: 상품후기(공개) ──────────────────────────────────\n /**\n * 상품 후기 목록(공개·VISIBLE 만, 최신순, 페이징).\n *\n * 상품의 **숫자 id(productId)** 로 조회한다 — slug 가 아니다. `getProduct(slug).id` 로 획득한다(0.4.0).\n */\n listProductReviews(\n productId: number,\n params?: ListReviewsParams,\n options?: ReadOptions,\n ): Promise<Paginated<Review>>;\n /** 상품 평점 요약(후기 수·평균 별점). 후기 없으면 count=0·avg=0. 목록 별점 배지에 값싸게 쓴다. */\n getProductReviewSummary(productId: number, options?: ReadOptions): Promise<RatingSummary>;\n /**\n * 후기 작성 — **로그인 필수 + 구매검증**. `input.orderItemId` 는 [OrderItemLine.id](주문 상세에서 획득).\n * 서버가 본인 주문·배송완료(DELIVERED) 이상·상품 일치를 3중 확인한다. 라인당 1개(재작성 시 409 `ALREADY_REVIEWED`).\n */\n createProductReview(productId: number, input: CreateReviewInput, accessToken: string): Promise<Review>;\n\n // ── 커머스: 고객 인증 ───────────────────────────────────────\n /** 소셜 로그인 — authorization code 를 백엔드가 교환하고 고객 토큰을 발급. */\n socialLogin(input: SocialLoginInput): Promise<AuthTokens>;\n /** refresh 토큰으로 새 토큰 회전(옛 refresh 는 폐기). */\n refreshSession(refreshToken: string): Promise<AuthTokens>;\n /** 로그아웃 — 이 고객의 모든 세션 폐기. */\n logout(accessToken: string): Promise<string>;\n /** 내 정보. */\n getMe(accessToken: string): Promise<CustomerSummary>;\n /** 현재 동의 상태 조회(로그인 필수) — 마케팅 수신 토글 화면 등에서 초기값으로 쓴다. */\n getConsents(accessToken: string): Promise<ConsentStatus[]>;\n /**\n * 동의 갱신(로그인 필수, append) — 마케팅 수신 on/off 등. 넘긴 항목만 반영되고 나머지는 유지된다.\n * 성공 시 백엔드 확인 메시지를 돌려준다(최신 상태가 필요하면 [getConsents] 로 다시 읽는다).\n */\n updateConsents(accessToken: string, consents: ConsentInput[]): Promise<string>;\n\n // ── 예약(SERVICE 상품) ──────────────────────────────────────\n /**\n * 예약 가능한 슬롯 조회 — **비로그인 가능**(상품 상세에서 시간표를 보여주는 자리).\n *\n * 미래·잔여정원 있는 OPEN 슬롯만 온다. 가용 판정은 [AvailabilitySlot.availableCount] 로 하고,\n * 카탈로그의 `inStock`/`available` 을 쓰지 마라 — 예약은 슬롯마다 정원이 따로다.\n */\n availability(params: AvailabilityParams, options?: ReadOptions): Promise<AvailabilitySlot[]>;\n /**\n * 예약 생성 — 슬롯 정원을 원자적으로 소비한다. **로그인 필수**(게스트 예약 없음).\n *\n * 무료 예약은 즉시 `CONFIRMED`. **유료·예약금이면 `status=PENDING` + [Booking.orderNo] 가 실려 오고**,\n * 그 orderNo 로 [startPayment] 를 불러 **기존 결제 흐름을 그대로 탄다**(예약 전용 결제 API 는 없다).\n *\n * 정원 소진은 `SLOT_FULL` — 슬롯 목록이 stale 했다는 뜻이니 [availability] 를 다시 읽어 보여줘라.\n */\n createBooking(accessToken: string, input: CreateBookingInput): Promise<Booking>;\n /** 내 예약 목록(최신순). 로그인 필수. */\n myBookings(accessToken: string): Promise<Booking[]>;\n /** 예약 취소 — 시작 전·무료 예약만. 정원이 복원된다. 로그인 필수. */\n cancelBooking(accessToken: string, bookingCode: string): Promise<string>;\n /** 같은 상품의 다른 슬롯으로 이동(신 슬롯 소비·구 슬롯 복원). 로그인 필수. */\n rescheduleBooking(accessToken: string, bookingCode: string, newSlotId: number): Promise<Booking>;\n\n // ── 커머스: 장바구니 ────────────────────────────────────────\n /**\n * 장바구니 조회. 로그인은 accessToken, 게스트는 cartSessionKey 로 식별.\n *\n * ⚠️ **예약(SERVICE) 상품은 장바구니에 담기지 않는다** — [addToCart] 가 400 으로 거부한다.\n * 예약은 [createBooking] 으로 별도 흐름을 탄다(카트에 슬롯을 실으면 체크아웃이 오염된다).\n */\n getCart(session: ShopSession): Promise<Cart>;\n /**\n * 담기(같은 variant 는 수량 누적).\n *\n * ⚠️ **예약(SERVICE) 상품은 400 으로 거부된다** — 예약은 [createBooking] 이다. 상품 상세에서\n * `productType === \"SERVICE\"` 면 담기 버튼 대신 슬롯 선택을 그려라.\n */\n addToCart(variantId: number, quantity: number, session: ShopSession): Promise<Cart>;\n /** 수량 변경. */\n updateCartItem(variantId: number, quantity: number, session: ShopSession): Promise<Cart>;\n /** 항목 삭제. */\n removeFromCart(variantId: number, session: ShopSession): Promise<Cart>;\n /** 비우기. */\n clearCart(session: ShopSession): Promise<Cart>;\n\n // ── 커머스: 결제·주문·배송 ──────────────────────────────────\n /**\n * 결제(주문 생성) — 스냅샷 동결 + 재고 차감. 상태=PENDING_PAYMENT.\n *\n * [idempotencyKey] 를 주면 **같은 키·같은 입력의 재요청이 새 주문을 만들지 않고 원주문을 그대로\n * 반환**한다(더블클릭·네트워크 재시도 안전). 같은 키로 **다른 입력**을 보내면 409.\n *\n * **키는 \"이 체크아웃 1건\"에 고정된 값이어야 한다** — 호출마다 새로 만들면 재시도가 서로 다른\n * 키를 들고 가서 아무것도 막지 못한다(그래서 SDK 가 자동 생성해 주지 않는다). 안정적인 소스를\n * 쓴다: 게스트는 **장바구니 세션 키**(카트 1개 → 주문 1건이라 의미가 정확히 맞는다),\n * 그 외에는 결제 시도 시작 시 한 번 만들어 재시도 동안 보관한 값.\n *\n * 안 주면 종전 동작 — 중복 제출은 카트가 이미 소비돼 404(`CART_NOT_FOUND`)로 떨어진다.\n */\n checkout(input: CheckoutInput, session: ShopSession, idempotencyKey?: string): Promise<OrderDetail>;\n /**\n * 결제 시작. **벤더에 따라 두 갈래**(테넌트가 자기 PG 를 고른다 — 기본 TOSS):\n * `session.widget` 이 있으면 위젯형(내 사이트에서 결제창 → [confirmPayment] 로 승인 확정),\n * 없으면 리다이렉트형(`session.paymentUrl` 로 보내면 끝).\n */\n startPayment(orderNo: string, access: OrderAccess): Promise<PaymentSession>;\n /**\n * **위젯형 벤더의 승인 확정**(토스 등). 결제창이 성공 URL 로 돌려준 파라미터를 그대로 넘긴다\n * (토스: `{paymentKey, orderId, amount}` — URL 쿼리에서 읽어 통째로).\n *\n * **금액을 넘겨도 서버는 안 믿는다** — 서버가 저장한 주문 금액으로 PG 에 직접 묻는다. 그래서\n * 브라우저에서 금액을 조작해도 승인되지 않는다.\n *\n * 이미 승인된 주문(웹훅 선착)이면 조용히 통과한다 — 재호출이 안전하다.\n * 리다이렉트형 벤더에 부르면 404(`PG_CONFIG_NOT_FOUND`).\n */\n confirmPayment(orderNo: string, providerParams: Record<string, string>, access: OrderAccess): Promise<void>;\n /** 주문 조회 — 본인(accessToken) 또는 게스트(phone). 인가 실패가 누적되면 429. */\n getOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 내 주문 목록(로그인 필수). */\n listMyOrders(accessToken: string, params?: {page?: number; size?: number}): Promise<Paginated<OrderSummary>>;\n /** 주문 취소(미결제만) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n cancelOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 구매 확정(배송완료 후) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n completeOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 배송 조회 — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n getShipment(orderNo: string, access: OrderAccess): Promise<ShipmentInfo>;\n}\n\n/**\n * 장바구니·결제의 사용자 식별. 로그인 고객은 [accessToken], 게스트는 [cartSessionKey]\n * (브라우저가 만든 안정적 익명 키)를 넘긴다. 로그인 시 게스트 카트→고객 카트 병합은 백엔드 몫.\n */\nexport interface ShopSession {\n accessToken?: string;\n cartSessionKey?: string;\n}\n\n/**\n * 주문 조회·취소·배송조회의 접근 크리덴셜. 로그인 고객은 [accessToken] 으로 소유권을,\n * 게스트는 주문 시 남긴 [phone] 으로 확인한다(주문번호 + 연락처).\n */\nexport interface OrderAccess {\n accessToken?: string;\n phone?: string;\n /**\n * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자 IP 를\n * 선언**하라. 게스트 주문 인가(주문번호+phone)에는 실패 rate-limit 이 걸려 있는데, 선언이 없으면\n * 백엔드가 보는 IP 가 **테넌트 서버 하나로 뭉쳐** 한 방문자의 실패가 그 사이트 전체를 잠글 수 있다.\n * 주문번호 축은 그와 무관하게 계속 서므로 방어 자체는 유효하다 — 선언은 **오탐을 줄이는 쪽**이다.\n */\n context?: RequestContext;\n}\n\n/**\n * IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.\n *\n * 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가\n * 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 — 전송은 전용 헤더\n * `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된\n * 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로\n * 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).\n */\nexport interface RequestContext {\n /**\n * 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고\n * (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).\n */\n clientIp?: string;\n}\n\nexport function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient {\n const baseUrl = options.baseUrl.replace(/\\/+$/, \"\");\n const fetchImpl = options.fetch ?? globalThis.fetch;\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"@zalkera/client: 전역 fetch 가 없습니다. Node 18+ 를 쓰거나 options.fetch 를 주입하세요.\");\n }\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n async function request<T>(\n path: string,\n init?: {\n method?: string;\n body?: unknown;\n query?: Record<string, unknown>;\n context?: RequestContext;\n /** 고객 access 토큰 → Authorization: Bearer. */\n bearer?: string;\n /** 게스트 장바구니 키 → X-Cart-Session. */\n cartSession?: string;\n /** 멱등키 → Idempotency-Key. 같은 키·같은 본문의 재요청은 원주문을 재생한다. */\n idempotencyKey?: string;\n /** Next.js ISR 캐시 옵션 — RSC/ISR fetch 에 `next.tags` 를 실어 온디맨드 revalidate 대상으로 만든다. */\n next?: {tags?: string[]};\n },\n ): Promise<T> {\n // 경로 조각은 `seg()` 로 인코딩되지만 **점은 인코딩 대상이 아니다** — `\"..\"` 를 넘기면 그대로\n // 한 세그먼트가 되고 `new URL` 이 정규화하며 경로가 한 단 올라간다(`/api` 이탈에는 3단이 필요해\n // 현재 실피해는 없지만, \"경로 파라미터가 경로 구조를 바꾸지 못한다\"는 불변식이 미완이 된다).\n // %2E 치환은 답이 아니다 — WHATWG URL 이 `%2e%2e` 도 dot-segment 로 정규화한다(실측).\n // 그래서 전송 단일점에서 거부한다. 정상 경로에 `.`·`..` 세그먼트는 존재하지 않는다.\n if (/(^|\\/)\\.{1,2}(\\/|$)/.test(path)) {\n throw new ZalkeraError(`경로에 상대 세그먼트가 있습니다: ${path}`, {status: 400, code: \"INVALID_PATH\"});\n }\n const url = new URL(`${baseUrl}/api${path}`);\n if (init?.query) {\n for (const [key, value] of Object.entries(init.query)) {\n if (value != null) url.searchParams.set(key, String(value));\n }\n }\n\n const headers: Record<string, string> = {\n \"X-Tenant\": options.tenant,\n Accept: \"application/json\",\n ...options.headers,\n };\n // 스토어프론트 서버 시크릿(memo78) — 있으면 전 요청에 붙는다. 키가 테넌트 신원의 정본이고\n // X-Tenant 는 대조용으로 함께 간다(dual). 순수 옵션 전달 — 값은 코드에 박히지 않는다.\n if (options.secretKey) headers[\"X-Storefront-Key\"] = options.secretKey;\n if (init?.body != null) headers[\"Content-Type\"] = \"application/json\";\n // 원 방문자 IP 를 백엔드에 전달 — 문의 레이트리밋·조회 dedup·IP 기록이 이걸 본다.\n //\n // **전용 헤더 + XFF 병행 송신.** 종전에는 XFF 하나로만 보냈는데, BFF→백엔드 사이에 로드밸런서가\n // 있으면 그것이 XFF 에 접속원 IP 를 **append** 한다. 그러면 백엔드가 보는 값은\n // `\"<우리가 넘긴 방문자IP>, <테넌트 서버IP>\"` 가 되고, 백엔드의 신뢰 홉 파싱(홉 1)은 **마지막 =\n // 테넌트 서버 IP** 를 취한다 — 문서가 \"반드시 넘겨라\"라고 적은 값을 백엔드가 버렸다(상용 실측).\n // 결함은 백엔드 파싱이 아니라 **선언 값을 위조 가능 채널(XFF)에 실은 전송로**였다.\n //\n // 그래서 선언은 전용 헤더로 옮긴다. 백엔드는 이 헤더를 **유효 스토어프론트 키가 확인된 요청에서만**\n // 승격한다(무키 요청의 선언은 무시 — 무인증 신뢰는 위조를 이름만 바꿔 재도입하는 것이다).\n //\n // XFF 도 **당분간 함께** 보낸다: 로컬 개발(프록시 0단·무키)에서는 지금도 XFF 경로가 실제로 동작\n // 중이라, 끊으면 로컬 계측이 퇴행한다. 병행 중단은 백엔드 승격이 정착한 뒤 다음 minor 다.\n if (init?.context?.clientIp) {\n headers[\"X-Zalkera-Client-Ip\"] = init.context.clientIp;\n headers[\"X-Forwarded-For\"] = init.context.clientIp;\n }\n // 커머스 인증 — 로그인 고객은 Bearer, 게스트 장바구니는 세션 키.\n if (init?.bearer) headers[\"Authorization\"] = `Bearer ${init.bearer}`;\n if (init?.cartSession) headers[\"X-Cart-Session\"] = init.cartSession;\n if (init?.idempotencyKey) headers[\"Idempotency-Key\"] = init.idempotencyKey;\n\n // 타임아웃 — 행 걸린 연결이 페이지 렌더를 무한정 붙들지 않게.\n //\n // ⚠ **읽기에는 `signal` 을 붙이지 않는다.** React 는 **외부 signal 이 달린 fetch 를 요청\n // 메모이제이션에서 제외**한다. 그래서 종전에는 한 렌더 안에서 같은 인자로 부른 호출이\n // 합쳐지지 않고 **백엔드 실히트가 정확히 2배**였다(심의 실측: 상품 상세 8회 → 4회,\n // `/policies` 는 site-config 4회 → 1회). 소비 레포의 주석 8곳이 \"memoization 이 1회로\n // 합친다\"고 적고 있었는데 전부 거짓이었다.\n //\n // 읽기는 대신 `Promise.race` 로 시간을 잰다 — 호출자는 같은 타임아웃 오류를 받고,\n // 다만 밑단 요청이 실제로 취소되지는 않는다(끝나면 버려진다). 렌더를 붙들지 않는다는\n // 목적은 그대로 달성되고, 취소 여부는 읽기에서 관측 가능한 차이를 만들지 않는다.\n //\n // 쓰기(POST·PUT·DELETE)는 종전대로 `signal` 을 붙인다 — 메모이제이션 대상이 아니라\n // 잃을 것이 없고, 중복 전송을 실제로 끊는 편이 안전하다.\n const method = init?.method ?? \"GET\";\n const isRead = method === \"GET\";\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n // ⚠ **읽기일 때만 만든다.** 초판은 무조건 만들어 두고 읽기에서만 `Promise.race` 했는데, 그러면\n // 쓰기에서는 **아무도 이 거절을 구독하지 않아** 성공한 호출마다 `timeoutMs` 뒤에\n // `unhandledRejection: AbortError` 가 뜬다(심의 실측 — 장바구니 POST 1회 = 로그 1건, 결정론).\n // `next start` 아래선 로그 오염이지만, 순수 Node 소비처는 기본이\n // `--unhandled-rejections=throw` 라 **프로세스가 죽는다.** 0.15.0 에는 없던 결함을 0.16.0 이\n // 새로 넣었다 — 읽기 경로만 재고 쓰기 경로를 안 쟀기 때문이다.\n //\n // 타이머도 `finally` 에서 걷는다. 안 걷으면 응답이 먼저 와도 프로세스가 그 시간만큼 붙들린다\n // (`unref` 로 완화되지만 불필요한 대기는 남는다).\n let timedOut = false;\n let deadlineTimer: ReturnType<typeof setTimeout> | undefined;\n const deadline = isRead\n ? new Promise<never>((_, reject) => {\n deadlineTimer = setTimeout(() => {\n timedOut = true;\n reject(new Error(\"AbortError\"));\n }, timeoutMs);\n deadlineTimer.unref?.();\n })\n : undefined;\n\n let response: Response;\n try {\n // Next.js 는 표준 RequestInit 에 비표준 `next`(캐시 태그/주기)를 확장한다. 표준 DOM 타입엔\n // 없으므로 교차 타입으로 선언해 (있을 때만) 실어 보낸다 — 일반 fetch 는 이 필드를 무시한다.\n const fetchInit: RequestInit & {next?: {tags?: string[]}} = {\n method,\n headers,\n body: init?.body != null ? JSON.stringify(init.body) : undefined,\n // 읽기는 위 이유로 signal 을 뺀다(메모이제이션 보존).\n ...(isRead ? {} : {signal: controller.signal}),\n // 리다이렉트를 따라가지 않는다 — **보안 불변식이라 스위치를 두지 않는다**(옵션으로 열면\n // 그 옵션이 곧 사고 경로가 된다). 기본값 `follow` 로 크로스오리진 3xx 를 따라가면\n // `X-Storefront-Key`(테넌트 시크릿)·`X-Tenant`·`X-Cart-Session`·`Idempotency-Key` 가\n // 제3자 오리진에 그대로 전달된다 — undici 는 커스텀 헤더를 벗기지 않는다(실측).\n // `\"error\"` 를 쓰지 않는 이유: undici 에서 일반 `TypeError: fetch failed` 로 떨어져\n // 아래 네트워크 분기에 흡수되고, 그러면 원인(대개 baseUrl 오배선)이 사라진다.\n redirect: \"manual\",\n };\n if (init?.next) fetchInit.next = init.next;\n const call = fetchImpl(url.toString(), fetchInit);\n response = deadline ? await Promise.race([call, deadline]) : await call;\n } catch (cause) {\n const aborted = timedOut || (cause instanceof Error && cause.name === \"AbortError\");\n throw new ZalkeraError(\n aborted ? `요청 시간이 초과되었습니다 (${timeoutMs}ms)` : \"네트워크 요청이 실패했습니다.\",\n {status: 0, cause},\n );\n } finally {\n clearTimeout(timer);\n if (deadlineTimer) clearTimeout(deadlineTimer);\n }\n\n // 리다이렉트는 여기서 끊는다 — `redirect: \"manual\"` 로 받았으니 따라간 적이 없다.\n // 본문·상태를 읽기 **전에** 갈라야 한다(3xx 는 !ok 라 그냥 두면 fromBody 가 삼켜, 왜 실패했는지가\n // 백엔드 에러와 구분되지 않는다). 상류 오염을 502 + 전용 code 로 알리는 `UPSTREAM_NON_JSON` 과 같은 사상.\n if (isRedirectResponse(response)) {\n // 본문은 볼 것이 없다 — 소켓이 매달리지 않게만 정리한다.\n response.body?.cancel().catch(() => {});\n throw new ZalkeraError(\n `백엔드가 리다이렉트로 응답했습니다 (HTTP ${response.status}). 보안상 따라가지 않습니다 — ` +\n \"따라가면 요청 헤더(테넌트 시크릿 키·장바구니 세션)가 다른 오리진으로 샙니다. \" +\n \"baseUrl 이 최종 오리진인지 확인하세요(http↔https 승격, www 유무, 프록시의 경로 리라이트).\",\n {status: 502, code: \"UPSTREAM_REDIRECT\"},\n );\n }\n\n // 204 등 본문 없는 응답은 파싱하지 않는다.\n const text = await response.text();\n const parsed = text ? safeJsonParse(text) : null;\n\n if (!response.ok) {\n throw ZalkeraError.fromBody(response.status, parsed);\n }\n // 빈 바디(204·void 엔드포인트 예: confirmPayment)는 정상 — data 접근 없이 통과.\n if (!text) return undefined as T;\n // 성공(2xx)인데 envelope 가 아니면(HTML·비객체 JSON) 게이트웨이/프록시 오염이다.\n // (null).data TypeError 로 새지 않게 구조화 에러로 변환 — BFF errorResponse 가 502 로 서빙(memo 57 §11-4).\n if (parsed === null || typeof parsed !== \"object\") {\n throw new ZalkeraError(\n `서버 응답 형식이 올바르지 않습니다. 잠시 후 다시 시도해주세요. (HTTP ${response.status})`,\n {status: 502, code: \"UPSTREAM_NON_JSON\"},\n );\n }\n return (parsed as ApiResponse<T>).data;\n }\n\n return {\n getSiteConfig: (options) => request<SiteConfig>(\"/public/site-config\", nextInit(options)),\n listCategories: () => request<Category[]>(\"/public/categories\"),\n listPosts: (params) =>\n request<Paginated<PostSummary>>(\"/public/posts\", {\n query: {\n category: params?.category,\n page: params?.page,\n size: params?.size,\n sort: params?.sort,\n },\n }),\n getPost: (slug) => request<PostDetail>(`/public/posts/${seg(slug)}`),\n recordPostView: (slug, context) =>\n request<boolean>(`/public/posts/${seg(slug)}/view`, {\n method: \"POST\",\n context,\n }),\n getMediaUrl: (id) => request<MediaUrl>(`/public/media/${seg(id)}/url`),\n submitInquiry: (input, context) =>\n request<InquiryCreated>(\"/public/inquiries\", {\n method: \"POST\",\n body: input,\n context,\n }),\n submitLead: (input, context) =>\n request<LeadCreated>(\"/public/leads\", {\n method: \"POST\",\n body: input,\n context,\n }),\n\n // ── 커머스: 카탈로그 ────────────────────────────────────\n getProduct: (slug, options) => request<ProductDetail>(`/public/products/${seg(slug)}`, nextInit(options)),\n listProducts: (params, options) =>\n request<Paginated<ProductSummary>>(\"/public/products\", {\n query: {\n productType: params?.productType,\n keyword: params?.keyword,\n categoryId: params?.categoryId,\n page: params?.page,\n size: params?.size,\n sort: params?.sort,\n },\n ...nextInit(options),\n }),\n listProductCategories: (options) => request<ProductCategory[]>(\"/public/product-categories\", nextInit(options)),\n\n // ── 커머스: 상품후기(공개) ──────────────────────────────\n listProductReviews: (productId, params, options) =>\n request<Paginated<Review>>(`/public/products/${seg(productId)}/reviews`, {\n query: {page: params?.page, size: params?.size},\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n getProductReviewSummary: (productId, options) =>\n request<RatingSummary>(`/public/products/${seg(productId)}/reviews/summary`, {\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n createProductReview: (productId, input, accessToken) =>\n request<Review>(`/shop/products/${seg(productId)}/reviews`, {\n method: \"POST\",\n body: input,\n bearer: accessToken,\n }),\n\n // ── 커머스: 고객 인증 ───────────────────────────────────\n socialLogin: (input) => request<AuthTokens>(\"/shop/auth/social\", {method: \"POST\", body: input}),\n refreshSession: (refreshToken) =>\n request<AuthTokens>(\"/shop/auth/refresh\", {method: \"POST\", body: {refreshToken}}),\n logout: (accessToken) => request<string>(\"/shop/auth/logout\", {method: \"POST\", bearer: accessToken}),\n getMe: (accessToken) => request<CustomerSummary>(\"/shop/me\", {bearer: accessToken}),\n getConsents: (accessToken) => request<ConsentStatus[]>(\"/shop/consents\", {bearer: accessToken}),\n updateConsents: (accessToken, consents) =>\n request<string>(\"/shop/consents\", {method: \"POST\", bearer: accessToken, body: {consents}}),\n\n // ── 예약(SERVICE 상품) ───────────────────────────────────\n availability: (params, options) =>\n request<AvailabilitySlot[]>(\"/public/booking/availability\", {\n query: {productId: params.productId, from: params.from, to: params.to},\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n createBooking: (accessToken, input) =>\n request<Booking>(\"/shop/booking/bookings\", {method: \"POST\", body: input, bearer: accessToken}),\n myBookings: (accessToken) => request<Booking[]>(\"/shop/booking/bookings\", {bearer: accessToken}),\n cancelBooking: (accessToken, bookingCode) =>\n request<string>(`/shop/booking/bookings/${seg(bookingCode)}/cancel`, {\n method: \"POST\",\n bearer: accessToken,\n }),\n rescheduleBooking: (accessToken, bookingCode, newSlotId) =>\n request<Booking>(`/shop/booking/bookings/${seg(bookingCode)}/reschedule`, {\n method: \"POST\",\n body: {newSlotId},\n bearer: accessToken,\n }),\n\n // ── 커머스: 장바구니 ────────────────────────────────────\n getCart: (session) => request<Cart>(\"/shop/cart\", shopInit(session)),\n addToCart: (variantId, quantity, session) =>\n request<Cart>(\"/shop/cart/items\", shopInit(session, {method: \"POST\", body: {variantId, quantity}})),\n updateCartItem: (variantId, quantity, session) =>\n request<Cart>(`/shop/cart/items/${seg(variantId)}`, shopInit(session, {method: \"PATCH\", body: {quantity}})),\n removeFromCart: (variantId, session) =>\n request<Cart>(`/shop/cart/items/${seg(variantId)}`, shopInit(session, {method: \"DELETE\"})),\n clearCart: (session) => request<Cart>(\"/shop/cart\", shopInit(session, {method: \"DELETE\"})),\n\n // ── 커머스: 결제·주문·배송 ──────────────────────────────\n checkout: (input, session, idempotencyKey) =>\n request<OrderDetail>(\"/shop/checkout\", shopInit(session, {method: \"POST\", body: input, idempotencyKey})),\n confirmPayment: (orderNo, providerParams, access) =>\n request<void>(\n `/shop/orders/${seg(orderNo)}/payment/confirm`,\n accessInit(access, {method: \"POST\", body: providerParams}),\n ),\n startPayment: (orderNo, access) =>\n request<PaymentSession>(\n `/shop/orders/${seg(orderNo)}/payment/session`,\n accessInit(access, {method: \"POST\"}),\n ),\n getOrder: (orderNo, access) => request<OrderDetail>(`/shop/orders/${seg(orderNo)}`, accessInit(access)),\n listMyOrders: (accessToken, params) =>\n request<Paginated<OrderSummary>>(\"/shop/orders\", {\n bearer: accessToken,\n query: {page: params?.page, size: params?.size},\n }),\n cancelOrder: (orderNo, access) =>\n request<OrderDetail>(`/shop/orders/${seg(orderNo)}/cancel`, accessInit(access, {method: \"POST\"})),\n completeOrder: (orderNo, access) =>\n request<OrderDetail>(`/shop/orders/${seg(orderNo)}/complete`, accessInit(access, {method: \"POST\"})),\n getShipment: (orderNo, access) =>\n request<ShipmentInfo>(`/shop/orders/${seg(orderNo)}/shipment`, accessInit(access)),\n };\n}\n\n/** [ReadOptions] 를 request init 의 Next 캐시 옵션으로 변환. 태그 없으면 빈 객체(하위호환). */\nfunction nextInit(options?: ReadOptions): {next?: {tags?: string[]}} {\n return options?.tags && options.tags.length > 0 ? {next: {tags: options.tags}} : {};\n}\n\n/** 장바구니·결제 요청의 사용자 식별을 request init 으로 변환. */\nfunction shopInit(\n session: ShopSession,\n extra?: {method?: string; body?: unknown; idempotencyKey?: string},\n): {method?: string; body?: unknown; bearer?: string; cartSession?: string; idempotencyKey?: string} {\n return {...extra, bearer: session.accessToken, cartSession: session.cartSessionKey};\n}\n\n/** 주문 접근(본인 토큰 또는 게스트 phone)을 request init 으로 변환. */\nfunction accessInit(\n access: OrderAccess,\n extra?: {method?: string; body?: unknown},\n): {\n method?: string;\n body?: unknown;\n bearer?: string;\n query?: Record<string, unknown>;\n context?: RequestContext;\n} {\n return {\n ...extra,\n bearer: access.accessToken,\n query: access.phone ? {phone: access.phone} : undefined,\n context: access.context,\n };\n}\n\n/**\n * 리다이렉트 응답 판별 — `redirect: \"manual\"` 의 결과 형상이 **런타임마다 다르다**.\n *\n * - Node(undici, v22 실측): 3xx 응답이 **그대로** 온다 — `status` 302·`type` `\"basic\"`·`ok` false.\n * 301·303·307·308 도 동일하다.\n * - 명세 준수 런타임(브라우저 등): opaque-redirect 필터 응답 — `status` 0·`type` `\"opaqueredirect\"`.\n *\n * 어느 쪽으로 와도 잡아야 보안 불변식이 성립하므로 둘 다 본다.\n */\nfunction isRedirectResponse(response: Response): boolean {\n return response.type === \"opaqueredirect\" || (response.status >= 300 && response.status < 400);\n}\n\nfunction safeJsonParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return null;\n }\n}\n","/**\n * 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).\n *\n * 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로\n * 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —\n * 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래\n * 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —\n * 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).\n *\n * 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —\n * 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,\n * client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.\n */\n\n/**\n * 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다.\n *\n * rev 4 = **참조 방언·콘텐츠 파일의 1급 승격**(memo129 §1.4). 섹션 타입 12종도 config 키 선언도\n * 안 바뀌었다 — 정본에 `dialects`(id ↔ 참조)와 `contentFile`(`content/pages/*.json`) 절이 생겼고,\n * 이 패키지는 그 방언을 읽는 헬퍼([asHandle]·[asHandleArray]·[assetPath]·[readConfig])를 실어 나른다.\n * 그래서 아래 `SECTION_CONTRACT` 리터럴은 rev 3 과 **바이트 동일**하다(동기 스크립트가 확인한다).\n *\n * rev 5 = **`categorySlug` 를 대등 참조로**(memo139). `SERVICE_MENU`·`BOOKING_CTA` 의 필수성 단위가\n * \"productIds 가 있는가\"에서 **\"참조가 하나라도 있는가\"**(`requiredRefsAnyOf`)로 옮겨갔다. 그 시대의\n * 판단이었고 rev 6 이 뒤집었다(아래).\n *\n * rev 6 = **`SERVICE_MENU`·`BOOKING_CTA` 완전 삭제 — 12종 → 10종**(memo142 §오너확정2-1). rev 3·5 가\n * \"조회형 섹션은 참조를 반드시 실어라\"로 조이던 잣대가 **\"조회형 섹션을 싣지 마라\"로 반전**됐다.\n * 경계 규칙(memo142 §1): 값이 콘텐츠 파일에 사는 저작물은 **선언 섹션**의 소관이고, 값이 업무 DB 에\n * 살고 화면이 비추기만 하는 조회는 **소스가 이 패키지를 직접 호출**해 그린다(`listProducts()`·\n * `listProductCategories()`). 절반 선언(`SERVICE_MENU`)은 \"어디에\"만 선언에 두고 \"어떻게\"(카드 그리드·\n * 필드·개수)를 공유 렌더러에 얼려 두는 형태였고, 그것이 자연어로 다양한 디자인을 만든다는 방향과 반대다.\n *\n * **`retired` 표기가 아니라 삭제**인 이유: 실측상 정당한 잔존 소비자가 0이었고(상용 `page_section`·\n * `product`·`product_category` 전부 0행), memo128 이 이미 `page_section` 계열을 퇴역 방향으로 잡아 뒀다.\n * 제3 상태는 계약·검사기·팩 게이트·콘솔이 각자 해석해야 하는 축을 새로 만든다.\n *\n * ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.\n * 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인\n * 팩**에만 선다.\n *\n * rev 7 = **DB 방언 소거 — 거처가 하나 남았다**(memo144). `page`·`page_section`·`menu` 계열이 퇴역하면서\n * 정본의 `dialects.id`(숫자 id 표기)가 가리킬 자리가 없어졌다. rev 4 가 방언을 1급으로 승격하며 rev 를\n * 올렸던 것의 **역연산**이라 서술 정리가 아니라 잣대 변경이다. **아래 리터럴은 rev 6 과 바이트 동일**이다\n * — 섹션 10종·`requiredRefs(AnyOf)` 는 한 글자도 안 바뀐다(동기 스크립트가 확인한다). 이 패키지에서\n * 함께 내려간 것은 그 거처를 읽던 표면이다: `getPage`·`listPages`·`listMenus` 와 그 타입들.\n * 남은 표기는 소스 하나 — `content/pages/*.json` 이 쓰는 참조 표기(`asset` 계열 문자열)이고,\n * 어휘 표의 `assetId` 계열 키 이름은 그 시절 표기가 굳은 것이다(대응은 정본 `dialects.reference`).\n */\nexport const SECTION_CONTRACT_REV = 7;\n\n/** 업종 분류 — 어휘를 묶어 보여 줄 때의 그룹핑이지 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 쓴다). */\nexport type SectionVertical = \"BEAUTY\" | \"GENERAL\";\n\nexport interface SectionSpec {\n readonly type: string;\n readonly vertical: SectionVertical;\n /** 이 섹션이 산출하는 schema.org 타입. null 이면 구조화 데이터 없음. */\n readonly jsonLd: string | null;\n /**\n * **필수 참조 config 키**(정본 `config` 선언에서 `?` 가 없는 `*Id`/`*Ids` 키 — memo119 §2.6-3·rev 3).\n *\n * 계약 전체를 실어 나르지 않고 이 축만 뽑아 오는 이유: 이 값을 읽는 소비자가 **팩 게이트 하나**이고,\n * 그가 답해야 하는 질문이 정확히 \"이 섹션이 아무것도 안 가리킨 채 시드에 들어와 있는가\"이기 때문이다.\n * 아무것도 안 가리킨 참조형 섹션은 렌더러가 `return null` 해서 **개시 직후 조용히 사라지는 섹션**이\n * 된다 — 그 결함이 고객 개시 순간이 아니라 우리 터미널에서 죽게 하는 것이 이 필드의 전부다.\n *\n * ⚠ **rev 6 기준 이 축을 쓰는 타입은 0 이다.** 그 요구를 갖던 둘이 어휘에서 삭제됐기 때문이다.\n * 필드를 남겨 두는 것은 계약 기계를 유지하기 위해서다 — 참조가 필수인 **저작물** 타입이 앞으로\n * 생길 수 있고(에셋 축), 팩 게이트가 이 선언을 읽는 코드도 그대로 선다. 다만 **조회형 타입의 증설로**\n * 이 축이 되살아나는 일은 없다(memo142 §6-2 가 그 문을 닫았다).\n *\n * 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는\n * 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.\n *\n * **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 그 섹션만 스킵한다).\n */\n readonly requiredRefs: readonly string[];\n /**\n * **참조 그룹**(rev 5) — 각 그룹에서 **하나 이상**이 채워져야 한다.\n *\n * rev 3 은 필수성을 키 하나(`productIds`)에 걸었는데, rev 5 에서 갈래 참조(`categorySlug`)가 같은\n * 역할을 하게 되면서 필수의 단위가 **\"참조 존재\"**로 옮겨갔다. 막으려는 것은 그대로다 — 아무것도\n * 안 가리킨 채 시드에 들어와 개시 직후 조용히 사라지는 섹션.\n *\n * ⚠ `requiredRefs` 와 같이 **rev 6 기준 이 축을 쓰는 타입도 0 이다**(그 둘이 삭제됐다). 남기는\n * 이유도 같다 — 계약 기계는 유지하고, 되살릴 문은 memo142 §6-2 가 닫았다.\n *\n * `requiredRefs`(무조건 필수)와 **함께** 쓴다: 그룹으로 표현되는 섹션은 `requiredRefs` 가 빈 배열이고,\n * 종전처럼 단일 키가 무조건 필수인 섹션은 이 필드가 빈 배열이다. 둘 다 빈 배열이면 참조 요구가 없다.\n */\n readonly requiredRefsAnyOf: readonly (readonly string[])[];\n}\n\n/**\n * 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,\n * 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.\n *\n * `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이\n * 그 항목을 통째로 못 읽고 \"client 누락\"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는\n * 길이 열린다 — 빠뜨림이 침묵이 아니라 실패가 되는 형상을 고른다.\n */\nexport const SECTION_CONTRACT = [\n // 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).\n // 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는\n // 진열이 낸다(보장표 `service-menu-itemlist` 의 route 는 원래 `any` — 판정 지점은 산출물이다).\n {type: \"BEFORE_AFTER_GALLERY\", vertical: \"BEAUTY\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"DOCTOR_INTRO\", vertical: \"BEAUTY\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n // 기업 마케팅(memo102 §2)\n {type: \"HERO\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"FEATURE_GRID\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"TEXT_MEDIA\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"LOGO_WALL\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"STATS_BAND\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"TESTIMONIALS\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"FAQ_LIST\", vertical: \"GENERAL\", jsonLd: \"FAQPage\", requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"LEAD_CTA\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n] as const satisfies readonly SectionSpec[];\n\n/** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */\nexport type KnownSectionType = (typeof SECTION_CONTRACT)[number][\"type\"];\n\n/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */\nexport function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[] {\n return SECTION_CONTRACT.filter((s) => s.vertical === vertical);\n}\n","/**\n * 사용자·콘솔 입력 URL 을 링크로 쓰기 전 소독한다 — **저장형 XSS 방어**.\n *\n * 콘솔 메뉴 url·섹션 config 의 href 는 사람이 입력하는 값이라 `javascript:alert(document.cookie)` 가\n * 들어올 수 있고, 백엔드는 스킴을 검증하지 않는다(길이·trim 뿐). react-dom 이 prod 번들에서\n * `javascript:` href 를 막긴 하지만 **dev 에서는 raw 로 실리고**, 프레임워크 내부 동작에 보안을\n * 의존하는 것 자체가 취약하다. 그래서 이건 **심층 방어**다 — 렌더 소스에서 스킴을 거른다.\n *\n * 허용: 내부 절대경로(`/about`)·http(s)·mailto·tel. 그 외(javascript:·data:·vbscript: 등)는 `#` 으로\n * 무력화한다(링크는 남되 스크립트는 안 돈다). 메뉴는 외부 링크가 정당하므로 내부로만 좁히지 않고\n * 스킴 허용목록을 쓴다.\n */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"]);\n\nexport function safeLinkUrl(raw: string | null | undefined): string {\n if (!raw) return \"#\";\n const url = raw.trim();\n // 내부 절대경로 — 스킴이 없다. `//host`(스킴 상대)는 외부라 파서로 넘겨 검증한다.\n if (url.startsWith(\"/\") && !url.startsWith(\"//\")) return url;\n try {\n const parsed = new URL(url); // 절대 URL 만 파싱된다(상대면 throw)\n return ALLOWED_SCHEMES.has(parsed.protocol) ? url : \"#\";\n } catch {\n // 파싱 실패 = 스킴 없는 상대경로(anchor#·?query 등) — 내부로 간주해 통과.\n return url.startsWith(\"//\") ? \"#\" : url;\n }\n}\n","/**\n * `X-Forwarded-For` 에서 **원 방문자 IP** 를 뽑는다. 백엔드 `ClientUtils.resolveClientIp` 의 **계약 거울**이다\n * (같은 입력에 같은 값 — 테스트 벡터를 백엔드에서 그대로 이식해 드리프트를 막는다).\n *\n * ```ts\n * // route handler 안\n * import {visitorIp} from \"@zalkera/client\";\n * const ip = visitorIp(req.headers); // 프록시 1단(기본)\n * await zalkera.submitInquiry(input, {clientIp: ip});\n * ```\n *\n * ## 왜 이 함수가 있는가 — `xff.split(\",\")[0]` 는 위조된다\n *\n * `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가\n * 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 프록시는 그 뒤에 진짜 IP 를 붙이므로 헤더는\n * `9.9.9.9, <진짜IP>` 가 된다 — **첫 엔트리는 공격자가 쓴 문자열**이다. 첫 홉을 채택하는 코드는 그래서\n * 레이트리밋이 한 줄로 우회되고(요청마다 IP 를 바꾸면 버킷이 매번 새로 생긴다) IP 기록이 오염된다.\n *\n * 옳은 채택 지점은 **우리가 통제하는 프록시들이 붙인 블록의 가장 바깥(왼쪽) 엔트리** = 그 방문자가 우리\n * 최외곽 프록시에 연결할 때 쓴 IP 다. 인덱스로는 `길이 - 신뢰홉수`.\n *\n * ## 보장 경계 — **여기까지만 참이다**\n *\n * 이 함수는 \"부르기만 하면 안전\"을 팔지 않는다. 파는 것은 **\"선언한 홉 수가 참인 만큼 안전\"** 이다.\n * 자유 변수는 정수 하나([VisitorIpOptions.trustedHops])이고, 그 값이 틀리면 결과도 틀린다:\n *\n * | 선언 vs 실제 | 채택되는 값 | 결과 | 드러남 |\n * |---|---|---|---|\n * | 선언 **<** 실제(과소) | 안쪽 인프라 IP(CDN 엣지 등) | 방문자 전원이 한 IP 로 뭉침 → 429 폭주 | **가시** — 즉시 눈에 밟힌다 |\n * | 선언 **=** 실제 | 방문자 IP | 정상 | — |\n * | 선언 **>** 실제(과대) | **공격자가 넣은 임의 엔트리** | 위조 관통 | **비가시 — 조용히 뚫린다** |\n *\n * 그래서 규율은 하나다: **선언은 실제 이하로만.** 기본값 1 은 \"항상 안전\"이 아니라 **\"위험한 방향으로는\n * 기본값이 데려가지 않는\"** 값이다 — 과대 선언에는 당신이 직접 큰 수를 적어야만 도달한다.\n *\n * **지원하지 않는 배포**: 리버스 프록시 **0단 직노출**(Node 를 인터넷에 직접 붙인 형태). 거기서는\n * `X-Forwarded-For` **전체가** 방문자가 쓴 값이라 어떤 홉 수를 넣어도 이 함수는 위조를 돌려준다.\n * 그 배포에서는 이 함수를 쓰지 말고 소켓 IP 를 플랫폼 수단으로 직접 얻어라 — 이 함수는 그 경우를\n * 흡수하는 척하지 않는다.\n *\n * **우리가 모르는 것**: 당신의 프록시 단 수. 그래서 묻는다(옵션·env). 자동 감지는 **하지 않는다** —\n * 결정적 신호가 없고(`CF-Connecting-IP` 존재도 신호가 못 된다: CF 밖에서 위조 가능), 추정은 과대 선언과\n * 같은 위험이다. `x-real-ip` 폴백도 **하지 않는다** — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.\n *\n * **잘커라가 서빙하는 사이트라면** 이 값을 고민할 필요가 없다. 서빙 프록시가 방문자 IP **단일 엔트리**로\n * `X-Forwarded-For` 를 재작성하므로 \"신뢰 홉 = 1\" 이 구성상 참이고, 그것이 이 함수의 기본값이다.\n *\n * @param headers `Headers` 또는 `NextRequest.headers` — `get(name)` 하나만 쓴다.\n * @param options 홉 수 명시. 우선순위: 명시 > env `ZALKERA_TRUSTED_PROXY_HOPS` > 기본 1.\n * @returns 원 방문자 IP. 정할 수 없으면 `undefined`(문자열 `\"unknown\"` 을 지어내지 않는다) —\n * 그대로 `RequestContext.clientIp` 에 넣으면 되고, 클라이언트가 헤더를 생략한다.\n */\nexport function visitorIp(headers: HeaderReader, options?: VisitorIpOptions): string | undefined {\n const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;\n if (hops <= 0) return undefined; // XFF 를 아예 신뢰하지 않겠다는 선언 — 백엔드의 `trustedHops <= 0` 과 같은 뜻.\n const raw = headers?.get?.(\"x-forwarded-for\");\n if (raw == null || raw.trim() === \"\") return undefined;\n const parts = raw\n .split(\",\")\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n if (parts.length === 0) return undefined;\n // 백엔드와 **같은 클램프**: 엔트리가 홉 수보다 적으면(설정 과다 또는 직결 정황) 최좌단으로 접는다.\n const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);\n return parts[index] || undefined;\n}\n\n/** `get(name)` 만 요구한다 — `Headers`·`NextRequest.headers`·직접 만든 객체가 전부 들어맞는다. */\nexport interface HeaderReader {\n get(name: string): string | null;\n}\n\nexport interface VisitorIpOptions {\n /**\n * 우리가(=당신이) 통제해 `X-Forwarded-For` 를 append 하는 프록시 **단 수**. 기본 1.\n * 예: nginx 하나=1 · CDN+로드밸런서=2. **실제보다 크게 적지 마라** — 위 표의 '과대' 행이 조용한 구멍이다.\n * `0` 이하를 주면 XFF 를 신뢰하지 않겠다는 뜻이라 항상 `undefined` 를 돌려준다.\n */\n trustedHops?: number;\n}\n\n/** 프록시 1단. 의미 있는 최솟값이고, 틀리는 방향이 **가시적 실패** 쪽이다. */\nconst DEFAULT_TRUSTED_HOPS = 1;\n\n/** 홉 수 선언 환경변수. 서버 전용 값이라 `NEXT_PUBLIC_` 접두를 붙이지 마라(브라우저 번들에 박힌다). */\nconst HOPS_ENV = \"ZALKERA_TRUSTED_PROXY_HOPS\";\n\nlet envWarned = false;\n\n/**\n * env 선언 읽기. **못 읽는 값은 조용히 삼키지 않는다** — 오타 하나로 선언이 무시되면 사용자는 2를 적어\n * 놓고 1로 도는 상태를 모른 채 산다(그 방향은 '과소'라 429 로 드러나지만, 원인을 찾는 데 시간이 든다).\n * 한 번만 경고하고 기본값으로 간다.\n */\nfunction hopsFromEnv(): number | undefined {\n const raw = typeof process !== \"undefined\" ? process.env?.[HOPS_ENV] : undefined;\n if (raw == null || raw.trim() === \"\") return undefined;\n const parsed = Number(raw);\n if (Number.isInteger(parsed)) return parsed;\n if (!envWarned) {\n envWarned = true;\n console.warn(\n `@zalkera/client: ${HOPS_ENV}=\"${raw}\" 는 정수가 아닙니다 — 기본값 ${DEFAULT_TRUSTED_HOPS} 로 진행합니다.`,\n );\n }\n return undefined;\n}\n","import {seg} from \"./pathSegment\";\n\n/**\n * 섹션 config 파싱 — **절대 throw 하지 않는다.**\n *\n * config 는 백엔드가 검증하지 않는 raw JSON 이다(의도된 설계 — 사이트마다 백엔드를 배포하지\n * 않으려고). 게다가 콘솔의 raw 편집기는 **JSON 문법만** 보고 저장을 허용한다 — 즉 문법은\n * 맞는데 형상이 다른 config 가 **정상 경로로** 들어온다. `{\"productIds\": 5}` 같은 것.\n *\n * 그래서 파싱이 성공했다고 필드를 믿으면 안 된다. `.map`·`.trim` 을 그냥 부르면 그 순간\n * **페이지 전체가 500** 이 난다 — 계약은 \"그 섹션만 사라진다\" 였다. 아래 가드들이 그 계약을\n * 지키는 장치다. [parseThemeColors] 와 같은 사상.\n *\n * **이 패키지에 사는 이유**(memo108 §1): 이건 표현이 아니라 **계약을 안전하게 읽는 법**이다. 마크업은\n * 만드는 쪽 자유지만, '섹션 하나가 사이트를 죽이지 않는다'는 계약은 한 벌이어야 한다 — 사본이 갈라지면\n * 그 계약이 조용히 깨진다(471946c 교훈).\n */\nexport function parseConfig<T>(config: string | null): T | null {\n if (!config) return null;\n try {\n const parsed: unknown = JSON.parse(config);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed) ? (parsed as T) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).\n *\n * 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는\n * 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른\n * 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 로 남긴다.\n * 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유\n * 그대로다. 그래서 입구를 하나로 좁힌다.\n *\n * [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config\n * 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).\n */\nexport function readConfig<T>(config: unknown): T | null {\n if (typeof config === \"string\") return parseConfig<T>(config);\n return config != null && typeof config === \"object\" && !Array.isArray(config) ? (config as T) : null;\n}\n\n/** 양의 정수 id 만 남긴다 — 배열이 아니면 빈 배열. */\nexport function asIdArray(value: unknown): number[] {\n if (!Array.isArray(value)) return [];\n return value.filter((v): v is number => typeof v === \"number\" && Number.isInteger(v) && v > 0);\n}\n\n/** 객체 배열만 — 아니면 빈 배열. */\nexport function asObjectArray(value: unknown): Record<string, unknown>[] {\n if (!Array.isArray(value)) return [];\n return value.filter((v): v is Record<string, unknown> => v != null && typeof v === \"object\" && !Array.isArray(v));\n}\n\n/** 문자열이 아니면 undefined — 숫자에 `.trim()` 을 부르는 사고를 막는다. */\nexport function asString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 양의 정수가 아니면 undefined. */\nexport function asId(value: unknown): number | undefined {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).\n *\n * 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config\n * 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.\n * **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.\n */\nexport function mediaSrc(assetId: number): string {\n return `/media/${seg(assetId)}`;\n}\n\n/*\n * ── 참조 방언 소비(rev 4 · memo129 §1.1) ────────────────────────────────────\n *\n * 소스가 사이트의 얼굴을 정본으로 가지므로(`zalkera.content = \"source\"`) config 는 숫자 id 를 쓸 수 없다 —\n * id 는 백엔드가 발급하고 테넌트마다 다른데, 소스는 고객이 소유하고 그대로 다른 곳에 재업로드된다.\n * 그래서 섹션 참조는 **에셋 = public 루트 절대 경로**로 적는다(rev 7 기준 계약이 아는 참조 축은 이\n * 하나다 — 상품·갈래는 선언이 아니라 소스의 직접 호출로 그린다·rev 6). 계약 정본은 백엔드\n * `doc/contracts/section-vocabulary.json` 의 `dialects.reference` 이고, 여기 셋은 그 표기를 안전하게\n * 읽는 법이다. 위의 id 형 가드와 **같은 계약**: 절대 throw 하지 않고, 형이 안 맞으면 그 값만 사라진다.\n */\n\n/** 상품 handle 하나. 문자열이 아니거나 공백뿐이면 `undefined` — 형식은 검사하지 않는다(런타임은 관용). */\nexport function asHandle(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const handle = value.trim();\n return handle === \"\" ? undefined : handle;\n}\n\n/**\n * 상품 handle 배열. **배열 순서를 보존한다** — 이 배열이 노출 순서의 원장이고, 같은 배열에서 만든\n * ItemList 가 화면과 같은 순서로 나가야 한다.\n * 배열이 아니면 빈 배열, 원소 중 handle 이 아닌 것만 떨군다. 중복은 남긴다 — 같은 상품을 두 번\n * 진열하는 것이 계약 위반은 아니다.\n *\n * ⚠ **어휘 rev 6 이후 이 헬퍼의 소비자는 섹션 config 가 아니라 소스다.** 조회형 섹션 타입이 삭제되면서\n * \"선언에 적힌 handle 목록\"이라는 입력이 계약에서 사라졌다. 헬퍼를 남기는 이유는 소스가 자기 큐레이션\n * (자기 카탈로그 안의 자기 handle — 정당하다)을 배열로 들고 다닐 때 여전히 쓰이기 때문이다.\n */\nexport function asHandleArray(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n return value.map(asHandle).filter((h): h is string => h !== undefined);\n}\n\n/**\n * 레포 `public/` 에이셋 경로. **루트 절대 경로만** 통과한다.\n *\n * 왜 `safeLinkUrl` 을 그대로 안 쓰는가: 링크는 외부로 나가는 것이 정당하지만(스킴 허용목록),\n * 이 값은 `<img src>` 로 들어가는 **레포 안 파일 참조**다. 원격 호스트를 허용하면 ⑴ 개시된 사이트가\n * 남의 서버에 의존해 조용히 깨지고 ⑵ 방문자 IP·리퍼러가 그 호스트로 새며 ⑶ `data:`·`javascript:`\n * 가 같은 구멍으로 들어온다. 원격 이미지가 필요한 레포는 계약 영역이 아니라 자유 영역에서 자기\n * 컴포넌트로 그린다 — 계약이 자유를 막지는 않되, 계약이 보증하는 범위는 좁게 잡는다.\n *\n * 막는 것: 스킴 있는 URL(`http:`·`data:`·`javascript:`) · 스킴 상대(`//host`) · 상대 경로 ·\n * 경로 탈출(`..`) · 역슬래시(윈도 경로·정규화 우회) · 널바이트. **절대 throw 하지 않는다.**\n */\nexport function assetPath(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const path = value.trim();\n if (!path.startsWith(\"/\") || path.startsWith(\"//\")) return undefined;\n if (path.includes(\"\\\\\") || path.includes(\"\\0\")) return undefined;\n // `..` 은 경로 조각으로 있을 때만 탈출이다 — `/img/a..b.png` 같은 정상 파일명을 벌하지 않는다.\n if (path.split(\"/\").includes(\"..\")) return undefined;\n return path;\n}\n","/**\n * 테넌트 테마 색 파서 (서버 전용).\n *\n * `SiteConfig.themeColors` 는 파싱하지 않은 raw JSON 문자열이다(백엔드 콘솔 `site.theme.update` 가\n * 넣는다). 같은 JSON 에 `layout` 등 이종 키가 병합돼 오므로 **색 키만 화이트리스트로** 읽는다.\n * 어떤 쓰레기가 와도 죽지 않는다(섹션 parse.ts 와 같은 사상) — 실패하면 빈 오버라이드(기본 테마).\n *\n * 읽어들인 색은 globals.css 의 `@theme` 변수를 `<html>` inline style 로 덮어, `bg-primary` 등\n * 전 유틸리티가 테넌트 색으로 바뀐다(layout.tsx). CSS 주입 방어를 위해 값은 hex 형식 검증 후에만 싣는다.\n *\n * **이 패키지에 사는 이유**(memo108 §1): L1(말로 색 바꾸기)의 기계는 표현이 아니라 계약이다 — 어떤 토큰을\n * 쓸지는 코드가 정하고 값은 config 가 정한다는 규약(memo65 §3)의 집행부이고, 순수 함수라 React 에 안 매인다.\n * 이 함수의 반환을 `<html style={...}>` 에 싣는 **배선은 각 사이트의 몫**이다(validator S8 이 그걸 센다).\n */\n\n/** `#rgb`/`#rrggbb` 만 통과. 임의 문자열을 style 에 싣지 않는다(CSS 주입 방어). */\nconst HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\n\n/** 콘솔 스키마의 색 키 → globals.css `@theme` 변수명 매핑. 이 키만 읽는다. */\nconst COLOR_KEYS: ReadonlyArray<readonly [string, string]> = [\n [\"primary\", \"--color-primary\"],\n [\"secondary\", \"--color-secondary\"],\n [\"background\", \"--color-background\"],\n [\"text\", \"--color-foreground\"],\n];\n\n/*\n * ── 전역 토큰 knob (memo69 §2.1 · 계약버전 2) ──────────────────────────────\n * 색과 달리 knob 은 **enum**이다. 백엔드 `site.theme.update` 화이트리스트가 허용값만 저장하지만,\n * 여기서도 매핑 테이블 lookup 으로 한 번 더 봉한다: **사용자 문자열은 어떤 경로로도 style 에 실리지\n * 않는다** — enum 은 테이블 key 조회뿐이고, 없는 값(오염·구버전)은 조용히 버려 @theme 기본값을 유지한다.\n * 이 값들은 백엔드 enum(FONTS/RADII/DENSITIES)과 반드시 일치해야 한다(계약).\n */\n\n/** 시스템 폰트 스택(globals.css --font-sans 와 동일 — system knob 의 기본값). */\nconst SYSTEM_STACK =\n 'ui-sans-serif, system-ui, -apple-system, \"Apple SD Gothic Neo\", \"Malgun Gothic\", \"Noto Sans KR\", sans-serif';\n\n/*\n * font: --font-sans 스택 교체.\n * TODO(memo69 §2.1 후속): pretendard·noto-serif-kr 의 self-host `@font-face` woff2 서브셋 자산 번들링.\n * 지금은 폰트 **패밀리명만** 스택 앞에 얹어, 사용자 환경에 설치돼 있으면 쓰고 없으면 폴백한다\n * (외부 CDN 0 유지). 자산이 동봉되면 여기 매핑은 그대로 두고 globals.css 에 @font-face 만 추가하면 된다.\n */\nconst FONTS: Readonly<Record<string, string>> = {\n system: SYSTEM_STACK,\n pretendard: `\"Pretendard\", ${SYSTEM_STACK}`,\n \"noto-serif-kr\": '\"Noto Serif KR\", ui-serif, Georgia, \"Nanum Myeongjo\", serif',\n};\n\n/** radius: --radius-knob 무단위 배수(globals.css radius 스케일이 곱한다). soft=1 이 Tailwind 기본과 일치. */\nconst RADII: Readonly<Record<string, string>> = {sharp: \"0\", soft: \"1\", round: \"2\"};\n\n/** density: --spacing 베이스(Tailwind v4 기본 0.25rem). 전 spacing 유틸리티가 일괄 스케일. */\nconst DENSITIES: Readonly<Record<string, string>> = {compact: \"0.22rem\", cozy: \"0.25rem\"};\n\n/** knob 키 → CSS 변수 → enum 매핑 테이블. */\nconst KNOB_KEYS: ReadonlyArray<readonly [string, string, Readonly<Record<string, string>>]> = [\n [\"font\", \"--font-sans\", FONTS],\n [\"radius\", \"--radius-knob\", RADII],\n [\"density\", \"--spacing\", DENSITIES],\n];\n\nexport interface ParsedTheme {\n /** `<html style={...}>` 에 그대로 얹을 CSS 변수 맵. 오버라이드가 없으면 빈 객체(기본 테마 유지). */\n cssVars: Record<string, string>;\n}\n\nexport function parseThemeColors(raw: string | null | undefined): ParsedTheme {\n const cssVars: Record<string, string> = {};\n if (!raw) return {cssVars};\n\n let obj: unknown;\n try {\n obj = JSON.parse(raw);\n } catch {\n return {cssVars};\n }\n if (obj == null || typeof obj !== \"object\" || Array.isArray(obj)) return {cssVars};\n\n const src = obj as Record<string, unknown>;\n let primaryHex: string | undefined;\n for (const [key, cssVar] of COLOR_KEYS) {\n const value = src[key];\n if (typeof value === \"string\" && HEX.test(value)) {\n cssVars[cssVar] = value;\n if (key === \"primary\") primaryHex = value;\n }\n }\n\n // 콘솔 스키마에 primary-foreground 키가 없다 — primary 가 오버라이드되면 대비가 큰 쪽을\n // 서버가 산출해 함께 주입한다(테넌트가 밝은 액센트를 골라도 CTA 글자가 안 죽는다).\n if (primaryHex) {\n cssVars[\"--color-primary-foreground\"] = onColor(primaryHex);\n }\n\n // 전역 토큰 knob(font·radius·density) — 매핑 테이블 lookup 만. hasOwnProperty 로 own key 만 채택해\n // 프로토타입 오염(constructor 등)·오염 문자열을 차단한다(사용자 문자열이 style 에 실리지 않는다).\n for (const [key, cssVar, table] of KNOB_KEYS) {\n const value = src[key];\n if (typeof value !== \"string\") continue;\n // hasOwnProperty 로 own key 만 채택한다 — 프로토타입 오염(constructor 등)을 차단하는 그 검사다.\n // 다만 그것으로 타입이 좁혀지지는 않으므로(strict), 조회 결과를 직접 확인해 싣는다.\n if (!Object.prototype.hasOwnProperty.call(table, value)) continue;\n const mapped = table[value];\n if (mapped !== undefined) cssVars[cssVar] = mapped;\n }\n\n return {cssVars};\n}\n\n/** primary 위에 얹을 글자색 — WCAG 상대 휘도로 흰색/slate-950 중 대비 큰 쪽. */\nfunction onColor(hex: string): string {\n return relativeLuminance(hex) > 0.4 ? \"#020617\" /* slate-950 */ : \"#ffffff\";\n}\n\n/** WCAG 상대 휘도(0~1). 검증된 hex 만 들어온다(HEX 통과분). */\nfunction relativeLuminance(hex: string): number {\n // `.map` 은 튜플성을 잃어 number[] 가 되고, 그러면 구조분해가 undefined 를 낳는다(strict).\n // 튜플을 유지한 채 채널별로 편다.\n const [r0, g0, b0] = toRgb(hex);\n const lin = (c: number) => {\n const s = c / 255;\n return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n };\n return 0.2126 * lin(r0) + 0.7152 * lin(g0) + 0.0722 * lin(b0);\n}\n\nfunction toRgb(hex: string): [number, number, number] {\n // `#rgb` 축약을 `#rrggbb` 로 편다. slice 로 자르는 것은 인덱스 접근과 달리 undefined 가 안 나온다\n // (이 패키지는 noUncheckedIndexedAccess 라 `h[0]` 은 string|undefined 다 — 검증된 hex 만 들어온다는\n // 사실을 타입으로 증명할 수 없으므로, 증명이 필요 없는 방식으로 쓴다).\n let h = hex.slice(1);\n if (h.length === 3) {\n h = h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\");\n }\n return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n}\n"]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/pathSegment.ts","../src/client.ts","../src/sections.ts","../src/safeUrl.ts","../src/visitorIp.ts","../src/sectionConfig.ts","../src/theme.ts"],"names":["options"],"mappings":"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","file":"index.cjs","sourcesContent":["import type {ApiErrorBody, ValidationError} from \"./types\";\n\n/**\n * zalkera API 호출 실패.\n *\n * 백엔드의 `ErrorResponse` 를 그대로 담는다.\n *\n * **원인 분기는 [code] 로 한다** — 한 엔드포인트가 같은 상태코드로 여러 원인을 낸다(checkout 의 409 =\n * `OUT_OF_STOCK`·`ITEM_NOT_PURCHASABLE`·`IDEMPOTENCY_CONFLICT` — 처방이 전부 다르다). `message` 는\n * 사람용 한국어라 **문자열 매칭 금지**.\n *\n * `status` 는 거친 분류에만:\n * - `400` + [validationErrors] — 입력값 문제(폼 필드별 메시지 노출)\n * - `404` — 없는 slug/리소스\n * - `429` — 레이트리밋(문의·리드 남발, 게스트 주문 인가 실패 누적). [isRateLimited] 로 편히 판별\n * - `5xx` — 서버 오류\n *\n * 네트워크 자체가 실패했거나 응답이 JSON 이 아니면 [status] 가 0 이고 [body] 가 null 이다.\n */\nexport class ZalkeraError extends Error {\n /** HTTP 상태. 네트워크 실패·비JSON 응답이면 0. */\n readonly status: number;\n /**\n * **기계 판독 에러 코드** — `ErrorResponse.errorCode`(백엔드 `ErrorCode` enum 이름).\n * 예: `OUT_OF_STOCK`·`IDEMPOTENCY_CONFLICT`·`CART_NOT_FOUND`. 이걸로 분기한다.\n *\n * 코드를 안 싣는 구버전 백엔드 응답에서는 `error`(HTTP 사유구, \"Conflict\")로 **폴백**한다 —\n * 그때는 원인을 가를 수 없으니, 폴백 값에 의존하는 분기를 짜지 마라.\n * 열린 계약이라 문자열이다(서버가 코드를 추가해도 SDK 릴리스가 필요 없다).\n */\n readonly code: string | null;\n /** 검증 실패 시 필드별 메시지. 그 외엔 빈 배열. */\n readonly validationErrors: ValidationError[];\n /** 파싱된 원본 에러 본문(있으면). */\n readonly body: ApiErrorBody | null;\n\n constructor(\n message: string,\n options: {\n status: number;\n code?: string | null;\n validationErrors?: ValidationError[];\n body?: ApiErrorBody | null;\n cause?: unknown;\n },\n ) {\n super(message, {cause: options.cause});\n this.name = \"ZalkeraError\";\n this.status = options.status;\n this.code = options.code ?? null;\n this.validationErrors = options.validationErrors ?? [];\n this.body = options.body ?? null;\n }\n\n /**\n * 레이트리밋(429) — \"잠시 후 다시\" 를 띄울 때 쓴다.\n *\n * 두 갈래다: ⑴ 문의·리드 폼 남발(IP 축) ⑵ **게스트 주문 인가 실패 누적** — 주문번호+연락처로\n * 여는 주문 조회·취소·구매확정·배송조회·결제세션은 연락처 대입을 막으려고 실패를 센다.\n * 정상 조회는 세지 않으므로, 이 코드가 뜨면 연락처를 여러 번 틀렸거나 같은 주문에 시도가 몰린 것이다.\n */\n get isRateLimited(): boolean {\n return this.status === 429;\n }\n\n /**\n * 스토어프론트 시크릿 키 문제(memo78) — 개발자 오배선 신호. `true` 면 `secretKey` 옵션을\n * 점검하라(미설정·불일치·폐기). [code] 로 정밀 분기: `STOREFRONT_KEY_REQUIRED`(401·키 필요)·\n * `TENANT_MISMATCH`(403·키↔tenant 불일치).\n */\n get isStorefrontKeyError(): boolean {\n return this.code === \"STOREFRONT_KEY_REQUIRED\" || this.code === \"TENANT_MISMATCH\";\n }\n\n /** 응답 본문(파싱된 것)으로부터 에러를 만든다. */\n static fromBody(status: number, body: unknown): ZalkeraError {\n if (isApiErrorBody(body)) {\n const code = body.errorCode ?? body.error;\n return new ZalkeraError(\n // 스토어프론트 키 오배선(memo78)은 개발자용 안내로 메시지를 덮는다 — 백엔드는 정보 누출\n // 최소화로 두루뭉술한 401 을 주므로, SDK 가 \"무엇을 고쳐야 하는지\"를 또렷이 알려준다.\n STOREFRONT_KEY_MESSAGES[code as string] ?? body.message,\n {\n // errorCode(기계 판독)를 우선, 없으면 error(HTTP 사유구)로 폴백 — 구버전 백엔드 안전.\n status,\n code,\n validationErrors: body.errors ?? [],\n body,\n },\n );\n }\n // 비JSON 에러 바디(게이트웨이 HTML·빈 바디). 502/503/504 는 상류 장애로 안내 메시지 + 코드 부여.\n const gateway = status === 502 || status === 503 || status === 504;\n return new ZalkeraError(\n gateway\n ? \"서비스에 일시적인 문제가 있습니다. 잠시 후 다시 시도해주세요.\"\n : `요청이 실패했습니다 (HTTP ${status})`,\n {status, code: gateway ? \"UPSTREAM_UNAVAILABLE\" : null},\n );\n }\n}\n\n/**\n * 스토어프론트 시크릿 키(memo78) 관련 에러코드 → 개발자용 안내 메시지. 백엔드 원문 메시지를 이걸로\n * 덮는다(백엔드는 열거 방지로 두루뭉술하게 답한다 — SDK 가 처방을 또렷이 준다).\n */\nconst STOREFRONT_KEY_MESSAGES: Record<string, string> = {\n STOREFRONT_KEY_REQUIRED:\n \"스토어프론트 시크릿 키가 필요합니다. createZalkeraClient 의 secretKey 옵션을 설정하세요 \" +\n \"(파트너 콘솔에서 발급 → 서버 .env 의 ZALKERA_STOREFRONT_KEY). 브라우저 번들에 넣지 마세요.\",\n TENANT_MISMATCH: \"secretKey 가 tenant 옵션과 다른 테넌트의 키입니다. 두 값이 같은 테넌트인지 확인하세요.\",\n};\n\nfunction isApiErrorBody(value: unknown): value is ApiErrorBody {\n return (\n typeof value === \"object\" &&\n value !== null &&\n \"message\" in value &&\n typeof (value as {message: unknown}).message === \"string\"\n );\n}\n","/**\n * URL 경로 조각 인코딩 — **경로에 값을 끼우는 유일한 관문**(내부 모듈·공개 export 아님).\n *\n * 이 결손의 본질은 \"URL 경로에 값을 인코딩 없이 보간했다\"이지 \"숫자가 아닌 값이 왔다\"가 아니다.\n * 선언이 `number` 라도 실제 호출자는 스토어프론트 BFF 라우트 핸들러이고, 입력은\n * `Request.json()`(`Promise<any>`)·`params`(문자열)라 **strict 타입검사를 그대로 통과한다.**\n * 인코딩을 빠뜨린 `..` 이 들어오면 `new URL()` 이 경로를 정규화해 `/api` 접두 밖으로 나가고,\n * 그 요청에는 테넌트 시크릿 키(`X-Storefront-Key`)가 붙어 있다.\n *\n * 그래서 처방은 값 검증(정수 강제·throw)이 아니다 — JS 소비자가 `\"42\"` 를 넘기는 정상 사용을 깨고,\n * \"이 id 가 유효한가\"의 판정은 어차피 백엔드 몫이다. 정석은 **모든 보간을 이 함수 하나에 수렴시켜\n * 빠뜨릴 수 없게 만드는 것**이고, `contract.test.ts` 의 소스 불변식 테스트가 그 수렴을 CI 로 강제한다\n * (경로 템플릿의 모든 `${` 가 `${seg(` 로 시작해야 한다).\n *\n * `String(v)` 를 먼저 거치는 이유: 템플릿 보간(`${v}`)은 Symbol 에서 TypeError 를 던지지만\n * `String(v)` 는 던지지 않는다 — 절대 throw 하지 않는 [mediaSrc] 의 계약을 지키기 위해서다.\n */\nexport function seg(v: string | number): string {\n return encodeURIComponent(String(v));\n}\n","import {ZalkeraError} from \"./errors\";\nimport {seg} from \"./pathSegment\";\nimport type {\n ApiResponse,\n AuthTokens,\n AvailabilityParams,\n AvailabilitySlot,\n Booking,\n Cart,\n Category,\n CheckoutInput,\n CreateBookingInput,\n CreateReviewInput,\n ConsentInput,\n ConsentStatus,\n CustomerSummary,\n InquiryCreated,\n InquiryInput,\n LeadCreated,\n LeadInput,\n ListPostsParams,\n ListReviewsParams,\n MediaUrl,\n OrderDetail,\n OrderSummary,\n ListProductsParams,\n Paginated,\n PaymentSession,\n PostDetail,\n PostSummary,\n ProductCategory,\n ProductDetail,\n ProductSummary,\n RatingSummary,\n Review,\n ShipmentInfo,\n SiteConfig,\n SocialLoginInput,\n} from \"./types\";\n\n/**\n * ISR 읽기 옵션 — Next.js 캐시 태그(memo31 §0-1). RSC/ISR 페이지에서 읽기 메서드에 넘기면\n * 그 fetch 에 `next.tags` 가 실려, 백엔드가 `revalidateTag(tag)` 로 온디맨드 무효화할 수 있다.\n * 태그 컨벤션: `site-config`(사이트 설정·테마·레이아웃), `products`(카탈로그), `product:{slug}`(특정 상품).\n * 넘기지 않으면 세그먼트 기본 캐시(페이지의 `revalidate` 주기)만 적용된다 — 하위호환 유지.\n */\nexport interface ReadOptions {\n /** Next ISR 캐시 태그. 이 태그로 백엔드가 온디맨드 revalidate 한다. */\n tags?: string[];\n}\n\nexport interface ZalkeraClientOptions {\n /**\n * 백엔드 베이스 URL — `/api` 접두사는 붙이지 않는다. 예: `http://localhost:8100`.\n * 클라이언트가 경로에 `/api/public/...` 를 붙인다.\n */\n baseUrl: string;\n /**\n * 테넌트 코드. 모든 요청에 `X-Tenant` 헤더로 실린다.\n *\n * 공개 API 는 이 헤더를 그대로 믿는다(비인증). 그래서 이 클라이언트는 **서버 사이드**\n * (RSC·route handler·server action)에서 쓰는 것을 전제로 한다 — 브라우저에서 직접 부르면\n * baseUrl 이 노출되고 CORS 를 열어야 한다. README 참고.\n *\n * [secretKey] 를 함께 주면 백엔드는 키로 테넌트를 결정하고(키가 정본), 이 값은 대조용으로만\n * 쓰인다. 이행기(dual)에는 둘 다 보내도 무방하며 일치해야 한다(불일치 시 403 `TENANT_MISMATCH`).\n */\n tenant: string;\n /**\n * 스토어프론트 서버 시크릿 키(선택·`oqsk_…`). 주면 모든 요청에 `X-Storefront-Key` 헤더로 실려,\n * 백엔드가 이 키로 테넌트 신원을 증명한다(memo78 — [tenant] 무인증 신뢰의 보안 승격).\n *\n * ⚠️ **진짜 비밀이다.** 오직 서버 `.env`(예: `ZALKERA_STOREFRONT_KEY`)에만 두고, 브라우저 번들에\n * 절대 넣지 마라 — `NEXT_PUBLIC_*` 접두사·클라이언트 컴포넌트 import 금지. 이 클라이언트가 서버\n * 전용인 이유가 그것이다. 유출 시 콘솔에서 revoke·재발급.\n *\n * 안 주면 종전대로 [tenant](`X-Tenant`)만으로 동작한다(dual 이행기 하위호환). 백엔드가 `required`\n * 모드인데 키가 없으면 401 `STOREFRONT_KEY_REQUIRED` → [ZalkeraError].\n */\n secretKey?: string;\n /**\n * `fetch` 구현 주입(선택). 기본은 전역 `fetch`(Node 18+·브라우저). 테스트·커스텀 에이전트·\n * Next.js 의 `fetch` 캐시 옵션을 감싸는 래퍼를 넣을 때 쓴다.\n *\n * (내부 `request` 전송부는 이 주입점을 데이터 소스 seam 으로 유지한다 — 미래의 로컬 픽스처(mock)\n * 모드를 갈아엎지 않고 얹기 위한 여지다. mock 구현은 현재 없다 — memo78 §14 후속.)\n */\n fetch?: typeof fetch;\n /** 모든 요청에 추가할 헤더(선택). */\n headers?: Record<string, string>;\n /** 요청 타임아웃(ms). 기본 10초. */\n timeoutMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * zalkera 공개 API 클라이언트.\n *\n * 테넌트 사이트(credium 등)가 백엔드의 공개 엔드포인트를 타입 안전하게 부르기 위한 얇은 래퍼다.\n * 런타임 의존성이 없다 — 전역 `fetch` 만 쓴다.\n *\n * ```ts\n * const cms = createZalkeraClient({ baseUrl: process.env.API_BASE_URL!, tenant: \"credium\" });\n * const posts = await cms.listPosts({ size: 10, sort: \"publishedAt,desc\" });\n * await cms.submitInquiry({ name, email, subject, message });\n * ```\n *\n * 모든 메서드는 성공 시 envelope 안쪽 `data` 를 돌려주고, 실패 시 [ZalkeraError] 를 던진다.\n */\nexport interface ZalkeraClient {\n /** 회사 정보·테마·SEO 기본값. ISR 페이지는 [ReadOptions.tags] 로 캐시 태그를 실을 수 있다. */\n getSiteConfig(options?: ReadOptions): Promise<SiteConfig>;\n /** 살아 있는 카테고리 전부(페이징 없음). */\n listCategories(): Promise<Category[]>;\n /** 발행된 글 목록(페이징). */\n listPosts(params?: ListPostsParams): Promise<Paginated<PostSummary>>;\n /** slug 로 글 상세(본문 포함). 없으면 404 → [ZalkeraError]. */\n getPost(slug: string): Promise<PostDetail>;\n /**\n * 조회 비콘 — 같은 뷰어의 같은 날 재조회는 집계되지 않는다(반환 false).\n * 화면 렌더를 막지 않도록 fire-and-forget 으로 부르길 권한다(실패해도 페이지는 살아야 한다).\n *\n * 조회 dedup 은 뷰어 IP·UA 를 쓴다. 서버 사이드에서 부를 때는 [RequestContext.clientIp] 로\n * 원 방문자 IP 를 넘겨야 방문자별로 집계된다 — 안 넘기면 전부 서버 IP 하나로 뭉친다.\n * 값은 [visitorIp] 로 뽑아라(첫 홉 직접 추출 금지 — 방문자가 위조할 수 있다).\n */\n recordPostView(slug: string, context?: RequestContext): Promise<boolean>;\n /** 미디어 presigned 다운로드 URL(만료 있음). */\n getMediaUrl(id: number): Promise<MediaUrl>;\n /**\n * 문의 접수. 성공 시 생성된 문의 id. 레이트리밋이면 429 → `error.isRateLimited`.\n *\n * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자\n * IP 를 반드시 넘겨야 한다.** 안 넘기면 백엔드가 테넌트 서버 IP 하나만 보고 몇 건 뒤 **모든 방문자**\n * 를 429 로 막는다.\n *\n * ⚠️ 그 값은 **[visitorIp] 로 뽑아라.** `x-forwarded-for` 의 **첫 홉을 직접 쓰지 마라** — 첫 엔트리는\n * 방문자가 요청에 손으로 실은 값이라 레이트리밋이 한 줄로 우회된다(백엔드는 첫 홉을 쓰지 않는다.\n * 신뢰 프록시 홉 기반으로 채택한다). 보장 경계·홉 수 선언은 [visitorIp] 문서 참조.\n */\n submitInquiry(input: InquiryInput, context?: RequestContext): Promise<InquiryCreated>;\n /**\n * 광고 리드 접수. 문의와 달리 이메일이 선택이고 UTM 추적을 함께 보낸다.\n *\n * ⚠️ [submitInquiry] 와 같은 이유로 서버 사이드에서는 [RequestContext.clientIp] 로 원 방문자\n * IP 를 넘겨야 한다 — 백엔드 리드 레이트리밋·IP 기록이 그 값을 본다. 값은 [visitorIp] 로 뽑는다.\n */\n submitLead(input: LeadInput, context?: RequestContext): Promise<LeadCreated>;\n\n // ── 커머스: 카탈로그(공개) ──────────────────────────────────\n /** slug 로 공개 상품(ACTIVE) 상세 — variant·재고 가용여부 포함. 없으면 404. ISR 태그는 [ReadOptions]. */\n getProduct(slug: string, options?: ReadOptions): Promise<ProductDetail>;\n /**\n * 공개 상품 목록(ACTIVE) — 카드용 요약(최저가·재고). ISR 태그는 [ReadOptions].\n * `params.categoryId` 로 카테고리별 목록을 그린다([ListProductsParams]).\n */\n listProducts(params?: ListProductsParams, options?: ReadOptions): Promise<Paginated<ProductSummary>>;\n /** 커머스 카테고리 목록(노출 순서). ISR 태그는 [ReadOptions]. */\n listProductCategories(options?: ReadOptions): Promise<ProductCategory[]>;\n\n // ── 커머스: 상품후기(공개) ──────────────────────────────────\n /**\n * 상품 후기 목록(공개·VISIBLE 만, 최신순, 페이징).\n *\n * 상품의 **숫자 id(productId)** 로 조회한다 — slug 가 아니다. `getProduct(slug).id` 로 획득한다(0.4.0).\n */\n listProductReviews(\n productId: number,\n params?: ListReviewsParams,\n options?: ReadOptions,\n ): Promise<Paginated<Review>>;\n /** 상품 평점 요약(후기 수·평균 별점). 후기 없으면 count=0·avg=0. 목록 별점 배지에 값싸게 쓴다. */\n getProductReviewSummary(productId: number, options?: ReadOptions): Promise<RatingSummary>;\n /**\n * 후기 작성 — **로그인 필수 + 구매검증**. `input.orderItemId` 는 [OrderItemLine.id](주문 상세에서 획득).\n * 서버가 본인 주문·배송완료(DELIVERED) 이상·상품 일치를 3중 확인한다. 라인당 1개(재작성 시 409 `ALREADY_REVIEWED`).\n */\n createProductReview(productId: number, input: CreateReviewInput, accessToken: string): Promise<Review>;\n\n // ── 커머스: 고객 인증 ───────────────────────────────────────\n /** 소셜 로그인 — authorization code 를 백엔드가 교환하고 고객 토큰을 발급. */\n socialLogin(input: SocialLoginInput): Promise<AuthTokens>;\n /** refresh 토큰으로 새 토큰 회전(옛 refresh 는 폐기). */\n refreshSession(refreshToken: string): Promise<AuthTokens>;\n /** 로그아웃 — 이 고객의 모든 세션 폐기. */\n logout(accessToken: string): Promise<string>;\n /** 내 정보. */\n getMe(accessToken: string): Promise<CustomerSummary>;\n /** 현재 동의 상태 조회(로그인 필수) — 마케팅 수신 토글 화면 등에서 초기값으로 쓴다. */\n getConsents(accessToken: string): Promise<ConsentStatus[]>;\n /**\n * 동의 갱신(로그인 필수, append) — 마케팅 수신 on/off 등. 넘긴 항목만 반영되고 나머지는 유지된다.\n * 성공 시 백엔드 확인 메시지를 돌려준다(최신 상태가 필요하면 [getConsents] 로 다시 읽는다).\n */\n updateConsents(accessToken: string, consents: ConsentInput[]): Promise<string>;\n\n // ── 예약(SERVICE 상품) ──────────────────────────────────────\n /**\n * 예약 가능한 슬롯 조회 — **비로그인 가능**(상품 상세에서 시간표를 보여주는 자리).\n *\n * 미래·잔여정원 있는 OPEN 슬롯만 온다. 가용 판정은 [AvailabilitySlot.availableCount] 로 하고,\n * 카탈로그의 `inStock`/`available` 을 쓰지 마라 — 예약은 슬롯마다 정원이 따로다.\n */\n availability(params: AvailabilityParams, options?: ReadOptions): Promise<AvailabilitySlot[]>;\n /**\n * 예약 생성 — 슬롯 정원을 원자적으로 소비한다. **로그인 필수**(게스트 예약 없음).\n *\n * 무료 예약은 즉시 `CONFIRMED`. **유료·예약금이면 `status=PENDING` + [Booking.orderNo] 가 실려 오고**,\n * 그 orderNo 로 [startPayment] 를 불러 **기존 결제 흐름을 그대로 탄다**(예약 전용 결제 API 는 없다).\n *\n * 정원 소진은 `SLOT_FULL` — 슬롯 목록이 stale 했다는 뜻이니 [availability] 를 다시 읽어 보여줘라.\n */\n createBooking(accessToken: string, input: CreateBookingInput): Promise<Booking>;\n /** 내 예약 목록(최신순). 로그인 필수. */\n myBookings(accessToken: string): Promise<Booking[]>;\n /** 예약 취소 — 시작 전·무료 예약만. 정원이 복원된다. 로그인 필수. */\n cancelBooking(accessToken: string, bookingCode: string): Promise<string>;\n /** 같은 상품의 다른 슬롯으로 이동(신 슬롯 소비·구 슬롯 복원). 로그인 필수. */\n rescheduleBooking(accessToken: string, bookingCode: string, newSlotId: number): Promise<Booking>;\n\n // ── 커머스: 장바구니 ────────────────────────────────────────\n /**\n * 장바구니 조회. 로그인은 accessToken, 게스트는 cartSessionKey 로 식별.\n *\n * ⚠️ **예약(SERVICE) 상품은 장바구니에 담기지 않는다** — [addToCart] 가 400 으로 거부한다.\n * 예약은 [createBooking] 으로 별도 흐름을 탄다(카트에 슬롯을 실으면 체크아웃이 오염된다).\n */\n getCart(session: ShopSession): Promise<Cart>;\n /**\n * 담기(같은 variant 는 수량 누적).\n *\n * ⚠️ **예약(SERVICE) 상품은 400 으로 거부된다** — 예약은 [createBooking] 이다. 상품 상세에서\n * `productType === \"SERVICE\"` 면 담기 버튼 대신 슬롯 선택을 그려라.\n */\n addToCart(variantId: number, quantity: number, session: ShopSession): Promise<Cart>;\n /** 수량 변경. */\n updateCartItem(variantId: number, quantity: number, session: ShopSession): Promise<Cart>;\n /** 항목 삭제. */\n removeFromCart(variantId: number, session: ShopSession): Promise<Cart>;\n /** 비우기. */\n clearCart(session: ShopSession): Promise<Cart>;\n\n // ── 커머스: 결제·주문·배송 ──────────────────────────────────\n /**\n * 결제(주문 생성) — 스냅샷 동결 + 재고 차감. 상태=PENDING_PAYMENT.\n *\n * [idempotencyKey] 를 주면 **같은 키·같은 입력의 재요청이 새 주문을 만들지 않고 원주문을 그대로\n * 반환**한다(더블클릭·네트워크 재시도 안전). 같은 키로 **다른 입력**을 보내면 409.\n *\n * **키는 \"이 체크아웃 1건\"에 고정된 값이어야 한다** — 호출마다 새로 만들면 재시도가 서로 다른\n * 키를 들고 가서 아무것도 막지 못한다(그래서 SDK 가 자동 생성해 주지 않는다). 안정적인 소스를\n * 쓴다: 게스트는 **장바구니 세션 키**(카트 1개 → 주문 1건이라 의미가 정확히 맞는다),\n * 그 외에는 결제 시도 시작 시 한 번 만들어 재시도 동안 보관한 값.\n *\n * 안 주면 종전 동작 — 중복 제출은 카트가 이미 소비돼 404(`CART_NOT_FOUND`)로 떨어진다.\n */\n checkout(input: CheckoutInput, session: ShopSession, idempotencyKey?: string): Promise<OrderDetail>;\n /**\n * 결제 시작. **벤더에 따라 두 갈래**(테넌트가 자기 PG 를 고른다 — 기본 TOSS):\n * `session.widget` 이 있으면 위젯형(내 사이트에서 결제창 → [confirmPayment] 로 승인 확정),\n * 없으면 리다이렉트형(`session.paymentUrl` 로 보내면 끝).\n */\n startPayment(orderNo: string, access: OrderAccess): Promise<PaymentSession>;\n /**\n * **위젯형 벤더의 승인 확정**(토스 등). 결제창이 성공 URL 로 돌려준 파라미터를 그대로 넘긴다\n * (토스: `{paymentKey, orderId, amount}` — URL 쿼리에서 읽어 통째로).\n *\n * **금액을 넘겨도 서버는 안 믿는다** — 서버가 저장한 주문 금액으로 PG 에 직접 묻는다. 그래서\n * 브라우저에서 금액을 조작해도 승인되지 않는다.\n *\n * 이미 승인된 주문(웹훅 선착)이면 조용히 통과한다 — 재호출이 안전하다.\n * 리다이렉트형 벤더에 부르면 404(`PG_CONFIG_NOT_FOUND`).\n */\n confirmPayment(orderNo: string, providerParams: Record<string, string>, access: OrderAccess): Promise<void>;\n /** 주문 조회 — 본인(accessToken) 또는 게스트(phone). 인가 실패가 누적되면 429. */\n getOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 내 주문 목록(로그인 필수). */\n listMyOrders(accessToken: string, params?: {page?: number; size?: number}): Promise<Paginated<OrderSummary>>;\n /** 주문 취소(미결제만) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n cancelOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 구매 확정(배송완료 후) — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n completeOrder(orderNo: string, access: OrderAccess): Promise<OrderDetail>;\n /** 배송 조회 — 본인 또는 게스트(phone). 인가 실패가 누적되면 429. */\n getShipment(orderNo: string, access: OrderAccess): Promise<ShipmentInfo>;\n}\n\n/**\n * 장바구니·결제의 사용자 식별. 로그인 고객은 [accessToken], 게스트는 [cartSessionKey]\n * (브라우저가 만든 안정적 익명 키)를 넘긴다. 로그인 시 게스트 카트→고객 카트 병합은 백엔드 몫.\n */\nexport interface ShopSession {\n accessToken?: string;\n cartSessionKey?: string;\n}\n\n/**\n * 주문 조회·취소·배송조회의 접근 크리덴셜. 로그인 고객은 [accessToken] 으로 소유권을,\n * 게스트는 주문 시 남긴 [phone] 으로 확인한다(주문번호 + 연락처).\n */\nexport interface OrderAccess {\n accessToken?: string;\n phone?: string;\n /**\n * ⚠️ 서버 사이드(테넌트 route handler)에서 부를 때는 [RequestContext.clientIp] 로 **원 방문자 IP 를\n * 선언**하라. 게스트 주문 인가(주문번호+phone)에는 실패 rate-limit 이 걸려 있는데, 선언이 없으면\n * 백엔드가 보는 IP 가 **테넌트 서버 하나로 뭉쳐** 한 방문자의 실패가 그 사이트 전체를 잠글 수 있다.\n * 주문번호 축은 그와 무관하게 계속 서므로 방어 자체는 유효하다 — 선언은 **오탐을 줄이는 쪽**이다.\n */\n context?: RequestContext;\n}\n\n/**\n * IP 민감 엔드포인트(문의·리드·조회 비콘)에서 원 방문자를 백엔드에 알리는 컨텍스트.\n *\n * 테넌트 사이트는 서버 사이드에서 이 클라이언트를 부르므로, 백엔드가 보는 소스 IP 는 방문자가\n * 아니라 테넌트 서버다. 그래서 방문자 IP 를 **선언**해서 넘긴다 — 전송은 전용 헤더\n * `X-Zalkera-Client-Ip`(+ 이행기 `X-Forwarded-For` 병행)이고, 백엔드는 **유효 스토어프론트 키가 확인된\n * 요청에서만** 그 선언을 채택한다. 무키 요청의 선언은 무시되므로 값이 안 반영될 수 있다(그때는 종전대로\n * 테넌트 서버 IP 로 뭉친다 — 퇴행이 아니라 현상 유지다).\n */\nexport interface RequestContext {\n /**\n * 원 방문자 IP. **[visitorIp] 로 뽑아라** — `x-forwarded-for` 첫 홉을 손으로 쓰면 방문자가 위조할 수 있고\n * (`x-real-ip` 도 프록시마다 달라 신뢰 못 한다), 그 우회는 조용하다. 못 정하면 넘기지 마라(생략 = 백엔드 폴백).\n */\n clientIp?: string;\n}\n\nexport function createZalkeraClient(options: ZalkeraClientOptions): ZalkeraClient {\n const baseUrl = options.baseUrl.replace(/\\/+$/, \"\");\n const fetchImpl = options.fetch ?? globalThis.fetch;\n if (typeof fetchImpl !== \"function\") {\n throw new Error(\"@zalkera/client: 전역 fetch 가 없습니다. Node 18+ 를 쓰거나 options.fetch 를 주입하세요.\");\n }\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n async function request<T>(\n path: string,\n init?: {\n method?: string;\n body?: unknown;\n query?: Record<string, unknown>;\n context?: RequestContext;\n /** 고객 access 토큰 → Authorization: Bearer. */\n bearer?: string;\n /** 게스트 장바구니 키 → X-Cart-Session. */\n cartSession?: string;\n /** 멱등키 → Idempotency-Key. 같은 키·같은 본문의 재요청은 원주문을 재생한다. */\n idempotencyKey?: string;\n /** Next.js ISR 캐시 옵션 — RSC/ISR fetch 에 `next.tags` 를 실어 온디맨드 revalidate 대상으로 만든다. */\n next?: {tags?: string[]};\n },\n ): Promise<T> {\n // 경로 조각은 `seg()` 로 인코딩되지만 **점은 인코딩 대상이 아니다** — `\"..\"` 를 넘기면 그대로\n // 한 세그먼트가 되고 `new URL` 이 정규화하며 경로가 한 단 올라간다(`/api` 이탈에는 3단이 필요해\n // 현재 실피해는 없지만, \"경로 파라미터가 경로 구조를 바꾸지 못한다\"는 불변식이 미완이 된다).\n // %2E 치환은 답이 아니다 — WHATWG URL 이 `%2e%2e` 도 dot-segment 로 정규화한다(실측).\n // 그래서 전송 단일점에서 거부한다. 정상 경로에 `.`·`..` 세그먼트는 존재하지 않는다.\n if (/(^|\\/)\\.{1,2}(\\/|$)/.test(path)) {\n throw new ZalkeraError(`경로에 상대 세그먼트가 있습니다: ${path}`, {status: 400, code: \"INVALID_PATH\"});\n }\n const url = new URL(`${baseUrl}/api${path}`);\n if (init?.query) {\n for (const [key, value] of Object.entries(init.query)) {\n if (value != null) url.searchParams.set(key, String(value));\n }\n }\n\n const headers: Record<string, string> = {\n \"X-Tenant\": options.tenant,\n Accept: \"application/json\",\n ...options.headers,\n };\n // 스토어프론트 서버 시크릿(memo78) — 있으면 전 요청에 붙는다. 키가 테넌트 신원의 정본이고\n // X-Tenant 는 대조용으로 함께 간다(dual). 순수 옵션 전달 — 값은 코드에 박히지 않는다.\n if (options.secretKey) headers[\"X-Storefront-Key\"] = options.secretKey;\n if (init?.body != null) headers[\"Content-Type\"] = \"application/json\";\n // 원 방문자 IP 를 백엔드에 전달 — 문의 레이트리밋·조회 dedup·IP 기록이 이걸 본다.\n //\n // **전용 헤더 + XFF 병행 송신.** 종전에는 XFF 하나로만 보냈는데, BFF→백엔드 사이에 로드밸런서가\n // 있으면 그것이 XFF 에 접속원 IP 를 **append** 한다. 그러면 백엔드가 보는 값은\n // `\"<우리가 넘긴 방문자IP>, <테넌트 서버IP>\"` 가 되고, 백엔드의 신뢰 홉 파싱(홉 1)은 **마지막 =\n // 테넌트 서버 IP** 를 취한다 — 문서가 \"반드시 넘겨라\"라고 적은 값을 백엔드가 버렸다(상용 실측).\n // 결함은 백엔드 파싱이 아니라 **선언 값을 위조 가능 채널(XFF)에 실은 전송로**였다.\n //\n // 그래서 선언은 전용 헤더로 옮긴다. 백엔드는 이 헤더를 **유효 스토어프론트 키가 확인된 요청에서만**\n // 승격한다(무키 요청의 선언은 무시 — 무인증 신뢰는 위조를 이름만 바꿔 재도입하는 것이다).\n //\n // XFF 도 **당분간 함께** 보낸다: 로컬 개발(프록시 0단·무키)에서는 지금도 XFF 경로가 실제로 동작\n // 중이라, 끊으면 로컬 계측이 퇴행한다. 병행 중단은 백엔드 승격이 정착한 뒤 다음 minor 다.\n if (init?.context?.clientIp) {\n headers[\"X-Zalkera-Client-Ip\"] = init.context.clientIp;\n headers[\"X-Forwarded-For\"] = init.context.clientIp;\n }\n // 커머스 인증 — 로그인 고객은 Bearer, 게스트 장바구니는 세션 키.\n if (init?.bearer) headers[\"Authorization\"] = `Bearer ${init.bearer}`;\n if (init?.cartSession) headers[\"X-Cart-Session\"] = init.cartSession;\n if (init?.idempotencyKey) headers[\"Idempotency-Key\"] = init.idempotencyKey;\n\n // 타임아웃 — 행 걸린 연결이 페이지 렌더를 무한정 붙들지 않게.\n //\n // ⚠ **읽기에는 `signal` 을 붙이지 않는다.** React 는 **외부 signal 이 달린 fetch 를 요청\n // 메모이제이션에서 제외**한다. 그래서 종전에는 한 렌더 안에서 같은 인자로 부른 호출이\n // 합쳐지지 않고 **백엔드 실히트가 정확히 2배**였다(심의 실측: 상품 상세 8회 → 4회,\n // `/policies` 는 site-config 4회 → 1회). 소비 레포의 주석 8곳이 \"memoization 이 1회로\n // 합친다\"고 적고 있었는데 전부 거짓이었다.\n //\n // 읽기는 대신 `Promise.race` 로 시간을 잰다 — 호출자는 같은 타임아웃 오류를 받고,\n // 다만 밑단 요청이 실제로 취소되지는 않는다(끝나면 버려진다). 렌더를 붙들지 않는다는\n // 목적은 그대로 달성되고, 취소 여부는 읽기에서 관측 가능한 차이를 만들지 않는다.\n //\n // 쓰기(POST·PUT·DELETE)는 종전대로 `signal` 을 붙인다 — 메모이제이션 대상이 아니라\n // 잃을 것이 없고, 중복 전송을 실제로 끊는 편이 안전하다.\n const method = init?.method ?? \"GET\";\n const isRead = method === \"GET\";\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n // ⚠ **읽기일 때만 만든다.** 초판은 무조건 만들어 두고 읽기에서만 `Promise.race` 했는데, 그러면\n // 쓰기에서는 **아무도 이 거절을 구독하지 않아** 성공한 호출마다 `timeoutMs` 뒤에\n // `unhandledRejection: AbortError` 가 뜬다(심의 실측 — 장바구니 POST 1회 = 로그 1건, 결정론).\n // `next start` 아래선 로그 오염이지만, 순수 Node 소비처는 기본이\n // `--unhandled-rejections=throw` 라 **프로세스가 죽는다.** 0.15.0 에는 없던 결함을 0.16.0 이\n // 새로 넣었다 — 읽기 경로만 재고 쓰기 경로를 안 쟀기 때문이다.\n //\n // 타이머도 `finally` 에서 걷는다. 안 걷으면 응답이 먼저 와도 프로세스가 그 시간만큼 붙들린다\n // (`unref` 로 완화되지만 불필요한 대기는 남는다).\n let timedOut = false;\n let deadlineTimer: ReturnType<typeof setTimeout> | undefined;\n const deadline = isRead\n ? new Promise<never>((_, reject) => {\n deadlineTimer = setTimeout(() => {\n timedOut = true;\n reject(new Error(\"AbortError\"));\n }, timeoutMs);\n deadlineTimer.unref?.();\n })\n : undefined;\n\n let response: Response;\n try {\n // Next.js 는 표준 RequestInit 에 비표준 `next`(캐시 태그/주기)를 확장한다. 표준 DOM 타입엔\n // 없으므로 교차 타입으로 선언해 (있을 때만) 실어 보낸다 — 일반 fetch 는 이 필드를 무시한다.\n const fetchInit: RequestInit & {next?: {tags?: string[]}} = {\n method,\n headers,\n body: init?.body != null ? JSON.stringify(init.body) : undefined,\n // 읽기는 위 이유로 signal 을 뺀다(메모이제이션 보존).\n ...(isRead ? {} : {signal: controller.signal}),\n // 리다이렉트를 따라가지 않는다 — **보안 불변식이라 스위치를 두지 않는다**(옵션으로 열면\n // 그 옵션이 곧 사고 경로가 된다). 기본값 `follow` 로 크로스오리진 3xx 를 따라가면\n // `X-Storefront-Key`(테넌트 시크릿)·`X-Tenant`·`X-Cart-Session`·`Idempotency-Key` 가\n // 제3자 오리진에 그대로 전달된다 — undici 는 커스텀 헤더를 벗기지 않는다(실측).\n // `\"error\"` 를 쓰지 않는 이유: undici 에서 일반 `TypeError: fetch failed` 로 떨어져\n // 아래 네트워크 분기에 흡수되고, 그러면 원인(대개 baseUrl 오배선)이 사라진다.\n redirect: \"manual\",\n };\n if (init?.next) fetchInit.next = init.next;\n const call = fetchImpl(url.toString(), fetchInit);\n response = deadline ? await Promise.race([call, deadline]) : await call;\n } catch (cause) {\n const aborted = timedOut || (cause instanceof Error && cause.name === \"AbortError\");\n throw new ZalkeraError(\n aborted ? `요청 시간이 초과되었습니다 (${timeoutMs}ms)` : \"네트워크 요청이 실패했습니다.\",\n {status: 0, cause},\n );\n } finally {\n clearTimeout(timer);\n if (deadlineTimer) clearTimeout(deadlineTimer);\n }\n\n // 리다이렉트는 여기서 끊는다 — `redirect: \"manual\"` 로 받았으니 따라간 적이 없다.\n // 본문·상태를 읽기 **전에** 갈라야 한다(3xx 는 !ok 라 그냥 두면 fromBody 가 삼켜, 왜 실패했는지가\n // 백엔드 에러와 구분되지 않는다). 상류 오염을 502 + 전용 code 로 알리는 `UPSTREAM_NON_JSON` 과 같은 사상.\n if (isRedirectResponse(response)) {\n // 본문은 볼 것이 없다 — 소켓이 매달리지 않게만 정리한다.\n response.body?.cancel().catch(() => {});\n throw new ZalkeraError(\n `백엔드가 리다이렉트로 응답했습니다 (HTTP ${response.status}). 보안상 따라가지 않습니다 — ` +\n \"따라가면 요청 헤더(테넌트 시크릿 키·장바구니 세션)가 다른 오리진으로 샙니다. \" +\n \"baseUrl 이 최종 오리진인지 확인하세요(http↔https 승격, www 유무, 프록시의 경로 리라이트).\",\n {status: 502, code: \"UPSTREAM_REDIRECT\"},\n );\n }\n\n // 204 등 본문 없는 응답은 파싱하지 않는다.\n const text = await response.text();\n const parsed = text ? safeJsonParse(text) : null;\n\n if (!response.ok) {\n throw ZalkeraError.fromBody(response.status, parsed);\n }\n // 빈 바디(204·void 엔드포인트 예: confirmPayment)는 정상 — data 접근 없이 통과.\n if (!text) return undefined as T;\n // 성공(2xx)인데 envelope 가 아니면(HTML·비객체 JSON) 게이트웨이/프록시 오염이다.\n // (null).data TypeError 로 새지 않게 구조화 에러로 변환 — BFF errorResponse 가 502 로 서빙(memo 57 §11-4).\n if (parsed === null || typeof parsed !== \"object\") {\n throw new ZalkeraError(\n `서버 응답 형식이 올바르지 않습니다. 잠시 후 다시 시도해주세요. (HTTP ${response.status})`,\n {status: 502, code: \"UPSTREAM_NON_JSON\"},\n );\n }\n return (parsed as ApiResponse<T>).data;\n }\n\n return {\n getSiteConfig: (options) => request<SiteConfig>(\"/public/site-config\", nextInit(options)),\n listCategories: () => request<Category[]>(\"/public/categories\"),\n listPosts: (params) =>\n request<Paginated<PostSummary>>(\"/public/posts\", {\n query: {\n category: params?.category,\n page: params?.page,\n size: params?.size,\n sort: params?.sort,\n },\n }),\n getPost: (slug) => request<PostDetail>(`/public/posts/${seg(slug)}`),\n recordPostView: (slug, context) =>\n request<boolean>(`/public/posts/${seg(slug)}/view`, {\n method: \"POST\",\n context,\n }),\n getMediaUrl: (id) => request<MediaUrl>(`/public/media/${seg(id)}/url`),\n submitInquiry: (input, context) =>\n request<InquiryCreated>(\"/public/inquiries\", {\n method: \"POST\",\n body: input,\n context,\n }),\n submitLead: (input, context) =>\n request<LeadCreated>(\"/public/leads\", {\n method: \"POST\",\n body: input,\n context,\n }),\n\n // ── 커머스: 카탈로그 ────────────────────────────────────\n getProduct: (slug, options) => request<ProductDetail>(`/public/products/${seg(slug)}`, nextInit(options)),\n listProducts: (params, options) =>\n request<Paginated<ProductSummary>>(\"/public/products\", {\n query: {\n productType: params?.productType,\n keyword: params?.keyword,\n categoryId: params?.categoryId,\n page: params?.page,\n size: params?.size,\n sort: params?.sort,\n },\n ...nextInit(options),\n }),\n listProductCategories: (options) => request<ProductCategory[]>(\"/public/product-categories\", nextInit(options)),\n\n // ── 커머스: 상품후기(공개) ──────────────────────────────\n listProductReviews: (productId, params, options) =>\n request<Paginated<Review>>(`/public/products/${seg(productId)}/reviews`, {\n query: {page: params?.page, size: params?.size},\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n getProductReviewSummary: (productId, options) =>\n request<RatingSummary>(`/public/products/${seg(productId)}/reviews/summary`, {\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n createProductReview: (productId, input, accessToken) =>\n request<Review>(`/shop/products/${seg(productId)}/reviews`, {\n method: \"POST\",\n body: input,\n bearer: accessToken,\n }),\n\n // ── 커머스: 고객 인증 ───────────────────────────────────\n socialLogin: (input) => request<AuthTokens>(\"/shop/auth/social\", {method: \"POST\", body: input}),\n refreshSession: (refreshToken) =>\n request<AuthTokens>(\"/shop/auth/refresh\", {method: \"POST\", body: {refreshToken}}),\n logout: (accessToken) => request<string>(\"/shop/auth/logout\", {method: \"POST\", bearer: accessToken}),\n getMe: (accessToken) => request<CustomerSummary>(\"/shop/me\", {bearer: accessToken}),\n getConsents: (accessToken) => request<ConsentStatus[]>(\"/shop/consents\", {bearer: accessToken}),\n updateConsents: (accessToken, consents) =>\n request<string>(\"/shop/consents\", {method: \"POST\", bearer: accessToken, body: {consents}}),\n\n // ── 예약(SERVICE 상품) ───────────────────────────────────\n availability: (params, options) =>\n request<AvailabilitySlot[]>(\"/public/booking/availability\", {\n query: {productId: params.productId, from: params.from, to: params.to},\n next: options?.tags ? {tags: options.tags} : undefined,\n }),\n createBooking: (accessToken, input) =>\n request<Booking>(\"/shop/booking/bookings\", {method: \"POST\", body: input, bearer: accessToken}),\n myBookings: (accessToken) => request<Booking[]>(\"/shop/booking/bookings\", {bearer: accessToken}),\n cancelBooking: (accessToken, bookingCode) =>\n request<string>(`/shop/booking/bookings/${seg(bookingCode)}/cancel`, {\n method: \"POST\",\n bearer: accessToken,\n }),\n rescheduleBooking: (accessToken, bookingCode, newSlotId) =>\n request<Booking>(`/shop/booking/bookings/${seg(bookingCode)}/reschedule`, {\n method: \"POST\",\n body: {newSlotId},\n bearer: accessToken,\n }),\n\n // ── 커머스: 장바구니 ────────────────────────────────────\n getCart: (session) => request<Cart>(\"/shop/cart\", shopInit(session)),\n addToCart: (variantId, quantity, session) =>\n request<Cart>(\"/shop/cart/items\", shopInit(session, {method: \"POST\", body: {variantId, quantity}})),\n updateCartItem: (variantId, quantity, session) =>\n request<Cart>(`/shop/cart/items/${seg(variantId)}`, shopInit(session, {method: \"PATCH\", body: {quantity}})),\n removeFromCart: (variantId, session) =>\n request<Cart>(`/shop/cart/items/${seg(variantId)}`, shopInit(session, {method: \"DELETE\"})),\n clearCart: (session) => request<Cart>(\"/shop/cart\", shopInit(session, {method: \"DELETE\"})),\n\n // ── 커머스: 결제·주문·배송 ──────────────────────────────\n checkout: (input, session, idempotencyKey) =>\n request<OrderDetail>(\"/shop/checkout\", shopInit(session, {method: \"POST\", body: input, idempotencyKey})),\n confirmPayment: (orderNo, providerParams, access) =>\n request<void>(\n `/shop/orders/${seg(orderNo)}/payment/confirm`,\n accessInit(access, {method: \"POST\", body: providerParams}),\n ),\n startPayment: (orderNo, access) =>\n request<PaymentSession>(\n `/shop/orders/${seg(orderNo)}/payment/session`,\n accessInit(access, {method: \"POST\"}),\n ),\n getOrder: (orderNo, access) => request<OrderDetail>(`/shop/orders/${seg(orderNo)}`, accessInit(access)),\n listMyOrders: (accessToken, params) =>\n request<Paginated<OrderSummary>>(\"/shop/orders\", {\n bearer: accessToken,\n query: {page: params?.page, size: params?.size},\n }),\n cancelOrder: (orderNo, access) =>\n request<OrderDetail>(`/shop/orders/${seg(orderNo)}/cancel`, accessInit(access, {method: \"POST\"})),\n completeOrder: (orderNo, access) =>\n request<OrderDetail>(`/shop/orders/${seg(orderNo)}/complete`, accessInit(access, {method: \"POST\"})),\n getShipment: (orderNo, access) =>\n request<ShipmentInfo>(`/shop/orders/${seg(orderNo)}/shipment`, accessInit(access)),\n };\n}\n\n/** [ReadOptions] 를 request init 의 Next 캐시 옵션으로 변환. 태그 없으면 빈 객체(하위호환). */\nfunction nextInit(options?: ReadOptions): {next?: {tags?: string[]}} {\n return options?.tags && options.tags.length > 0 ? {next: {tags: options.tags}} : {};\n}\n\n/** 장바구니·결제 요청의 사용자 식별을 request init 으로 변환. */\nfunction shopInit(\n session: ShopSession,\n extra?: {method?: string; body?: unknown; idempotencyKey?: string},\n): {method?: string; body?: unknown; bearer?: string; cartSession?: string; idempotencyKey?: string} {\n return {...extra, bearer: session.accessToken, cartSession: session.cartSessionKey};\n}\n\n/** 주문 접근(본인 토큰 또는 게스트 phone)을 request init 으로 변환. */\nfunction accessInit(\n access: OrderAccess,\n extra?: {method?: string; body?: unknown},\n): {\n method?: string;\n body?: unknown;\n bearer?: string;\n query?: Record<string, unknown>;\n context?: RequestContext;\n} {\n return {\n ...extra,\n bearer: access.accessToken,\n query: access.phone ? {phone: access.phone} : undefined,\n context: access.context,\n };\n}\n\n/**\n * 리다이렉트 응답 판별 — `redirect: \"manual\"` 의 결과 형상이 **런타임마다 다르다**.\n *\n * - Node(undici, v22 실측): 3xx 응답이 **그대로** 온다 — `status` 302·`type` `\"basic\"`·`ok` false.\n * 301·303·307·308 도 동일하다.\n * - 명세 준수 런타임(브라우저 등): opaque-redirect 필터 응답 — `status` 0·`type` `\"opaqueredirect\"`.\n *\n * 어느 쪽으로 와도 잡아야 보안 불변식이 성립하므로 둘 다 본다.\n */\nfunction isRedirectResponse(response: Response): boolean {\n return response.type === \"opaqueredirect\" || (response.status >= 300 && response.status < 400);\n}\n\nfunction safeJsonParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return null;\n }\n}\n","/**\n * 섹션 어휘 계약 — **정본의 코드 표현**(memo102 §6).\n *\n * 정본은 백엔드 레포의 `doc/contracts/section-vocabulary.json` 이고, 이 파일은 그것을 npm 으로\n * 실어 나르는 **운반체**다. 스토어프론트 렌더러가 이 상수를 읽어 자기 커버리지를 기계로 검사한다 —\n * 사본이 갈라진 채 조용히 굳는 것을 막는 게 목적이지, 실시간 동일성이 목적은 아니다(계약이 원래\n * 스큐 내성으로 설계돼 있다: 미지 타입은 스킵). rev 7 에서 사본은 **둘**이다(이 운반체·렌더러) —\n * 백엔드 `SectionType` enum 과 콘솔 zod 는 거처(DB)와 함께 퇴역했다(memo144).\n *\n * 두 레포가 갈라져 있어 상호 CI 강제가 불가능하므로 **사람 이음새가 정확히 한 곳** 남는다 —\n * 백엔드 JSON ↔ 이 파일. [SECTION_CONTRACT_REV] 를 백엔드 스펙의 `contractRev` 와 맞춰 두고,\n * client 발행 전 `scripts/sync-section-contract.mjs` 로 대조한다.\n */\n\n/**\n * 백엔드 스펙 `contractRev` 와 같아야 한다. 어긋나면 동기 스크립트가 잡는다.\n *\n * rev 4 = **참조 방언·콘텐츠 파일의 1급 승격**(memo129 §1.4). 섹션 타입 12종도 config 키 선언도\n * 안 바뀌었다 — 정본에 `dialects`(id ↔ 참조)와 `contentFile`(`content/pages/*.json`) 절이 생겼고,\n * 이 패키지는 그 방언을 읽는 헬퍼([asHandle]·[asHandleArray]·[assetPath]·[readConfig])를 실어 나른다.\n * 그래서 아래 `SECTION_CONTRACT` 리터럴은 rev 3 과 **바이트 동일**하다(동기 스크립트가 확인한다).\n *\n * rev 5 = **`categorySlug` 를 대등 참조로**(memo139). `SERVICE_MENU`·`BOOKING_CTA` 의 필수성 단위가\n * \"productIds 가 있는가\"에서 **\"참조가 하나라도 있는가\"**(`requiredRefsAnyOf`)로 옮겨갔다. 그 시대의\n * 판단이었고 rev 6 이 뒤집었다(아래).\n *\n * rev 6 = **`SERVICE_MENU`·`BOOKING_CTA` 완전 삭제 — 12종 → 10종**(memo142 §오너확정2-1). rev 3·5 가\n * \"조회형 섹션은 참조를 반드시 실어라\"로 조이던 잣대가 **\"조회형 섹션을 싣지 마라\"로 반전**됐다.\n * 경계 규칙(memo142 §1): 값이 콘텐츠 파일에 사는 저작물은 **선언 섹션**의 소관이고, 값이 업무 DB 에\n * 살고 화면이 비추기만 하는 조회는 **소스가 이 패키지를 직접 호출**해 그린다(`listProducts()`·\n * `listProductCategories()`). 절반 선언(`SERVICE_MENU`)은 \"어디에\"만 선언에 두고 \"어떻게\"(카드 그리드·\n * 필드·개수)를 공유 렌더러에 얼려 두는 형태였고, 그것이 자연어로 다양한 디자인을 만든다는 방향과 반대다.\n *\n * **`retired` 표기가 아니라 삭제**인 이유: 실측상 정당한 잔존 소비자가 0이었고(상용 `page_section`·\n * `product`·`product_category` 전부 0행), memo128 이 이미 `page_section` 계열을 퇴역 방향으로 잡아 뒀다.\n * 제3 상태는 계약·검사기·팩 게이트·콘솔이 각자 해석해야 하는 축을 새로 만든다.\n *\n * ⚠ **계약이 스큐 내성이라 이 삭제가 구 사이트를 깨지 않는다** — 렌더러는 미지 타입을 조용히 스킵한다.\n * 어휘를 강제하지도 않는다(memo125 요건 1): 자기 소스에 무엇을 적든 자유이고, 집행은 **우리 산출물인\n * 팩**에만 선다.\n *\n * rev 7 = **DB 방언 소거 — 거처가 하나 남았다**(memo144). `page`·`page_section`·`menu` 계열이 퇴역하면서\n * 정본의 `dialects.id`(숫자 id 표기)가 가리킬 자리가 없어졌다. rev 4 가 방언을 1급으로 승격하며 rev 를\n * 올렸던 것의 **역연산**이라 서술 정리가 아니라 잣대 변경이다. **아래 리터럴은 rev 6 과 바이트 동일**이다\n * — 섹션 10종·`requiredRefs(AnyOf)` 는 한 글자도 안 바뀐다(동기 스크립트가 확인한다). 이 패키지에서\n * 함께 내려간 것은 그 거처를 읽던 표면이다: `getPage`·`listPages`·`listMenus` 와 그 타입들.\n * 남은 표기는 소스 하나 — `content/pages/*.json` 이 쓰는 참조 표기(`asset` 계열 문자열)이고,\n * 어휘 표의 `assetId` 계열 키 이름은 그 시절 표기가 굳은 것이다(대응은 정본 `dialects.reference`).\n */\nexport const SECTION_CONTRACT_REV = 7;\n\n/** 업종 분류 — 어휘를 묶어 보여 줄 때의 그룹핑이지 사용 제한이 아니다(GENERAL 은 뷰티 사이트도 쓴다). */\nexport type SectionVertical = \"BEAUTY\" | \"GENERAL\";\n\nexport interface SectionSpec {\n readonly type: string;\n readonly vertical: SectionVertical;\n /** 이 섹션이 산출하는 schema.org 타입. null 이면 구조화 데이터 없음. */\n readonly jsonLd: string | null;\n /**\n * **필수 참조 config 키**(정본 `config` 선언에서 `?` 가 없는 `*Id`/`*Ids` 키 — memo119 §2.6-3·rev 3).\n *\n * 계약 전체를 실어 나르지 않고 이 축만 뽑아 오는 이유: 이 값을 읽는 소비자가 **팩 게이트 하나**이고,\n * 그가 답해야 하는 질문이 정확히 \"이 섹션이 아무것도 안 가리킨 채 시드에 들어와 있는가\"이기 때문이다.\n * 아무것도 안 가리킨 참조형 섹션은 렌더러가 `return null` 해서 **개시 직후 조용히 사라지는 섹션**이\n * 된다 — 그 결함이 고객 개시 순간이 아니라 우리 터미널에서 죽게 하는 것이 이 필드의 전부다.\n *\n * ⚠ **rev 6 기준 이 축을 쓰는 타입은 0 이다.** 그 요구를 갖던 둘이 어휘에서 삭제됐기 때문이다.\n * 필드를 남겨 두는 것은 계약 기계를 유지하기 위해서다 — 참조가 필수인 **저작물** 타입이 앞으로\n * 생길 수 있고(에셋 축), 팩 게이트가 이 선언을 읽는 코드도 그대로 선다. 다만 **조회형 타입의 증설로**\n * 이 축이 되살아나는 일은 없다(memo142 §6-2 가 그 문을 닫았다).\n *\n * 키 이름은 정본 그대로 **id 형**이다. 시드가 쓰는 참조형 키로 미리 바꿔 두지 않는 이유: 이 패키지는\n * 정본의 운반체이지 시드 문법의 번역기가 아니고, id↔참조 대응 규칙은 팩 게이트가 자기 자리에서 안다.\n *\n * **필수성의 집행 지점은 팩/시드뿐이다** — 런타임은 그대로 관용이다(렌더러가 그 섹션만 스킵한다).\n */\n readonly requiredRefs: readonly string[];\n /**\n * **참조 그룹**(rev 5) — 각 그룹에서 **하나 이상**이 채워져야 한다.\n *\n * rev 3 은 필수성을 키 하나(`productIds`)에 걸었는데, rev 5 에서 갈래 참조(`categorySlug`)가 같은\n * 역할을 하게 되면서 필수의 단위가 **\"참조 존재\"**로 옮겨갔다. 막으려는 것은 그대로다 — 아무것도\n * 안 가리킨 채 시드에 들어와 개시 직후 조용히 사라지는 섹션.\n *\n * ⚠ `requiredRefs` 와 같이 **rev 6 기준 이 축을 쓰는 타입도 0 이다**(그 둘이 삭제됐다). 남기는\n * 이유도 같다 — 계약 기계는 유지하고, 되살릴 문은 memo142 §6-2 가 닫았다.\n *\n * `requiredRefs`(무조건 필수)와 **함께** 쓴다: 그룹으로 표현되는 섹션은 `requiredRefs` 가 빈 배열이고,\n * 종전처럼 단일 키가 무조건 필수인 섹션은 이 필드가 빈 배열이다. 둘 다 빈 배열이면 참조 요구가 없다.\n */\n readonly requiredRefsAnyOf: readonly (readonly string[])[];\n}\n\n/**\n * 아는 섹션 전량(rev 7 기준 **10종** — rev 6 과 동일). **순서는 관행 아크**(주목→가치→신뢰→행동)이고,\n * 어휘를 목록으로 보여 주는 자리는 이 순서를 그대로 쓰면 된다. 값 추가는 백엔드 스펙을 먼저 고친 뒤 여기로 옮긴다.\n *\n * `requiredRefs` 는 빈 배열이라도 **반드시 적는다**. 생략을 허용하면 동기 스크립트의 리터럴 정규식이\n * 그 항목을 통째로 못 읽고 \"client 누락\"으로 시끄럽게 죽는 대신, 오타 하나가 게이트를 조용히 끄는\n * 길이 열린다 — 빠뜨림이 침묵이 아니라 실패가 되는 형상을 고른다.\n */\nexport const SECTION_CONTRACT = [\n // 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).\n // 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는\n // 진열이 낸다(보장표 `service-menu-itemlist` 의 route 는 원래 `any` — 판정 지점은 산출물이다).\n {type: \"BEFORE_AFTER_GALLERY\", vertical: \"BEAUTY\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"DOCTOR_INTRO\", vertical: \"BEAUTY\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n // 기업 마케팅(memo102 §2)\n {type: \"HERO\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"FEATURE_GRID\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"TEXT_MEDIA\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"LOGO_WALL\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"STATS_BAND\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"TESTIMONIALS\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"FAQ_LIST\", vertical: \"GENERAL\", jsonLd: \"FAQPage\", requiredRefs: [], requiredRefsAnyOf: []},\n {type: \"LEAD_CTA\", vertical: \"GENERAL\", jsonLd: null, requiredRefs: [], requiredRefsAnyOf: []},\n] as const satisfies readonly SectionSpec[];\n\n/** 지금 렌더러가 아는 섹션 타입. 이 밖의 값이 와도 정상이다(스킵). */\nexport type KnownSectionType = (typeof SECTION_CONTRACT)[number][\"type\"];\n\n/** 업종별 필터 — 어휘를 업종으로 묶어 볼 때 쓴다. */\nexport function sectionsOfVertical(vertical: SectionVertical): readonly SectionSpec[] {\n return SECTION_CONTRACT.filter((s) => s.vertical === vertical);\n}\n","/**\n * 사용자·콘솔 입력 URL 을 링크로 쓰기 전 소독한다 — **저장형 XSS 방어 + 오픈 리다이렉트 방어**.\n *\n * 콘솔 메뉴 url·섹션 config 의 href 는 사람이 입력하는 값이라 `javascript:alert(document.cookie)` 가\n * 들어올 수 있고, 백엔드는 스킴을 검증하지 않는다(길이·trim 뿐). react-dom 이 prod 번들에서\n * `javascript:` href 를 막긴 하지만 **dev 에서는 raw 로 실리고**, 프레임워크 내부 동작에 보안을\n * 의존하는 것 자체가 취약하다. 그래서 이건 **심층 방어**다 — 렌더 소스에서 스킴을 거른다.\n *\n * 허용: 내부 절대경로(`/about`)·http(s)·mailto·tel. 그 외(javascript:·data:·vbscript: 등)는 `#` 으로\n * 무력화한다(링크는 남되 스크립트는 안 돈다). 메뉴는 외부 링크가 정당하므로 내부로만 좁히지 않고\n * 스킴 허용목록을 쓴다.\n */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"]);\n\n/** 내부 경로 판정용 가짜 오리진 — 이 값으로 정규화되면 \"밖으로 안 나간다\"가 참이다. */\nconst DUMMY_ORIGIN = \"https://zalkera.invalid\";\n\n/**\n * 내부 절대경로로 **정규화**한다 — 밖으로 나가는 형태면 `null`.\n *\n * ⚠ **문자 검사로는 못 막는다.** URL 파서(브라우저 포함)는 탭·CR·LF 를 URL 어디에서든 제거한 뒤\n * 해석하고, 역슬래시를 `/` 로 접는다. 그래서 `startsWith(\"/\") && !startsWith(\"//\")` 는 다음을\n * 전부 통과시킨다:\n * `/<TAB>/evil.example` · `/<CR>/evil.example` · `/\\evil.example`\n * 셋 다 `/` 로 시작하고 `//` 로 시작하지 않지만 **브라우저는 `https://evil.example/` 로 읽는다.**\n * 저장형 XSS 는 아니지만 오픈 리다이렉트·피싱이고, 이 값의 출처는 콘솔·AI·고객 zip 이라\n * 신뢰 경계 밖이다.\n * 재현: `node -e 'console.log(new URL(\"/\\t/evil.example\",\"https://t.example\").origin)'`\n *\n * ⚠ **입력이 아니라 돌려주는 값을 판정한다.** `new URL(raw, DUMMY_ORIGIN).origin` 하나로 끝내면\n * 그건 **입력**이 내부인지만 묻는다. `/..//evil.example` 은 그 판정을 통과하고 정규화 결과가\n * `//evil.example` 이 되며, 그 값이 href·`Location` 으로 소비되면 브라우저는 **프로토콜 상대\n * URL** 로 읽어 `https://evil.example/` 로 간다.\n * 그래서 소독기는 **자기 출력이 자기 입력 판정을 다시 통과해야** 한다. 그 멱등성을 여기서 건다.\n * `//` 를 문자로 거르지 않는 이유도 같다 — 문자 목록은 다음 형태에서 또 진다\n * (`/%2e%2e//`·`/a/b/../..//`·`/./..//` 가 전부 같은 값으로 정규화된다).\n * 재현: `node -e 'console.log(new URL(\"/..//evil.example\",\"https://zalkera.invalid\").pathname)'`\n *\n * ⚠ **이 함수가 판정의 단 하나의 자리다.** 규칙을 다른 파일로 옮겨 적지 말고 이것을 불러라 —\n * 같은 규칙이 베껴져 있으면 한쪽만 고쳐지고 다른 쪽이 취약하게 남는다(`assetPath` 만 고치고\n * 이 함수를 두었던 판이 그랬다).\n */\nexport function internalPath(raw: string): string | null {\n let out: string;\n try {\n const parsed = new URL(raw, DUMMY_ORIGIN);\n if (parsed.origin !== DUMMY_ORIGIN) return null;\n out = parsed.pathname + parsed.search + parsed.hash;\n } catch {\n return null;\n }\n try {\n if (new URL(out, DUMMY_ORIGIN).origin !== DUMMY_ORIGIN) return null;\n } catch {\n return null;\n }\n return out;\n}\n\nexport function safeLinkUrl(raw: string | null | undefined): string {\n if (!raw) return \"#\";\n const url = raw.trim();\n\n // 현재 페이지 기준 조각·질의는 그대로 둔다 — 호스트를 바꿀 수 없어 안전하고, 정규화하면\n // `#top` 이 `/#top` 이 되어 **다른 페이지에서 홈으로 튄다**.\n if (url.startsWith(\"#\") || url.startsWith(\"?\")) return url;\n\n // 내부 절대경로 — 판정도 정규화도 파서가 한다. 통과 값은 원문이 아니라 정규화된 것을 돌려준다\n // (제어문자가 섞인 원문을 그대로 넘기면 소비자가 또 다르게 해석할 여지가 남는다).\n if (url.startsWith(\"/\")) return internalPath(url) ?? \"#\";\n\n try {\n const parsed = new URL(url); // 절대 URL 만 파싱된다(상대면 throw)\n return ALLOWED_SCHEMES.has(parsed.protocol) ? url : \"#\";\n } catch {\n // 파싱 실패 = 스킴 없는 상대경로(anchor#·?query 등). 여기도 파서로 재판정한다 —\n // `\\evil.example` 같은 값이 \"파싱 실패\"로 빠져나가던 갈래를 닫는다.\n try {\n return internalPath(url) ?? \"#\";\n } catch {\n return \"#\";\n }\n }\n}\n","/**\n * `X-Forwarded-For` 에서 **원 방문자 IP** 를 뽑는다. 백엔드 `ClientUtils.resolveClientIp` 의 **계약 거울**이다\n * (같은 입력에 같은 값 — 테스트 벡터를 백엔드에서 그대로 이식해 드리프트를 막는다).\n *\n * ```ts\n * // route handler 안\n * import {visitorIp} from \"@zalkera/client\";\n * const ip = visitorIp(req.headers); // 프록시 1단(기본)\n * await zalkera.submitInquiry(input, {clientIp: ip});\n * ```\n *\n * ## 왜 이 함수가 있는가 — `xff.split(\",\")[0]` 는 위조된다\n *\n * `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가\n * 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 프록시는 그 뒤에 진짜 IP 를 붙이므로 헤더는\n * `9.9.9.9, <진짜IP>` 가 된다 — **첫 엔트리는 공격자가 쓴 문자열**이다. 첫 홉을 채택하는 코드는 그래서\n * 레이트리밋이 한 줄로 우회되고(요청마다 IP 를 바꾸면 버킷이 매번 새로 생긴다) IP 기록이 오염된다.\n *\n * 옳은 채택 지점은 **우리가 통제하는 프록시들이 붙인 블록의 가장 바깥(왼쪽) 엔트리** = 그 방문자가 우리\n * 최외곽 프록시에 연결할 때 쓴 IP 다. 인덱스로는 `길이 - 신뢰홉수`.\n *\n * ## 보장 경계 — **여기까지만 참이다**\n *\n * 이 함수는 \"부르기만 하면 안전\"을 팔지 않는다. 파는 것은 **\"선언한 홉 수가 참인 만큼 안전\"** 이다.\n * 자유 변수는 정수 하나([VisitorIpOptions.trustedHops])이고, 그 값이 틀리면 결과도 틀린다:\n *\n * | 선언 vs 실제 | 채택되는 값 | 결과 | 드러남 |\n * |---|---|---|---|\n * | 선언 **<** 실제(과소) | 안쪽 인프라 IP(CDN 엣지 등) | 방문자 전원이 한 IP 로 뭉침 → 429 폭주 | **가시** — 즉시 눈에 밟힌다 |\n * | 선언 **=** 실제 | 방문자 IP | 정상 | — |\n * | 선언 **>** 실제(과대) | **공격자가 넣은 임의 엔트리** | 위조 관통 | **비가시 — 조용히 뚫린다** |\n *\n * 그래서 규율은 하나다: **선언은 실제 이하로만.** 기본값 1 은 \"항상 안전\"이 아니라 **\"위험한 방향으로는\n * 기본값이 데려가지 않는\"** 값이다 — 과대 선언에는 당신이 직접 큰 수를 적어야만 도달한다.\n *\n * **지원하지 않는 배포**: 리버스 프록시 **0단 직노출**(Node 를 인터넷에 직접 붙인 형태). 거기서는\n * `X-Forwarded-For` **전체가** 방문자가 쓴 값이라 어떤 홉 수를 넣어도 이 함수는 위조를 돌려준다.\n * 그 배포에서는 이 함수를 쓰지 말고 소켓 IP 를 플랫폼 수단으로 직접 얻어라 — 이 함수는 그 경우를\n * 흡수하는 척하지 않는다.\n *\n * **우리가 모르는 것**: 당신의 프록시 단 수. 그래서 묻는다(옵션·env). 자동 감지는 **하지 않는다** —\n * 결정적 신호가 없고(`CF-Connecting-IP` 존재도 신호가 못 된다: CF 밖에서 위조 가능), 추정은 과대 선언과\n * 같은 위험이다. `x-real-ip` 폴백도 **하지 않는다** — 세우는 주체가 프록시마다 다르고 안 세우면 위조 자유다.\n *\n * **잘커라가 서빙하는 사이트라면** 이 값을 고민할 필요가 없다. 서빙 프록시가 방문자 IP **단일 엔트리**로\n * `X-Forwarded-For` 를 재작성하므로 \"신뢰 홉 = 1\" 이 구성상 참이고, 그것이 이 함수의 기본값이다.\n *\n * @param headers `Headers` 또는 `NextRequest.headers` — `get(name)` 하나만 쓴다.\n * @param options 홉 수 명시. 우선순위: 명시 > env `ZALKERA_TRUSTED_PROXY_HOPS` > 기본 1.\n * @returns 원 방문자 IP. 정할 수 없으면 `undefined`(문자열 `\"unknown\"` 을 지어내지 않는다) —\n * 그대로 `RequestContext.clientIp` 에 넣으면 되고, 클라이언트가 헤더를 생략한다.\n */\nexport function visitorIp(headers: HeaderReader, options?: VisitorIpOptions): string | undefined {\n const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;\n if (hops <= 0) return undefined; // XFF 를 아예 신뢰하지 않겠다는 선언 — 백엔드의 `trustedHops <= 0` 과 같은 뜻.\n const raw = headers?.get?.(\"x-forwarded-for\");\n if (raw == null || raw.trim() === \"\") return undefined;\n const parts = raw\n .split(\",\")\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n if (parts.length === 0) return undefined;\n // 백엔드와 **같은 클램프**: 엔트리가 홉 수보다 적으면(설정 과다 또는 직결 정황) 최좌단으로 접는다.\n const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);\n return parts[index] || undefined;\n}\n\n/** `get(name)` 만 요구한다 — `Headers`·`NextRequest.headers`·직접 만든 객체가 전부 들어맞는다. */\nexport interface HeaderReader {\n get(name: string): string | null;\n}\n\nexport interface VisitorIpOptions {\n /**\n * 우리가(=당신이) 통제해 `X-Forwarded-For` 를 append 하는 프록시 **단 수**. 기본 1.\n * 예: nginx 하나=1 · CDN+로드밸런서=2. **실제보다 크게 적지 마라** — 위 표의 '과대' 행이 조용한 구멍이다.\n * `0` 이하를 주면 XFF 를 신뢰하지 않겠다는 뜻이라 항상 `undefined` 를 돌려준다.\n */\n trustedHops?: number;\n}\n\n/** 프록시 1단. 의미 있는 최솟값이고, 틀리는 방향이 **가시적 실패** 쪽이다. */\nconst DEFAULT_TRUSTED_HOPS = 1;\n\n/** 홉 수 선언 환경변수. 서버 전용 값이라 `NEXT_PUBLIC_` 접두를 붙이지 마라(브라우저 번들에 박힌다). */\nconst HOPS_ENV = \"ZALKERA_TRUSTED_PROXY_HOPS\";\n\nlet envWarned = false;\n\n/**\n * env 선언 읽기. **못 읽는 값은 조용히 삼키지 않는다** — 오타 하나로 선언이 무시되면 사용자는 2를 적어\n * 놓고 1로 도는 상태를 모른 채 산다(그 방향은 '과소'라 429 로 드러나지만, 원인을 찾는 데 시간이 든다).\n * 한 번만 경고하고 기본값으로 간다.\n */\nfunction hopsFromEnv(): number | undefined {\n const raw = typeof process !== \"undefined\" ? process.env?.[HOPS_ENV] : undefined;\n if (raw == null || raw.trim() === \"\") return undefined;\n const parsed = Number(raw);\n if (Number.isInteger(parsed)) return parsed;\n if (!envWarned) {\n envWarned = true;\n console.warn(\n `@zalkera/client: ${HOPS_ENV}=\"${raw}\" 는 정수가 아닙니다 — 기본값 ${DEFAULT_TRUSTED_HOPS} 로 진행합니다.`,\n );\n }\n return undefined;\n}\n","import {seg} from \"./pathSegment\";\n\n/**\n * 섹션 config 파싱 — **절대 throw 하지 않는다.**\n *\n * config 는 백엔드가 검증하지 않는 raw JSON 이다(의도된 설계 — 사이트마다 백엔드를 배포하지\n * 않으려고). 게다가 콘솔의 raw 편집기는 **JSON 문법만** 보고 저장을 허용한다 — 즉 문법은\n * 맞는데 형상이 다른 config 가 **정상 경로로** 들어온다. `{\"productIds\": 5}` 같은 것.\n *\n * 그래서 파싱이 성공했다고 필드를 믿으면 안 된다. `.map`·`.trim` 을 그냥 부르면 그 순간\n * **페이지 전체가 500** 이 난다 — 계약은 \"그 섹션만 사라진다\" 였다. 아래 가드들이 그 계약을\n * 지키는 장치다. [parseThemeColors] 와 같은 사상.\n *\n * **이 패키지에 사는 이유**(memo108 §1): 이건 표현이 아니라 **계약을 안전하게 읽는 법**이다. 마크업은\n * 만드는 쪽 자유지만, '섹션 하나가 사이트를 죽이지 않는다'는 계약은 한 벌이어야 한다 — 사본이 갈라지면\n * 그 계약이 조용히 깨진다(471946c 교훈).\n */\nexport function parseConfig<T>(config: string | null): T | null {\n if (!config) return null;\n try {\n const parsed: unknown = JSON.parse(config);\n return parsed && typeof parsed === \"object\" && !Array.isArray(parsed) ? (parsed as T) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * config 를 **입력 형태와 무관하게** 읽는다 — 문자열이면 파싱하고, 이미 객체면 그대로 본다(rev 4).\n *\n * 계약이 말하는 config 는 **객체**다(`content/pages/*.json` 의 `sections[].config`). 문자열도 받는 이유는\n * 둘이다: ⑴ 손으로 고치는 파일이라 config 를 통째 문자열로 적어 넣는 일이 실제로 있고 ⑵ 종전의 다른\n * 거처(DB 컬럼)가 문자열을 줬다 — 그 거처는 rev 7 에서 사라졌지만(memo144) 관용은 append-only 로 남긴다.\n * 소비자가 두 갈래로 갈리면 섹션 컴포넌트가 두 벌이 되고, 그것이 이 패키지가 사본을 안 만드는 이유\n * 그대로다. 그래서 입구를 하나로 좁힌다.\n *\n * [parseConfig] 와 같은 계약: **절대 throw 하지 않고**, 객체가 아니면 `null`(배열도 null — 섹션 config\n * 는 객체다). 기존 `parseConfig` 는 그대로 둔다(append-only).\n */\nexport function readConfig<T>(config: unknown): T | null {\n if (typeof config === \"string\") return parseConfig<T>(config);\n return config != null && typeof config === \"object\" && !Array.isArray(config) ? (config as T) : null;\n}\n\n/** 양의 정수 id 만 남긴다 — 배열이 아니면 빈 배열. */\nexport function asIdArray(value: unknown): number[] {\n if (!Array.isArray(value)) return [];\n return value.filter((v): v is number => typeof v === \"number\" && Number.isInteger(v) && v > 0);\n}\n\n/** 객체 배열만 — 아니면 빈 배열. */\nexport function asObjectArray(value: unknown): Record<string, unknown>[] {\n if (!Array.isArray(value)) return [];\n return value.filter((v): v is Record<string, unknown> => v != null && typeof v === \"object\" && !Array.isArray(v));\n}\n\n/** 문자열이 아니면 undefined — 숫자에 `.trim()` 을 부르는 사고를 막는다. */\nexport function asString(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\n/** 양의 정수가 아니면 undefined. */\nexport function asId(value: unknown): number | undefined {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0 ? value : undefined;\n}\n\n/**\n * 미디어는 프록시로만 — presigned URL 을 마크업에 넣지 않는다(만료되면 깨진 이미지가 박제된다).\n *\n * 경로 조각은 URL 인코딩한다 — 선언은 `number` 지만 실제 인자는 백엔드가 검증하지 않는 raw config\n * 에서 온다(이 파일 상단 참고). 인코딩이 없으면 `../` 이 프록시 라우트 밖으로 새어 나간다.\n * **throw 하지 않는다**는 이 파일의 계약은 그대로다 — 인코딩만 하고 값을 판정하지 않는다.\n */\nexport function mediaSrc(assetId: number): string {\n return `/media/${seg(assetId)}`;\n}\n\n/*\n * ── 참조 방언 소비(rev 4 · memo129 §1.1) ────────────────────────────────────\n *\n * 소스가 사이트의 얼굴을 정본으로 가지므로(`zalkera.content = \"source\"`) config 는 숫자 id 를 쓸 수 없다 —\n * id 는 백엔드가 발급하고 테넌트마다 다른데, 소스는 고객이 소유하고 그대로 다른 곳에 재업로드된다.\n * 그래서 섹션 참조는 **에셋 = public 루트 절대 경로**로 적는다(rev 7 기준 계약이 아는 참조 축은 이\n * 하나다 — 상품·갈래는 선언이 아니라 소스의 직접 호출로 그린다·rev 6). 계약 정본은 백엔드\n * `doc/contracts/section-vocabulary.json` 의 `dialects.reference` 이고, 여기 셋은 그 표기를 안전하게\n * 읽는 법이다. 위의 id 형 가드와 **같은 계약**: 절대 throw 하지 않고, 형이 안 맞으면 그 값만 사라진다.\n */\n\n/** 상품 handle 하나. 문자열이 아니거나 공백뿐이면 `undefined` — 형식은 검사하지 않는다(런타임은 관용). */\nexport function asHandle(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const handle = value.trim();\n return handle === \"\" ? undefined : handle;\n}\n\n/**\n * 상품 handle 배열. **배열 순서를 보존한다** — 이 배열이 노출 순서의 원장이고, 같은 배열에서 만든\n * ItemList 가 화면과 같은 순서로 나가야 한다.\n * 배열이 아니면 빈 배열, 원소 중 handle 이 아닌 것만 떨군다. 중복은 남긴다 — 같은 상품을 두 번\n * 진열하는 것이 계약 위반은 아니다.\n *\n * ⚠ **어휘 rev 6 이후 이 헬퍼의 소비자는 섹션 config 가 아니라 소스다.** 조회형 섹션 타입이 삭제되면서\n * \"선언에 적힌 handle 목록\"이라는 입력이 계약에서 사라졌다. 헬퍼를 남기는 이유는 소스가 자기 큐레이션\n * (자기 카탈로그 안의 자기 handle — 정당하다)을 배열로 들고 다닐 때 여전히 쓰이기 때문이다.\n */\nexport function asHandleArray(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n return value.map(asHandle).filter((h): h is string => h !== undefined);\n}\n\n/**\n * 레포 `public/` 에이셋 경로. **루트 절대 경로만** 통과한다.\n *\n * 왜 `safeLinkUrl` 을 그대로 안 쓰는가: 링크는 외부로 나가는 것이 정당하지만(스킴 허용목록),\n * 이 값은 `<img src>` 로 들어가는 **레포 안 파일 참조**다. 원격 호스트를 허용하면 ⑴ 개시된 사이트가\n * 남의 서버에 의존해 조용히 깨지고 ⑵ 방문자 IP·리퍼러가 그 호스트로 새며 ⑶ `data:`·`javascript:`\n * 가 같은 구멍으로 들어온다. 원격 이미지가 필요한 레포는 계약 영역이 아니라 자유 영역에서 자기\n * 컴포넌트로 그린다 — 계약이 자유를 막지는 않되, 계약이 보증하는 범위는 좁게 잡는다.\n *\n * 막는 것: 스킴 있는 URL(`http:`·`data:`·`javascript:`) · 스킴 상대(`//host`) · 상대 경로 ·\n * 경로 탈출(`..`) · 역슬래시(윈도 경로·정규화 우회) · 널바이트. **절대 throw 하지 않는다.**\n *\n * ## 판정은 **파서**가 한다 — 문자 검사로는 못 막는다\n *\n * URL 파서(브라우저 포함)는 **탭·CR·LF 를 URL 어디에서든 제거한 뒤** 해석한다. 그래서\n * `\"/\\t/evil.com\"` 은 `startsWith(\"//\")` 를 피해 놓고 실제로는 `//evil.com` 이 되어\n * `https://evil.com/` 로 나간다 — 문자검사가 보는 문자열과 브라우저가 보는 URL 이 다르다.\n *\n * 재현: `node -e 'console.log(new URL(\"/\\t/evil.com\",\"https://t.example\").href)'` → `https://evil.com/`\n *\n * 그래서 같은 리포의 `safeUrl.ts` 와 같은 규율을 쓴다: **파싱해서 출처가 안 바뀌었는지 본다.**\n * 통과한 값은 파서가 정규화한 결과를 돌려주므로, 이후 어느 소비자가 다시 파싱해도 같은 것을 본다.\n */\nconst ASSET_DUMMY_ORIGIN = \"https://zalkera.invalid\";\n\nexport function assetPath(value: unknown): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const path = value.trim();\n if (!path.startsWith(\"/\") || path.startsWith(\"//\")) return undefined;\n if (path.includes(\"\\\\\") || path.includes(\"\\0\")) return undefined;\n // `..` 은 경로 조각으로 있을 때만 탈출이다 — `/img/a..b.png` 같은 정상 파일명을 벌하지 않는다.\n if (path.split(\"/\").includes(\"..\")) return undefined;\n\n let parsed: URL;\n try {\n parsed = new URL(path, ASSET_DUMMY_ORIGIN);\n } catch {\n return undefined;\n }\n // 출처가 바뀌었으면 그 값은 이미 외부를 가리킨다(탭·CR·LF 로 `//host` 가 된 경우가 여기서 잡힌다).\n if (parsed.origin !== ASSET_DUMMY_ORIGIN) return undefined;\n const out = parsed.pathname + parsed.search + parsed.hash;\n // 정규화 결과를 한 번 더 통과시킨다 — 파서가 만든 값이 다시 이탈하지 않는지.\n if (!out.startsWith(\"/\") || out.startsWith(\"//\")) return undefined;\n return out;\n}\n","/**\n * 테넌트 테마 색 파서 (서버 전용).\n *\n * `SiteConfig.themeColors` 는 파싱하지 않은 raw JSON 문자열이다(백엔드 콘솔 `site.theme.update` 가\n * 넣는다). 같은 JSON 에 `layout` 등 이종 키가 병합돼 오므로 **색 키만 화이트리스트로** 읽는다.\n * 어떤 쓰레기가 와도 죽지 않는다(섹션 parse.ts 와 같은 사상) — 실패하면 빈 오버라이드(기본 테마).\n *\n * 읽어들인 색은 globals.css 의 `@theme` 변수를 `<html>` inline style 로 덮어, `bg-primary` 등\n * 전 유틸리티가 테넌트 색으로 바뀐다(layout.tsx). CSS 주입 방어를 위해 값은 hex 형식 검증 후에만 싣는다.\n *\n * **이 패키지에 사는 이유**(memo108 §1): L1(말로 색 바꾸기)의 기계는 표현이 아니라 계약이다 — 어떤 토큰을\n * 쓸지는 코드가 정하고 값은 config 가 정한다는 규약(memo65 §3)의 집행부이고, 순수 함수라 React 에 안 매인다.\n * 이 함수의 반환을 `<html style={...}>` 에 싣는 **배선은 각 사이트의 몫**이다(validator S8 이 그걸 센다).\n */\n\n/** `#rgb`/`#rrggbb` 만 통과. 임의 문자열을 style 에 싣지 않는다(CSS 주입 방어). */\nconst HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\n\n/** 콘솔 스키마의 색 키 → globals.css `@theme` 변수명 매핑. 이 키만 읽는다. */\nconst COLOR_KEYS: ReadonlyArray<readonly [string, string]> = [\n [\"primary\", \"--color-primary\"],\n [\"secondary\", \"--color-secondary\"],\n [\"background\", \"--color-background\"],\n [\"text\", \"--color-foreground\"],\n];\n\n/*\n * ── 전역 토큰 knob (memo69 §2.1 · 계약버전 2) ──────────────────────────────\n * 색과 달리 knob 은 **enum**이다. 백엔드 `site.theme.update` 화이트리스트가 허용값만 저장하지만,\n * 여기서도 매핑 테이블 lookup 으로 한 번 더 봉한다: **사용자 문자열은 어떤 경로로도 style 에 실리지\n * 않는다** — enum 은 테이블 key 조회뿐이고, 없는 값(오염·구버전)은 조용히 버려 @theme 기본값을 유지한다.\n * 이 값들은 백엔드 enum(FONTS/RADII/DENSITIES)과 반드시 일치해야 한다(계약).\n */\n\n/** 시스템 폰트 스택(globals.css --font-sans 와 동일 — system knob 의 기본값). */\nconst SYSTEM_STACK =\n 'ui-sans-serif, system-ui, -apple-system, \"Apple SD Gothic Neo\", \"Malgun Gothic\", \"Noto Sans KR\", sans-serif';\n\n/*\n * font: --font-sans 스택 교체.\n * TODO(memo69 §2.1 후속): pretendard·noto-serif-kr 의 self-host `@font-face` woff2 서브셋 자산 번들링.\n * 지금은 폰트 **패밀리명만** 스택 앞에 얹어, 사용자 환경에 설치돼 있으면 쓰고 없으면 폴백한다\n * (외부 CDN 0 유지). 자산이 동봉되면 여기 매핑은 그대로 두고 globals.css 에 @font-face 만 추가하면 된다.\n */\nconst FONTS: Readonly<Record<string, string>> = {\n system: SYSTEM_STACK,\n pretendard: `\"Pretendard\", ${SYSTEM_STACK}`,\n \"noto-serif-kr\": '\"Noto Serif KR\", ui-serif, Georgia, \"Nanum Myeongjo\", serif',\n};\n\n/** radius: --radius-knob 무단위 배수(globals.css radius 스케일이 곱한다). soft=1 이 Tailwind 기본과 일치. */\nconst RADII: Readonly<Record<string, string>> = {sharp: \"0\", soft: \"1\", round: \"2\"};\n\n/** density: --spacing 베이스(Tailwind v4 기본 0.25rem). 전 spacing 유틸리티가 일괄 스케일. */\nconst DENSITIES: Readonly<Record<string, string>> = {compact: \"0.22rem\", cozy: \"0.25rem\"};\n\n/** knob 키 → CSS 변수 → enum 매핑 테이블. */\nconst KNOB_KEYS: ReadonlyArray<readonly [string, string, Readonly<Record<string, string>>]> = [\n [\"font\", \"--font-sans\", FONTS],\n [\"radius\", \"--radius-knob\", RADII],\n [\"density\", \"--spacing\", DENSITIES],\n];\n\nexport interface ParsedTheme {\n /** `<html style={...}>` 에 그대로 얹을 CSS 변수 맵. 오버라이드가 없으면 빈 객체(기본 테마 유지). */\n cssVars: Record<string, string>;\n}\n\nexport function parseThemeColors(raw: string | null | undefined): ParsedTheme {\n const cssVars: Record<string, string> = {};\n if (!raw) return {cssVars};\n\n let obj: unknown;\n try {\n obj = JSON.parse(raw);\n } catch {\n return {cssVars};\n }\n if (obj == null || typeof obj !== \"object\" || Array.isArray(obj)) return {cssVars};\n\n const src = obj as Record<string, unknown>;\n let primaryHex: string | undefined;\n for (const [key, cssVar] of COLOR_KEYS) {\n const value = src[key];\n if (typeof value === \"string\" && HEX.test(value)) {\n cssVars[cssVar] = value;\n if (key === \"primary\") primaryHex = value;\n }\n }\n\n // 콘솔 스키마에 primary-foreground 키가 없다 — primary 가 오버라이드되면 대비가 큰 쪽을\n // 서버가 산출해 함께 주입한다(테넌트가 밝은 액센트를 골라도 CTA 글자가 안 죽는다).\n if (primaryHex) {\n cssVars[\"--color-primary-foreground\"] = onColor(primaryHex);\n }\n\n // 전역 토큰 knob(font·radius·density) — 매핑 테이블 lookup 만. hasOwnProperty 로 own key 만 채택해\n // 프로토타입 오염(constructor 등)·오염 문자열을 차단한다(사용자 문자열이 style 에 실리지 않는다).\n for (const [key, cssVar, table] of KNOB_KEYS) {\n const value = src[key];\n if (typeof value !== \"string\") continue;\n // hasOwnProperty 로 own key 만 채택한다 — 프로토타입 오염(constructor 등)을 차단하는 그 검사다.\n // 다만 그것으로 타입이 좁혀지지는 않으므로(strict), 조회 결과를 직접 확인해 싣는다.\n if (!Object.prototype.hasOwnProperty.call(table, value)) continue;\n const mapped = table[value];\n if (mapped !== undefined) cssVars[cssVar] = mapped;\n }\n\n return {cssVars};\n}\n\n/** primary 위에 얹을 글자색 — WCAG 상대 휘도로 흰색/slate-950 중 대비 큰 쪽. */\nfunction onColor(hex: string): string {\n return relativeLuminance(hex) > 0.4 ? \"#020617\" /* slate-950 */ : \"#ffffff\";\n}\n\n/** WCAG 상대 휘도(0~1). 검증된 hex 만 들어온다(HEX 통과분). */\nfunction relativeLuminance(hex: string): number {\n // `.map` 은 튜플성을 잃어 number[] 가 되고, 그러면 구조분해가 undefined 를 낳는다(strict).\n // 튜플을 유지한 채 채널별로 편다.\n const [r0, g0, b0] = toRgb(hex);\n const lin = (c: number) => {\n const s = c / 255;\n return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n };\n return 0.2126 * lin(r0) + 0.7152 * lin(g0) + 0.0722 * lin(b0);\n}\n\nfunction toRgb(hex: string): [number, number, number] {\n // `#rgb` 축약을 `#rrggbb` 로 편다. slice 로 자르는 것은 인덱스 접근과 달리 undefined 가 안 나온다\n // (이 패키지는 noUncheckedIndexedAccess 라 `h[0]` 은 string|undefined 다 — 검증된 hex 만 들어온다는\n // 사실을 타입으로 증명할 수 없으므로, 증명이 필요 없는 방식으로 쓴다).\n let h = hex.slice(1);\n if (h.length === 3) {\n h = h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\");\n }\n return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n}\n"]}