@zalkera/client 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/bin/validate-storefront.mjs +237 -1
- package/dist/index.cjs +41 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +127 -114
- package/dist/index.d.ts +127 -114
- package/dist/index.js +41 -11
- package/dist/index.js.map +1 -1
- package/llms.txt +131 -60
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ export const zalkera = createZalkeraClient({
|
|
|
47
47
|
```ts
|
|
48
48
|
const posts = await zalkera.listPosts({size: 10, sort: "publishedAt,desc"});
|
|
49
49
|
const post = await zalkera.getPost("hello-world");
|
|
50
|
-
const
|
|
50
|
+
const site = await zalkera.getSiteConfig({tags: ["site-config"]});
|
|
51
51
|
const product = await zalkera.getProduct("tee", {tags: ["product:tee"]});
|
|
52
52
|
await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
53
53
|
```
|
|
@@ -83,13 +83,13 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
83
83
|
| `listPosts(params?)` | `GET /api/public/posts` | `Paginated<PostSummary>` |
|
|
84
84
|
| `getPost(slug)` | `GET /api/public/posts/{slug}` | `PostDetail` |
|
|
85
85
|
| `recordPostView(slug, ctx?)` | `POST /api/public/posts/{slug}/view` | `boolean` |
|
|
86
|
-
| `getPage(slug, options?)` | `GET /api/public/pages/{slug}` | `PageContent` |
|
|
87
|
-
| `listPages(params?, options?)` | `GET /api/public/pages` | `Paginated<PageSummary>` |
|
|
88
|
-
| `listMenus(options?)` | `GET /api/public/menus` | `Menu[]` |
|
|
89
86
|
| `getMediaUrl(id)` | `GET /api/public/media/{id}/url` | `MediaUrl` |
|
|
90
87
|
| `submitInquiry(input, ctx?)` | `POST /api/public/inquiries` | `InquiryCreated` |
|
|
91
88
|
| `submitLead(input, ctx?)` | `POST /api/public/leads` | `LeadCreated` |
|
|
92
89
|
|
|
90
|
+
- **고정 페이지·섹션·내비는 API 표면이 아니다.** 사이트의 얼굴은 레포 파일이 정본이라
|
|
91
|
+
(`content/pages/*.json`·`content/nav.json`) 백엔드 왕복이 없다 — 배선은 `llms.txt` §4.8·§9.
|
|
92
|
+
|
|
93
93
|
### 커머스 카탈로그·후기(공개)
|
|
94
94
|
|
|
95
95
|
| 메서드 | 엔드포인트 | 반환 |
|
|
@@ -101,7 +101,6 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
101
101
|
| `getProductReviewSummary(productId, options?)` | `GET /api/public/products/{id}/reviews/summary` | `RatingSummary` |
|
|
102
102
|
| `createProductReview(productId, input, accessToken)` | `POST /api/shop/products/{id}/reviews` | `Review` |
|
|
103
103
|
|
|
104
|
-
- `listPages`는 **열거 전용**(본문 없음 — sitemap용). `size` 상한 100.
|
|
105
104
|
- 후기 조회는 **숫자 `productId`**(slug 아님) — `getProduct(slug).id`로 획득.
|
|
106
105
|
- 후기 작성은 로그인 필수 + 구매검증(본인·배송완료 이상·상품 일치).
|
|
107
106
|
|
|
@@ -174,14 +173,21 @@ await zalkera.submitInquiry({name, email, subject, message}, {clientIp});
|
|
|
174
173
|
### IP 민감 호출은 방문자 IP를 넘긴다
|
|
175
174
|
|
|
176
175
|
- 대상: `submitInquiry`·`submitLead`·`recordPostView`.
|
|
177
|
-
- 이유: 백엔드 IP 레이트리밋·조회 dedup
|
|
176
|
+
- 이유: 백엔드 IP 레이트리밋·조회 dedup이 이 값을 본다. 안 넘기면 테넌트 서버 IP로 뭉쳐 방문자 전원이 429.
|
|
177
|
+
- 주문 계열(`getOrder`/`cancelOrder`/`completeOrder`/`getShipment`/`startPayment`/`confirmPayment`)도 같다 — `OrderAccess.context`로 넘긴다. 게스트 주문 인가는 연락처 대입을 막으려고 **실패를 세는데**, 선언이 없으면 그 사이트의 게스트 전체가 한 IP로 묶인다(주문번호 축은 그와 무관하게 계속 선다).
|
|
178
178
|
|
|
179
179
|
```ts
|
|
180
180
|
// route handler 안
|
|
181
|
-
|
|
181
|
+
import {visitorIp} from "@zalkera/client";
|
|
182
|
+
|
|
183
|
+
const ip = visitorIp(req.headers); // 프록시 1단 기준(기본)
|
|
184
|
+
// const ip = visitorIp(req.headers, {trustedHops: 2}); // 예: CDN + 로드밸런서
|
|
182
185
|
await zalkera.submitInquiry(input, {clientIp: ip});
|
|
183
186
|
```
|
|
184
187
|
|
|
188
|
+
- ❌ **`x-forwarded-for` 첫 엔트리 직접 추출 금지**(`xff.split(",")[0]`). 이 헤더는 프록시가 오른쪽에 append하므로 **첫 엔트리는 방문자가 손으로 실은 값**이다 — 그걸로 레이트리밋 버킷을 만들면 요청마다 값을 바꿔 우회된다. `x-real-ip` 폴백도 마찬가지(세우는 주체가 프록시마다 다르다).
|
|
189
|
+
- **보장 경계**: `visitorIp()`는 "부르면 안전"이 아니라 **"선언한 홉 수가 참인 만큼 안전"**을 준다. 실제보다 **크게** 선언하면 위조 엔트리를 채택한다(조용히 뚫린다). 실제보다 **작게** 선언하면 방문자가 뭉쳐 429로 드러난다. **선언은 실제 이하로만.** 리버스 프록시 **0단 직노출** 배포는 미지원(소켓 IP를 플랫폼 수단으로 직접 얻을 것). 잘커라가 서빙하는 사이트는 기본값 1이 구성상 맞다.
|
|
190
|
+
|
|
185
191
|
## 시크릿 키 (`secretKey`)
|
|
186
192
|
|
|
187
193
|
- 역할: `X-Tenant` 무인증 신뢰의 **보안 승격** — 키가 테넌트 신원을 증명(백엔드에선 키가 정본).
|
|
@@ -205,7 +211,7 @@ await zalkera.submitInquiry(input, {clientIp: ip});
|
|
|
205
211
|
| `code` | 기계 판독 에러코드(백엔드 `ErrorCode`). 구버전은 HTTP 사유구로 폴백. |
|
|
206
212
|
| `validationErrors` | 400 시 필드별 메시지. |
|
|
207
213
|
| `body` | 파싱된 원본 에러 본문. |
|
|
208
|
-
| `isRateLimited` | `status === 429`. |
|
|
214
|
+
| `isRateLimited` | `status === 429`. 문의·리드 남발 또는 **게스트 주문 인가 실패 누적**. |
|
|
209
215
|
| `isStorefrontKeyError` | `STOREFRONT_KEY_REQUIRED` 또는 `TENANT_MISMATCH`. |
|
|
210
216
|
|
|
211
217
|
```ts
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* E2 "use client" 파일에서 서버 클라이언트 싱글턴(lib/zalkera) import → 같은 위험.
|
|
11
11
|
* E3 **값**이 새는 형태 — `NEXT_PUBLIC_` 접두가 붙은 시크릿(소스·`.env*` 양쪽) · 소스에 박힌
|
|
12
12
|
* 스토어프론트 키 리터럴(`oqsk_…`). E1·E2 가 모듈만 보고 값은 아무도 안 보던 자리다.
|
|
13
|
+
* I1 warning — `x-forwarded-for` 의 **첫 엔트리 채택**(`xff.split(",")[0]` 계열). 첫 엔트리는 방문자가
|
|
14
|
+
* 요청에 손으로 실은 값이라 IP 레이트리밋이 한 줄로 우회된다. `visitorIp()` 로 대체한다. I 절 주석 참고.
|
|
13
15
|
* W1 클라이언트 싱글턴 파일이 하나도 없음 → 서버 사이드 호출 패턴 미구현 의심.
|
|
14
16
|
* C1 ISR-우선 게이트(memo31 §0-12) — SEO 라우트 page 가 per-page SSR(동적 렌더)을 강제하면 실패.
|
|
15
17
|
* codegen 산출물이 홈·목록·상세·콘텐츠 페이지를 동적SSR 로 만들면 CI 를 red 로 만들어 미배포.
|
|
@@ -61,6 +63,11 @@
|
|
|
61
63
|
* inferred 선언 부재 + content/pages/*.json 이 실재 W (형상은 있는데 선언이 없다)
|
|
62
64
|
* none 그 외(다른 선언값 · content 디렉터리 없음) – (스킵)
|
|
63
65
|
*
|
|
66
|
+
* N0 **`zalkera.content` 가 모르는 값** — 모드는 `none`(N1~N5 스킵)이지만 경고 한 줄을 낸다. 둘을 가른다:
|
|
67
|
+
* ⑴ 은퇴한 값 `"sections-db"` — 그 선언을 든 레포는 얼굴의 정본이 어디에도 없다(어휘 rev 7 · memo144).
|
|
68
|
+
* ⑵ 그 밖의 미지 값(오탈자 `"sourse"` 류) — **스킵한다는 사실 자체를 말한다.** 유효값이 하나로 줄어
|
|
69
|
+
* 한 글자 오타가 N 규칙 전체를 조용히 끄는 스위치가 됐다(memo144 §심의-4 · 경고 ⑦).
|
|
70
|
+
* 어느 쪽도 error 가 아니다 — 남의 선언값을 우리가 해석해 막으면 그것이 어휘 강제다(memo125 요건 1).
|
|
64
71
|
* N1 content/index.ts(매니페스트) 부재 — 정적 import 가 없으면 HMR 도 standalone 트레이싱도 없다.
|
|
65
72
|
* N2 content/pages/*.json 파싱 실패 또는 최상위가 객체 아님.
|
|
66
73
|
* N3 매니페스트와 파일의 어긋남 — 파일은 있는데 매니페스트에 없으면 **그 페이지는 존재하지 않는다**
|
|
@@ -73,6 +80,13 @@
|
|
|
73
80
|
* 상품 `handle` 이 실재하는지는 **여기서 못 판정한다** — 카탈로그는 DB(레인 B)에 있다. 그 축의
|
|
74
81
|
* 잣대는 산출물(개시된 사이트)이지 소스가 아니다.
|
|
75
82
|
*
|
|
83
|
+
* O1 warning(**관문 모드에서도 경고 — 승격 영구 금지**) — `next.config` 에 `output: 'standalone'` 이
|
|
84
|
+
* 없다. 잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결 산출물을 내야 하는데
|
|
85
|
+
* (우리 박스는 `node server.js` 로 띄운다·memo145), **설정 문자열은 그 사실을 재지 못한다** —
|
|
86
|
+
* 조건부 조립이면 키가 있어도 산출이 안 나오고 없어도 나올 수 있다(X1 과 같은 양방향 거짓).
|
|
87
|
+
* 그래서 정적으로 **확신할 수 있는 형상**(리터럴 객체 한 벌)에서만 말하고 동적이면 잠자코
|
|
88
|
+
* 스킵한다. 판정은 산출물이 사실인 자리에서만 한다(`verify-zip` ⑧ · CI · 서빙 게이트 exit 4).
|
|
89
|
+
* 자체 호스팅(BYO)이면 요건 자체가 없다.
|
|
76
90
|
* D1 AGENTS.md 가 **없는 파일을 가리킴**. codegen 이 가장 먼저 읽는 문서라 죽은 좌표는 곧 탐색 토큰이다
|
|
77
91
|
* (2026-07-30 기준선 실측: 낡은 좌표 때문에 에이전트가 콘텐츠 계약 대신 라우트를 새로 짰다).
|
|
78
92
|
* D2 설치된 `@zalkera/client` 의 llms.txt 가 **본보기로 지목한 경로**가 이 레포에 없음 — 레시피가
|
|
@@ -366,6 +380,108 @@ function crossOriginSink() {
|
|
|
366
380
|
return warnings;
|
|
367
381
|
}
|
|
368
382
|
|
|
383
|
+
/*
|
|
384
|
+
* ── I1 : 방문자 IP 를 `x-forwarded-for` **첫 엔트리**에서 뽑는다 ───────────────────────────
|
|
385
|
+
*
|
|
386
|
+
* `X-Forwarded-For` 는 **각 프록시가 자기가 받은 연결의 IP 를 오른쪽에 append** 하는 헤더다. 방문자가
|
|
387
|
+
* 요청에 `X-Forwarded-For: 9.9.9.9` 를 손으로 실으면 헤더는 `9.9.9.9, <진짜IP>` 가 되고, **첫 엔트리는
|
|
388
|
+
* 공격자가 쓴 문자열**이다. 첫 엔트리로 레이트리밋 버킷을 만들면 요청마다 값을 바꿔 버킷을 무한히 새로
|
|
389
|
+
* 만들 수 있다 — 2026-08-01 보안 심의가 상용 사이트에서 이 우회를 **실측**했다.
|
|
390
|
+
*
|
|
391
|
+
* **이 축은 이름이 아니라 사실을 잰다**(X1 과 다른 점). `headers.get("x-forwarded-for")…split(",")[0]`
|
|
392
|
+
* 이라는 표현식 **자체가** 첫 홉 채택이다 — "우리 심볼을 썼는가"가 아니라 무엇을 하는가를 본다.
|
|
393
|
+
* 걸리면 진짜다.
|
|
394
|
+
*
|
|
395
|
+
* ⚠ **못 잡는 것**(문서화된 음성 한계 — 선례는 E3 다): 값이 다른 파일을 거쳐 오는 경우, `[0]` 이 아닌
|
|
396
|
+
* 계산된 인덱스로 첫 엔트리를 집는 경우, 헤더 이름을 문자열 조립으로 만든 경우. v1 은 직접 표현식과
|
|
397
|
+
* **같은 파일 안 단순 변수 경유**까지만 좇는다. 잡지 못한 형상은 검사기가 조용한 것이지 안전한 것이 아니다.
|
|
398
|
+
*
|
|
399
|
+
* **왜 지금은 경고인가.** 관문으로 켜면 첫 동작이 **상용 자산 반려**다(우리 예제 레포와 상용 사이트가
|
|
400
|
+
* 아직 이 관용구를 쓴다 — 우리 교본이 그렇게 가르쳤기 때문이다). 자산을 먼저 일소하고(memo143 T4)
|
|
401
|
+
* 그 뒤에 [servingSink] 로 옮긴다. **승격 지점은 여기 한 줄**이다.
|
|
402
|
+
*
|
|
403
|
+
* 그리고 경고는 **받아 줄 곳을 가리켜야** 방치로 안 끝난다 — 메시지가 `visitorIp()` 를 지목한다.
|
|
404
|
+
*/
|
|
405
|
+
function clientIpSink() {
|
|
406
|
+
return warnings; // T4(자산 일소) 뒤 `servingSink()` 로 승격 — memo143 §3-물음3·미결 3.
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* **서빙 산출물 계약 축(O)의 목적지 — 관문 모드에서도 경고다. 승격은 영구 금지다.**
|
|
411
|
+
*
|
|
412
|
+
* 계약 자체는 사실이다(memo145 §0): *"잘커라가 서빙하는 소스는 빌드가 `.next/standalone` 자기완결
|
|
413
|
+
* 산출물을 내야 한다."* 우리 박스는 `next start` 가 아니라 그 산출물을 `node server.js` 로 띄운다.
|
|
414
|
+
*
|
|
415
|
+
* **그런데 이 검사기는 그 사실을 잴 수 없다.** 여기서 읽을 수 있는 것은 `next.config` 라는 **설정 문자열**
|
|
416
|
+
* 이고, 그것은 산출물이 아니다. X1(교차사이트 가드)과 정확히 같은 함정이라 양쪽으로 틀린다:
|
|
417
|
+
* · **거짓 음성** — `output: "standalone"` 이 적혀 있어도 조건부 조립(`...(cond ? {} : {output:…})`)이면
|
|
418
|
+
* 산출이 안 나온다. **통과가 서빙됨을 뜻하지 않는다.**
|
|
419
|
+
* · **거짓 양성** — 키가 없어도 외부 조립·플러그인 래핑·재export 로 산출이 나올 수 있다.
|
|
420
|
+
* **반려가 결함을 뜻하지 않는다.**
|
|
421
|
+
*
|
|
422
|
+
* 그래서 이 축은 **경고까지만**이고, 관문은 산출물이 정의상 사실인 자리에만 둔다 — `verify-zip` ⑧
|
|
423
|
+
* (빌드 뒤 실물)·examples CI·서빙 박스 `build.sh` exit 4. 셋 다 **이미 지불한 빌드**를 읽는다.
|
|
424
|
+
*
|
|
425
|
+
* 그리고 하나 더: **자체 호스팅(BYO)에는 이 요건의 근거가 아예 없다**(memo140 §6.5 — 강제의 근거는
|
|
426
|
+
* 누가 서빙하는가다). Vercel·정적 export 로 사는 레포에 관문을 들이대면 멀쩡한 소스를 반려하게 된다.
|
|
427
|
+
* 그래서 메시지도 조건부로 말한다("잘커라 호스팅에 올릴 소스라면").
|
|
428
|
+
*
|
|
429
|
+
* ⚠ 이 함수를 `servingSink()` 로 바꾸지 마라. E·C 가 관문인 것과 다른 이유는 그 둘이 **소스에서 사실을
|
|
430
|
+
* 잴 수 있기** 때문이고, 이 축은 산출물에서만 사실이 나오기 때문이다.
|
|
431
|
+
*/
|
|
432
|
+
function servingOutputSink() {
|
|
433
|
+
return warnings;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ⚠ 아래 조각들은 **일반 문자열**로 쓴다. 정규식 리터럴·템플릿 리터럴 안에 백틱을 담을 수 없어서다
|
|
437
|
+
// (따옴표 세 종류를 다 흡수해야 하므로 백틱이 문자 클래스에 반드시 들어간다).
|
|
438
|
+
/** 문자열 리터럴을 여는/닫는 따옴표 한 글자 — `"` · `'` · 백틱(```). */
|
|
439
|
+
const QUOTE = '["\'\\u0060]';
|
|
440
|
+
/** 따옴표가 아닌 한 글자(리터럴 안쪽). */
|
|
441
|
+
const NOT_QUOTE = '[^"\'\\u0060]';
|
|
442
|
+
|
|
443
|
+
/** 헤더 이름 리터럴. Node(`req.headers["x-forwarded-for"]`)·web(`headers.get("x-forwarded-for")`) 양쪽을 흡수한다. */
|
|
444
|
+
const XFF_NAME = new RegExp(`${QUOTE}x-forwarded-for${QUOTE}`, "i");
|
|
445
|
+
|
|
446
|
+
/** 콤마로 자르는 `split` 호출 — `split(",")`·`split(", ")`·`split(',')` 전부. */
|
|
447
|
+
const SPLIT_ON_COMMA = `\\.\\s*split\\s*\\(\\s*${QUOTE}${NOT_QUOTE}*,${NOT_QUOTE}*${QUOTE}\\s*\\)`;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* 첫 엔트리 채택의 세 표기 — `[0]` · `.at(0)` · `.shift()`. 셋 다 같은 사실이다.
|
|
451
|
+
* **`.pop()`·`.at(-1)` 은 일부러 안 잡는다** — 마지막 엔트리는 첫 홉 위조와 다른 축이다(그쪽은 프록시가 쓴 값).
|
|
452
|
+
*/
|
|
453
|
+
const FIRST_ENTRY = "(?:\\s*\\[\\s*0\\s*\\]|\\s*\\.\\s*at\\s*\\(\\s*0\\s*\\)|\\s*\\.\\s*shift\\s*\\(\\s*\\))";
|
|
454
|
+
|
|
455
|
+
/** 헤더 리터럴 → split → 첫 엔트리가 **한 표현식으로** 이어진 형태. 사이의 `?.`·`!`·`.trim()` 등은 흡수한다. */
|
|
456
|
+
const I1_DIRECT = new RegExp(
|
|
457
|
+
`${QUOTE}x-forwarded-for${QUOTE}[\\s\\S]{0,120}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`,
|
|
458
|
+
"i",
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
/** `const xff = <…x-forwarded-for…>` — 같은 파일 안 단순 변수 경유를 좇기 위한 바인딩 수집. */
|
|
462
|
+
const I1_BINDING = new RegExp(
|
|
463
|
+
`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=[^;\\n]*${QUOTE}x-forwarded-for${QUOTE}`,
|
|
464
|
+
"gi",
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
/** 정규식 메타문자 이스케이프(식별자에 `$` 가 흔하다). */
|
|
468
|
+
function escapeRegExp(value) {
|
|
469
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** 이 텍스트가 첫 엔트리를 채택하는가. 직접 표현식 → 변수 경유 순으로 본다. 걸린 형상 문자열을 준다(없으면 null). */
|
|
473
|
+
function firstHopAdoption(text) {
|
|
474
|
+
if (!XFF_NAME.test(text)) return null; // 헤더를 아예 안 읽으면 이 축의 대상이 아니다(대다수 파일이 여기서 끝난다).
|
|
475
|
+
if (I1_DIRECT.test(text)) return "x-forwarded-for 를 읽어 바로 첫 엔트리를 채택";
|
|
476
|
+
I1_BINDING.lastIndex = 0;
|
|
477
|
+
for (const m of text.matchAll(I1_BINDING)) {
|
|
478
|
+
const name = escapeRegExp(m[1]);
|
|
479
|
+
const viaVariable = new RegExp(`\\b${name}\\b[\\s\\S]{0,40}?${SPLIT_ON_COMMA}${FIRST_ENTRY}`);
|
|
480
|
+
if (viaVariable.test(text)) return `\`${m[1]}\`(x-forwarded-for 값)의 첫 엔트리를 채택`;
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
369
485
|
const STYLE_MODE = detectStyleMode(root);
|
|
370
486
|
|
|
371
487
|
/**
|
|
@@ -378,6 +494,20 @@ const STYLE_MODE = detectStyleMode(root);
|
|
|
378
494
|
* declared : zalkera.content === "source" → N 규칙 error
|
|
379
495
|
* inferred : 선언 부재 + content/pages/*.json → N 규칙 warning
|
|
380
496
|
* none : 그 외 → N 규칙 스킵
|
|
497
|
+
*
|
|
498
|
+
* **은퇴한 선언값**(`"sections-db"`)은 3모드를 늘리지 않는다 — 판정은 그대로 `none` 이고 경고 한 줄을
|
|
499
|
+
* 더한다(어휘 rev 7 · memo144). 섹션이 백엔드 DB 에도 살던 시절의 값인데 그 거처가 통째로 퇴역해서,
|
|
500
|
+
* 그 선언을 든 레포는 **얼굴의 정본이 어디에도 없는 상태**다. 조용히 스킵하면 그 사실이 안 보인다.
|
|
501
|
+
*
|
|
502
|
+
* **미지 값**(`"sourse"` 같은 오탈자)도 같은 대접이다 — `none` 이되 **말은 한다**(memo144 §심의-4 부수 ·
|
|
503
|
+
* 경고 ⑦). 종전에는 이 자리가 침묵이었다: 유효값이 `"source"` 하나로 줄면서 오탈자 한 글자가 N1~N5
|
|
504
|
+
* 전체를 조용히 끄는 스위치가 됐고, `content/pages/*.json` 이 **실재해도** 선언이 없는 레포(`inferred`)
|
|
505
|
+
* 보다 낮은 감시를 받았다. 계약을 지키려던 손이 오타 때문에 계약 밖으로 떨어지는데 화면에 아무 말이
|
|
506
|
+
* 없는 것 — 그것이 조용한 실패다.
|
|
507
|
+
*
|
|
508
|
+
* ⚠ **경고까지다. error 도, 모드 승격도 아니다.** 우리 계약이 아닌 값을 적는 것은 자유이고(memo125
|
|
509
|
+
* 요건 1 — 어휘 강제 금지), 남의 `zalkera.content` 를 우리가 해석해 검사를 걸면 그것이 강제가 된다.
|
|
510
|
+
* 여기서 하는 일은 판정을 바꾸는 것이 아니라 **판정 결과를 보이게 하는 것**뿐이다.
|
|
381
511
|
*/
|
|
382
512
|
function detectContentMode(srcDir) {
|
|
383
513
|
const repoRoot = resolve(srcDir, "..");
|
|
@@ -399,7 +529,32 @@ function detectContentMode(srcDir) {
|
|
|
399
529
|
dir = parent;
|
|
400
530
|
}
|
|
401
531
|
if (declared === "source") return "declared";
|
|
402
|
-
if (declared
|
|
532
|
+
if (declared === "sections-db") {
|
|
533
|
+
warnings.push(
|
|
534
|
+
"[N0] package.json 의 `zalkera.content` 가 은퇴한 값 `\"sections-db\"` 입니다 — " +
|
|
535
|
+
"섹션이 백엔드 DB 에 살던 시절의 표기이고 그 거처는 퇴역했습니다(어휘 rev 7). " +
|
|
536
|
+
"사이트의 얼굴을 `content/pages/*.json`·`content/nav.json` 으로 옮기고 선언을 `\"source\"` 로 바꾸세요 " +
|
|
537
|
+
"(llms.txt §9.1).",
|
|
538
|
+
);
|
|
539
|
+
return "none";
|
|
540
|
+
}
|
|
541
|
+
if (declared !== undefined) {
|
|
542
|
+
// 우리 계약이 아닌 선언값 — 이 레포에 콘텐츠 파일 계약이 없다. **다만 말은 한다**(위 KDoc).
|
|
543
|
+
// 콘텐츠 파일이 실재하면 오탈자일 가능성이 높으므로 그 사실을 함께 적는다 — 사람이 오타인지
|
|
544
|
+
// 의도인지 가를 재료다(우리가 대신 판정하지 않는다).
|
|
545
|
+
const shown = typeof declared === "string" ? `"${declared}"` : JSON.stringify(declared);
|
|
546
|
+
const pages = contentPageFiles(repoRoot).length;
|
|
547
|
+
warnings.push(
|
|
548
|
+
`[N0] package.json 의 \`zalkera.content\` 값 ${shown} 을 모릅니다 — 아는 값은 \`"source"\` 하나이고, ` +
|
|
549
|
+
`그래서 콘텐츠 파일 계약 검사(N1~N5)를 **건너뜁니다**` +
|
|
550
|
+
(pages > 0
|
|
551
|
+
? `. 그런데 이 레포에는 \`content/pages/*.json\` 이 ${pages}개 있습니다 — 오탈자라면 ` +
|
|
552
|
+
`\`"source"\` 로 고치세요(고치면 N 규칙이 error 로 섭니다). 일부러 다른 값을 쓰신 것이면 ` +
|
|
553
|
+
`이 경고는 무시하십시오.`
|
|
554
|
+
: `. 일부러 다른 값을 쓰신 것이면 이 경고는 무시하십시오.`),
|
|
555
|
+
);
|
|
556
|
+
return "none";
|
|
557
|
+
}
|
|
403
558
|
return contentPageFiles(repoRoot).length > 0 ? "inferred" : "none";
|
|
404
559
|
}
|
|
405
560
|
|
|
@@ -1230,6 +1385,17 @@ function check(file) {
|
|
|
1230
1385
|
// 붙은 시크릿은 "노출될 수도 있다"가 아니라 **이미 노출된 것**이다(.env.example §12 가 같은 말을
|
|
1231
1386
|
// 문장으로 적어 뒀는데 검사기는 한 번도 안 봤다). 소스에 박은 키 리터럴도 같은 축이다.
|
|
1232
1387
|
for (const hit of secretExposures(text)) servingSink().push(`[E3] ${rel}: ${hit}`);
|
|
1388
|
+
|
|
1389
|
+
// I1: 방문자 IP 를 XFF **첫 엔트리**에서 뽑는다 — 방문자가 위조할 수 있는 값이다(I 절 주석).
|
|
1390
|
+
// 경고이되 **받아 줄 곳을 가리킨다**: 대체물이 없는 경고는 방치로 끝난다.
|
|
1391
|
+
const firstHop = firstHopAdoption(text);
|
|
1392
|
+
if (firstHop) {
|
|
1393
|
+
clientIpSink().push(
|
|
1394
|
+
`[I1] ${rel}: ${firstHop} — **첫 엔트리는 방문자가 위조할 수 있습니다**(IP 레이트리밋 우회·IP 기록 오염). ` +
|
|
1395
|
+
`@zalkera/client 의 \`visitorIp(headers)\` 를 쓰고 프록시 홉 수를 선언하세요(기본 1). ` +
|
|
1396
|
+
`잘커라가 서빙하면 홉 1 이 구성상 참이라 기본값 그대로 맞습니다.`,
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1233
1399
|
}
|
|
1234
1400
|
|
|
1235
1401
|
/**
|
|
@@ -1860,6 +2026,75 @@ function checkEnvFiles() {
|
|
|
1860
2026
|
}
|
|
1861
2027
|
}
|
|
1862
2028
|
|
|
2029
|
+
/*
|
|
2030
|
+
* ── O1 : 서빙 산출물 계약 — `next.config` 가 standalone 을 안 낸다(경고 전용) ──────────────
|
|
2031
|
+
*
|
|
2032
|
+
* **못 재는 것을 잰 척하지 않는 것**이 이 규칙의 설계 전부다([servingOutputSink] 참조). 그래서 판정을
|
|
2033
|
+
* 두 단계로 나눈다:
|
|
2034
|
+
*
|
|
2035
|
+
* 1. **확신할 수 있는 형상인가** — 설정이 *정적 리터럴 객체 한 벌*인가. 스프레드·삼항·함수형 config·
|
|
2036
|
+
* 플러그인 래핑(`withMDX(config)`)·`process.env`·`require` 가 보이면 **잠자코 스킵한다.** 그 형상에서
|
|
2037
|
+
* "키가 없다"는 산출이 안 나온다는 뜻이 아니다.
|
|
2038
|
+
* 2. 확신할 수 있을 때만 키를 본다 — 없으면(또는 `output:"export"` 면) 경고.
|
|
2039
|
+
*
|
|
2040
|
+
* 스킵이 조용한 이유: 여기서 "동적이라 못 쟀습니다"를 매번 찍으면 정상적으로 플러그인을 쓰는 레포가
|
|
2041
|
+
* 영구히 시끄러워지고, 그러면 사람이 경고 전체를 무시하기 시작한다. 진실은 몇 분 뒤 빌드가 말해 준다.
|
|
2042
|
+
*/
|
|
2043
|
+
const NEXT_CONFIG_NAMES = ["next.config.ts", "next.config.mts", "next.config.js", "next.config.mjs", "next.config.cjs"];
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* 이 설정 파일이 **정적으로 판독 가능한가**. 하나라도 걸리면 못 읽는 것으로 본다(거짓 경고 금지).
|
|
2047
|
+
* · `...` 스프레드 조립 · `?` 삼항(그리고 TS optional) — 값이 갈린다
|
|
2048
|
+
* · `=>`·`function` 함수형 config · `require(`·`process.env`·백틱 런타임 값
|
|
2049
|
+
* · `export default <ident>(` 플러그인 래핑(`withMDX(config)`)
|
|
2050
|
+
* · 타입이 아닌 import 다른 모듈이 설정을 만든다는 신호
|
|
2051
|
+
*/
|
|
2052
|
+
function isStaticallyReadableConfig(text) {
|
|
2053
|
+
if (/\.\.\.|=>|\bfunction\b|\brequire\s*\(|process\.env|`|\?/.test(text)) return false;
|
|
2054
|
+
if (/export\s+default\s+[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
|
|
2055
|
+
if (/module\.exports\s*=\s*[A-Za-z_$][\w$]*\s*\(/.test(text)) return false;
|
|
2056
|
+
// `import type {NextConfig} from "next"` 은 값을 안 나른다 — 그것만 허용한다.
|
|
2057
|
+
for (const m of text.matchAll(/^\s*import\s+([^;\n]*)from\s/gm)) {
|
|
2058
|
+
if (!/^\s*type\b/.test(m[1])) return false;
|
|
2059
|
+
}
|
|
2060
|
+
return true;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function checkServingOutputContract() {
|
|
2064
|
+
const repoRoot = resolve(root, "..");
|
|
2065
|
+
const found = NEXT_CONFIG_NAMES.map((n) => join(repoRoot, n)).filter((p) => existsSync(p));
|
|
2066
|
+
// 설정 파일이 여럿이면 어느 것이 유효한지 Next 의 해석 순서에 달렸다 — 못 가르는 자리라 스킵한다.
|
|
2067
|
+
if (found.length !== 1) return;
|
|
2068
|
+
|
|
2069
|
+
let text;
|
|
2070
|
+
try {
|
|
2071
|
+
text = stripComments(readFileSync(found[0], "utf8"));
|
|
2072
|
+
} catch {
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
if (!isStaticallyReadableConfig(text)) return; // 동적 조립 — 잠자코 스킵.
|
|
2076
|
+
|
|
2077
|
+
const name = basename(found[0]);
|
|
2078
|
+
const advice =
|
|
2079
|
+
`잘커라 호스팅에 올릴 소스라면 ${name} 에 output: 'standalone' 이 필요합니다 ` +
|
|
2080
|
+
`(우리 박스는 next start 가 아니라 빌드 산출물 .next/standalone/server.js 를 실행합니다). ` +
|
|
2081
|
+
`자체 호스팅이면 무관합니다. ` +
|
|
2082
|
+
`⚠ 이 검사는 설정 문자열만 봅니다 — 판정은 빌드 산출물이 합니다(verify-zip·CI·서빙 게이트).`;
|
|
2083
|
+
|
|
2084
|
+
const m = text.match(/(?:^|[{,;\s])output\s*:\s*(["'])([^"']*)\1/);
|
|
2085
|
+
if (m) {
|
|
2086
|
+
if (m[2] === "standalone") return; // 계약을 지키는 형태 — 조용히 통과(사실 판정은 빌드가 한다).
|
|
2087
|
+
// `export` 같은 리터럴 값은 **자기완결 산출물을 못 낸다**는 사실이 확실하다.
|
|
2088
|
+
servingOutputSink().push(`[O1] ${name}: output: '${m[2]}' — ${advice}`);
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
// `output:` 자체가 없다(리터럴에서 부재가 확실). 값이 식별자·객체면 위 정규식이 안 잡는데,
|
|
2092
|
+
// 그 형상은 위 [isStaticallyReadableConfig] 를 이미 통과 못 한다(변수 경유는 import·삼항 없이는
|
|
2093
|
+
// 나오기 어렵다) — 남는 형상은 실제 부재이거나 `output: someVar` 뿐이라, 후자를 위해 한 번 더 본다.
|
|
2094
|
+
if (/(?:^|[{,;\s])output\s*:/.test(text)) return; // 값이 리터럴이 아니다 — 못 잰다.
|
|
2095
|
+
servingOutputSink().push(`[O1] ${name}: output 설정이 없습니다 — ${advice}`);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1863
2098
|
try {
|
|
1864
2099
|
statSync(root);
|
|
1865
2100
|
} catch {
|
|
@@ -1877,6 +2112,7 @@ checkDocCoordinates(); // D1·D2 — 문서 좌표가 실물을 가리키는가.
|
|
|
1877
2112
|
checkCrossOriginGuards(); // X1·X2·X3 — 교차사이트 위조 가드(memo118).
|
|
1878
2113
|
checkPagesRouter(); // C1p·C1pa·X1p — Pages Router 좌표(App Router 전용이던 사각).
|
|
1879
2114
|
checkEnvFiles(); // E3 — `.env*` 의 NEXT_PUBLIC_ 시크릿.
|
|
2115
|
+
checkServingOutputContract(); // O1 — 서빙 산출물 계약(경고 전용·관문 승격 영구 금지).
|
|
1880
2116
|
|
|
1881
2117
|
/**
|
|
1882
2118
|
* `llms.txt` 운반본 드리프트 — **fail-soft**.
|
package/dist/index.cjs
CHANGED
|
@@ -25,7 +25,13 @@ var ZalkeraError = class _ZalkeraError extends Error {
|
|
|
25
25
|
this.validationErrors = options.validationErrors ?? [];
|
|
26
26
|
this.body = options.body ?? null;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* 레이트리밋(429) — "잠시 후 다시" 를 띄울 때 쓴다.
|
|
30
|
+
*
|
|
31
|
+
* 두 갈래다: ⑴ 문의·리드 폼 남발(IP 축) ⑵ **게스트 주문 인가 실패 누적** — 주문번호+연락처로
|
|
32
|
+
* 여는 주문 조회·취소·구매확정·배송조회·결제세션은 연락처 대입을 막으려고 실패를 센다.
|
|
33
|
+
* 정상 조회는 세지 않으므로, 이 코드가 뜨면 연락처를 여러 번 틀렸거나 같은 주문에 시도가 몰린 것이다.
|
|
34
|
+
*/
|
|
29
35
|
get isRateLimited() {
|
|
30
36
|
return this.status === 429;
|
|
31
37
|
}
|
|
@@ -102,7 +108,10 @@ function createZalkeraClient(options) {
|
|
|
102
108
|
};
|
|
103
109
|
if (options.secretKey) headers["X-Storefront-Key"] = options.secretKey;
|
|
104
110
|
if (init?.body != null) headers["Content-Type"] = "application/json";
|
|
105
|
-
if (init?.context?.clientIp)
|
|
111
|
+
if (init?.context?.clientIp) {
|
|
112
|
+
headers["X-Zalkera-Client-Ip"] = init.context.clientIp;
|
|
113
|
+
headers["X-Forwarded-For"] = init.context.clientIp;
|
|
114
|
+
}
|
|
106
115
|
if (init?.bearer) headers["Authorization"] = `Bearer ${init.bearer}`;
|
|
107
116
|
if (init?.cartSession) headers["X-Cart-Session"] = init.cartSession;
|
|
108
117
|
if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey;
|
|
@@ -186,12 +195,6 @@ function createZalkeraClient(options) {
|
|
|
186
195
|
method: "POST",
|
|
187
196
|
context
|
|
188
197
|
}),
|
|
189
|
-
getPage: (slug, options2) => request(`/public/pages/${seg(slug)}`, nextInit(options2)),
|
|
190
|
-
listPages: (params, options2) => request("/public/pages", {
|
|
191
|
-
query: { page: params?.page, size: params?.size },
|
|
192
|
-
...nextInit(options2)
|
|
193
|
-
}),
|
|
194
|
-
listMenus: (options2) => request("/public/menus", { next: options2?.tags ? { tags: options2.tags } : void 0 }),
|
|
195
198
|
getMediaUrl: (id) => request(`/public/media/${seg(id)}/url`),
|
|
196
199
|
submitInquiry: (input, context) => request("/public/inquiries", {
|
|
197
200
|
method: "POST",
|
|
@@ -291,7 +294,8 @@ function accessInit(access, extra) {
|
|
|
291
294
|
return {
|
|
292
295
|
...extra,
|
|
293
296
|
bearer: access.accessToken,
|
|
294
|
-
query: access.phone ? { phone: access.phone } : void 0
|
|
297
|
+
query: access.phone ? { phone: access.phone } : void 0,
|
|
298
|
+
context: access.context
|
|
295
299
|
};
|
|
296
300
|
}
|
|
297
301
|
function isRedirectResponse(response) {
|
|
@@ -306,7 +310,7 @@ function safeJsonParse(text) {
|
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
// src/sections.ts
|
|
309
|
-
var SECTION_CONTRACT_REV =
|
|
313
|
+
var SECTION_CONTRACT_REV = 7;
|
|
310
314
|
var SECTION_CONTRACT = [
|
|
311
315
|
// 뷰티(memo47) — 조회형 둘(SERVICE_MENU·BOOKING_CTA)은 rev 6 에서 삭제됐다(위 KDoc).
|
|
312
316
|
// 시술 목록의 ItemList 는 사라진 것이 아니라 거처가 바뀌었다: `/products` 라우트와 소스가 조합하는
|
|
@@ -341,6 +345,32 @@ function safeLinkUrl(raw) {
|
|
|
341
345
|
}
|
|
342
346
|
}
|
|
343
347
|
|
|
348
|
+
// src/visitorIp.ts
|
|
349
|
+
function visitorIp(headers, options) {
|
|
350
|
+
const hops = options?.trustedHops ?? hopsFromEnv() ?? DEFAULT_TRUSTED_HOPS;
|
|
351
|
+
if (hops <= 0) return void 0;
|
|
352
|
+
const raw = headers?.get?.("x-forwarded-for");
|
|
353
|
+
if (raw == null || raw.trim() === "") return void 0;
|
|
354
|
+
const parts = raw.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
355
|
+
if (parts.length === 0) return void 0;
|
|
356
|
+
const index = Math.min(Math.max(parts.length - hops, 0), parts.length - 1);
|
|
357
|
+
return parts[index] || void 0;
|
|
358
|
+
}
|
|
359
|
+
var DEFAULT_TRUSTED_HOPS = 1;
|
|
360
|
+
var HOPS_ENV = "ZALKERA_TRUSTED_PROXY_HOPS";
|
|
361
|
+
var envWarned = false;
|
|
362
|
+
function hopsFromEnv() {
|
|
363
|
+
const raw = typeof process !== "undefined" ? process.env?.[HOPS_ENV] : void 0;
|
|
364
|
+
if (raw == null || raw.trim() === "") return void 0;
|
|
365
|
+
const parsed = Number(raw);
|
|
366
|
+
if (Number.isInteger(parsed)) return parsed;
|
|
367
|
+
if (!envWarned) {
|
|
368
|
+
envWarned = true;
|
|
369
|
+
console.warn(`@zalkera/client: ${HOPS_ENV}="${raw}" \uB294 \uC815\uC218\uAC00 \uC544\uB2D9\uB2C8\uB2E4 \u2014 \uAE30\uBCF8\uAC12 ${DEFAULT_TRUSTED_HOPS} \uB85C \uC9C4\uD589\uD569\uB2C8\uB2E4.`);
|
|
370
|
+
}
|
|
371
|
+
return void 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
344
374
|
// src/sectionConfig.ts
|
|
345
375
|
function parseConfig(config) {
|
|
346
376
|
if (!config) return null;
|
|
@@ -480,5 +510,6 @@ exports.parseThemeColors = parseThemeColors;
|
|
|
480
510
|
exports.readConfig = readConfig;
|
|
481
511
|
exports.safeLinkUrl = safeLinkUrl;
|
|
482
512
|
exports.sectionsOfVertical = sectionsOfVertical;
|
|
513
|
+
exports.visitorIp = visitorIp;
|
|
483
514
|
//# sourceMappingURL=index.cjs.map
|
|
484
515
|
//# sourceMappingURL=index.cjs.map
|