@zalkera/client 0.21.9 → 0.21.11

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.
@@ -539,19 +539,13 @@ function servingSink() {
539
539
  * 양쪽으로 다 틀린다(심의 실측):
540
540
  * · **거짓 음성** — 라우트 안에 동명 `function assertSameOrigin(){return null}` 을 선언하면 통과한다.
541
541
  * 인자로 엉뚱한 `Request` 를 넘겨도 통과한다. 즉 **통과가 안전을 뜻하지 않는다.**
542
- * · **거짓 양성 가능성** — 상용 서빙 중인 사이트(bix·credium)가 **둘 걸린다**(실측 2/2).
543
- * 자기 방식으로 막은 라우트도 우리 심볼을 안 쓰면 걸린다는 뜻이다.
542
+ * · **거짓 양성 가능성** — 자기 방식으로 막은 라우트도 우리 심볼을 쓰면 걸린다. 실사용 사이트로
543
+ * 확인했다.
544
544
  *
545
- * ⚠ **초판 주석 정정(2026-08-01 보안 심의).** 여기 원래 *" reCAPTCHA + rate limit 으로 같은 위협을
546
- * 이미 막고 있다"* 적었는데 **재 보지 않고 문장이었고 거짓이었다.** 심의가 서버를 띄워 실측하니
547
- * bix `/api/contact` 교차 오리진 JSON·교차사이트 form **둘 200 으로 접수**했고, rate limit 은
548
- * `x-forwarded-for` 위조로 완전 우회됐으며, reCAPTCHA `.env.example` 기본값이 `false` 였다.
549
- * **그 사이트는 실제로 안 막고 있었다.**
550
- *
551
- * 그래도 이 축을 관문에서 빼는 판단은 유지한다 — 근거가 "다른 수단으로 막고 있다"가 아니라
552
- * **"이 검사가 안전을 재지 못한다"** 이기 때문이다(위 거짓 음성). 통과해도 안전하지 않은 검사를
553
- * 관문에 놓으면 안전하다는 착각만 판다. 다만 **막지 않는 대신 반드시 보여야 한다** —
554
- * 업로드·검수 결과에 이 경고를 띄우는 것이 이 결정의 나머지 절반이고, 그것 없이는 방치다.
545
+ * ⚠ 관문에서 빼는 근거는 **"다른 수단으로 막고 있다" 아니다.** 근거는 거짓 음성,
546
+ * **이 검사가 안전을 재지 못한다**는 것뿐이다. 통과해도 안전하지 않은 검사를 관문에 놓으면
547
+ * 안전하다는 착각만 판다. 다만 **막지 않는 대신 반드시 보여야 한다** 업로드·검수 결과에
548
+ * 경고를 띄우는 것이 결정의 나머지 절반이고, 그것 없이는 방치다.
555
549
  *
556
550
  * 통과가 안전을 뜻하지 않고 실패가 위험을 뜻하지 않는 검사를 관문에 놓으면, 얻는 것은 심리적 안심뿐이고
557
551
  * 잃는 것은 신뢰다 — 게이트의 첫 동작이 **상용 전량 중단**이 된다.
@@ -2257,11 +2251,19 @@ function checkContentContract() {
2257
2251
  // import 로 읽으면 없는 파일을 찾는 오탐이 난다(실제로 이 검사를 넣자마자 그렇게 죽었다).
2258
2252
  // 이 레포가 C1b·S8 에서 이미 밟은 함정과 같은 것이라 같은 처방을 쓴다.
2259
2253
  const declaredSlugs = manifest
2260
- ? new Set([...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([^"'/]+)\.json["']/g)].map((m) => m[1]))
2254
+ ? new Set(
2255
+ [...stripComments(manifest).matchAll(/from\s+["']\.\/pages\/([^"'/]+)\.json["']/g)].map((m) =>
2256
+ // ⚠ **정규화를 접어서 비교한다.** macOS 가 만든 zip 은 파일명을 NFD 로 담고 리눅스에서
2257
+ // 풀면 NFD 로 남는데 소스의 import 문은 NFC 다 — 화면에는 같은 글자로 보이면서
2258
+ // "import 안 한다"와 "파일이 없다"가 **동시에** 뜬다. zip 업로드가 정본 입구인
2259
+ // 제품에서 직행 경로이고, 이 축은 면제 마커가 없어 빌드가 선다.
2260
+ m[1].normalize("NFC"),
2261
+ ),
2262
+ )
2261
2263
  : null;
2262
2264
  if (declaredSlugs) {
2263
2265
  for (const file of files) {
2264
- const slug = basename(file, ".json");
2266
+ const slug = basename(file, ".json").normalize("NFC");
2265
2267
  if (!declaredSlugs.has(slug)) {
2266
2268
  sink.push(
2267
2269
  `[N3] ${rel(file)} 를 ${rel(manifestPath)} 가 import 하지 않습니다 — ` +
@@ -2270,7 +2272,7 @@ function checkContentContract() {
2270
2272
  }
2271
2273
  }
2272
2274
  for (const slug of declaredSlugs) {
2273
- if (!files.some((f) => basename(f, ".json") === slug)) {
2275
+ if (!files.some((f) => basename(f, ".json").normalize("NFC") === slug)) {
2274
2276
  sink.push(
2275
2277
  `[N3] ${rel(manifestPath)} 가 import 하는 content/pages/${slug}.json 이 없습니다 — 빌드가 깨집니다.`,
2276
2278
  );
@@ -2361,12 +2363,25 @@ function checkContentContract() {
2361
2363
  );
2362
2364
  continue;
2363
2365
  }
2364
- try {
2365
- statSync(join(repoRoot, "public", value));
2366
- } catch {
2367
- sink.push(
2368
- `[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
2366
+ // ⚠ **레포 밖은 판정하지 않는다.** `statSync` 를 맨몸으로 부르면 테넌트가 `public/` 에
2367
+ // 심링크 하나를 심고 config 로 아무 경로나 물어볼 수 있다 — 있는 파일은 침묵, 없는
2368
+ // 파일은 보고이므로 **빌드 박스 파일시스템의 존재 오라클**이 된다. 이 파일이 스스로
2369
+ // 적어 둔 위협 모델(입력·탐침·회신이 갖춰진 자리)이 N5 에서만 봉쇄 없이 성립했다.
2370
+ const assetAt = containedPath(join(repoRoot, "public"), `.${value}`);
2371
+ if (assetAt === null) {
2372
+ markUnmeasured(
2373
+ join(repoRoot, "public", value),
2374
+ "EXDEV",
2375
+ `${at}: "${path}" — 레포 밖을 가리켜 확인하지 않았습니다`,
2369
2376
  );
2377
+ } else {
2378
+ try {
2379
+ statSync(assetAt);
2380
+ } catch {
2381
+ sink.push(
2382
+ `[N5] ${at}: "${path}" 가 가리키는 public${value} 파일이 없습니다 — 개시하면 깨진 이미지입니다.`,
2383
+ );
2384
+ }
2370
2385
  }
2371
2386
  }
2372
2387
  // N5 — 계약 필수 참조.
@@ -2422,7 +2437,10 @@ function check(file) {
2422
2437
  const isClient = isClientBoundary(src);
2423
2438
 
2424
2439
  // 서버 클라이언트 싱글턴 존재 확인 — create{Zalkera,Oneque}Client 호출(구 심볼 수용).
2425
- if (/create(?:Zalkera|Oneque)Client\s*\(/.test(src)) {
2440
+ // **주석 제거 사본에서 잰다.** 원문에 대고 재면 주석 한 줄이 두 가지를 동시에 한다:
2441
+ // ⑴ 정상 클라이언트 컴포넌트에 E1 오류를 지어내고 ⑵ `singletonFound` 를 세워 W1(싱글턴 부재)
2442
+ // 경고를 **침묵시킨다.** 하나의 결함이 오탐과 미탐을 같이 낸다.
2443
+ if (/create(?:Zalkera|Oneque)Client\s*\(/.test(stripComments(src))) {
2426
2444
  singletonFound = true;
2427
2445
  if (isClient)
2428
2446
  servingSink().push(`[E1] ${rel}: "use client" 파일에서 createZalkeraClient 를 만든다 — baseUrl 노출.`);
@@ -2444,7 +2462,12 @@ function check(file) {
2444
2462
 
2445
2463
  // C1: ISR-우선 게이트 — SEO 라우트 page 는 per-page SSR 을 강제할 수 없다.
2446
2464
  if (isSeoPageFile(file) && !isClient) {
2447
- const hits = SSR_FORBIDDEN.filter(({re}) => re.test(src)).map(({why}) => why);
2465
+ // **주석을 지운 사본에서 잰다.** 원문에 대고 재면 "예전엔 여기서 cookies() 읽었다" 같은
2466
+ // 설명 주석이 그대로 오류가 된다 — 이 파일이 그 함정을 스스로 적어 뒀고(C1b 는 그렇게 고쳤다)
2467
+ // C1 만 원문에 남아 있었다. 게다가 유일한 탈출구인 `zalkera-allow-dynamic` 마커는 동적 SSR 을
2468
+ // 안 쓰는 페이지에 "동적 SSR 이 필요하다"는 **거짓 사유**를 적게 만든다.
2469
+ // 마커 탐색은 아래에서 원문에 대고 한다 — 마커는 주석이라 지운 사본엔 없다.
2470
+ const hits = SSR_FORBIDDEN.filter(({re}) => re.test(stripComments(src))).map(({why}) => why);
2448
2471
  if (hits.length > 0) {
2449
2472
  const allow = src.match(/\/\/\s*(?:zalkera|oneque?)-allow-dynamic:\s*(.+)/);
2450
2473
  const detail = `${rel}: SEO 라우트가 동적SSR 을 유발한다 → ${hits.join("; ")}`;
@@ -3336,7 +3359,13 @@ function checkManualCarrier() {
3336
3359
  return; // client 미설치 — 여기서 판정할 것이 없다.
3337
3360
  }
3338
3361
  if (!existsSync(carried)) return;
3339
- if (!readFileSync(at).equals(readFileSync(carried))) {
3362
+ // ⚠ **`readSafe` 를 쓴다.** 양쪽 다 테넌트가 통제하는 경로다(`llms.txt` 와 설치본). 맨몸
3363
+ // `readFileSync` 는 ⑴ 레포 밖 심링크를 따라가 **내용 동일성 오라클**이 되고(맞히면 침묵,
3364
+ // 틀리면 경고 — 길이 오라클도 겸한다) ⑵ 크기 상한을 통째로 우회한다(실측 RSS 354MB).
3365
+ const rootDoc = readSafe(at, "W-LLMS 루트 llms.txt");
3366
+ const carriedDoc = readSafe(carried, "W-LLMS 운반본 llms.txt");
3367
+ if (rootDoc === null || carriedDoc === null) return;
3368
+ if (rootDoc !== carriedDoc) {
3340
3369
  warnings.push(
3341
3370
  "[W-LLMS] 루트 llms.txt 가 설치된 @zalkera/client 의 것과 다릅니다 — client 를 올린 뒤 사본을 " +
3342
3371
  "안 고쳤다면 낡은 명세가 배송됩니다. 일부러 고친 것이면 이 경고는 무시하십시오.",
package/dist/index.cjs CHANGED
@@ -348,7 +348,7 @@ function internalPath(raw) {
348
348
  return out;
349
349
  }
350
350
  function safeLinkUrl(raw) {
351
- if (!raw) return "#";
351
+ if (typeof raw !== "string" || !raw) return "#";
352
352
  const url = raw.trim();
353
353
  if (url.startsWith("#") || url.startsWith("?")) return url;
354
354
  if (url.startsWith("/")) return internalPath(url) ?? "#";