@roottale/cms-core 0.33.0 → 0.38.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/CHANGELOG.md +32 -0
- package/dist/index.d.ts +12 -7
- package/dist/index.js +57 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @roottale/cms-core
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0d6ec9f: 보안(XSS) 수정 — 외부 고객 사이트 렌더 경로의 stored XSS 2건 차단.
|
|
8
|
+
- `cms-core` `sanitizeHtml`: 이벤트 핸들러/위험 URL 정규식 앵커를 `\s+` →
|
|
9
|
+
`[\s/]+` 로 강화. HTML5 파서가 `/` 도 속성 구분자로 허용하므로 과거
|
|
10
|
+
`<img/src=x/onerror=...>` 같은 슬래시-구분 토큰이 deny-list 를 우회해
|
|
11
|
+
무인터랙션 stored XSS 가 가능했다. 회귀 테스트 추가.
|
|
12
|
+
- `cms-renderer-astro` `renderSeoHead` / `seo-core` `serializeJsonLd`(신규
|
|
13
|
+
export): JSON-LD 를 `<script type="application/ld+json">` 에 넣을 때
|
|
14
|
+
`JSON.stringify` 가 `<`/`>`/`&` 를 escape 하지 않아 사용자 제어 title 등에
|
|
15
|
+
`</script>` 가 들어가면 head 에 임의 마크업이 주입됐다. `\uXXXX` escape 로 차단.
|
|
16
|
+
- 소비자(web-front / starter)에 strict CSP 배선(인라인 스크립트 실행 차단,
|
|
17
|
+
cms-core `PHASE_1_CSP` 정합) — deny-list 살균의 2차 방어선.
|
|
18
|
+
|
|
19
|
+
## 0.34.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 4b21cb9: ADR-0060 Amendment 1 M4 — 카테고리-소유권 파생 제거 (섹션은 collectionKey 전용)
|
|
24
|
+
|
|
25
|
+
`resolvePostCollection` 이 이제 글의 명시 `collectionKey` 만 본다. 카테고리 slug
|
|
26
|
+
파생·catch-all·first-wins 로직 제거. `RouteCollection.categories` 는 라우팅
|
|
27
|
+
소유권이 아니라 **주제(category) allowlist**(아카이브 범위)로만 의미가 바뀜.
|
|
28
|
+
`validateCollections` 의 "catch-all 도달 불가" 검사도 제거.
|
|
29
|
+
|
|
30
|
+
**동작 변경(주의):** collectionKey 없는 글은 컬렉션 모드 사이트에서 어느 스트림에도
|
|
31
|
+
안 속해 sitemap/상세 라우트에서 제외된다. 마이그레이션(M1 백필 + M2 에디터)로 모든
|
|
32
|
+
글에 collection_key 가 채워진 뒤 적용할 것. 단일 `/blog` 레거시(컬렉션 미선언) 사이트는
|
|
33
|
+
무영향.
|
|
34
|
+
|
|
3
35
|
## 0.33.0
|
|
4
36
|
|
|
5
37
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -95,9 +95,10 @@ interface RouteCollection {
|
|
|
95
95
|
/** 라우트 base path (예: "/notice" | "/blog"). 앞 슬래시 포함, 뒤 슬래시 없이. */
|
|
96
96
|
readonly basePath: string;
|
|
97
97
|
/**
|
|
98
|
-
* 이
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`
|
|
99
|
+
* 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`
|
|
100
|
+
* 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈
|
|
101
|
+
* 배열 = 이 섹션은 주제 없음.
|
|
101
102
|
*/
|
|
102
103
|
readonly categories: readonly string[];
|
|
103
104
|
/** RSS 피드 포함 여부 (기본 false). */
|
|
@@ -108,21 +109,25 @@ interface RouteCollection {
|
|
|
108
109
|
/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */
|
|
109
110
|
interface RoutablePost {
|
|
110
111
|
readonly slug: string;
|
|
112
|
+
/** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */
|
|
111
113
|
readonly terms: ReadonlyArray<{
|
|
112
114
|
readonly taxonomy: string;
|
|
113
115
|
readonly slug: string;
|
|
114
116
|
}>;
|
|
115
117
|
/**
|
|
116
|
-
* ADR-0060 Amendment 1 (Option A) — 글에 명시된 섹션 key.
|
|
117
|
-
*
|
|
118
|
+
* ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한
|
|
119
|
+
* 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.
|
|
118
120
|
*/
|
|
119
121
|
readonly collectionKey?: string | null;
|
|
120
122
|
}
|
|
121
123
|
/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */
|
|
122
124
|
declare function isInCollectionMode(collections: readonly RouteCollection[] | undefined): boolean;
|
|
123
125
|
/**
|
|
124
|
-
* 글이 속한 collection 1개를 해석 —
|
|
125
|
-
*
|
|
126
|
+
* 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의
|
|
127
|
+
* 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →
|
|
128
|
+
* sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —
|
|
129
|
+
* 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,
|
|
130
|
+
* 아카이브 라우트에서 쓰임.)
|
|
126
131
|
*/
|
|
127
132
|
declare function resolvePostCollection(post: RoutablePost, collections: readonly RouteCollection[]): RouteCollection | null;
|
|
128
133
|
/**
|
package/dist/index.js
CHANGED
|
@@ -119,27 +119,12 @@ function bindingSrcValue(value) {
|
|
|
119
119
|
function trimTrailingSlash(value) {
|
|
120
120
|
return value.replace(/\/+$/, "");
|
|
121
121
|
}
|
|
122
|
-
function categorySlugSet(post) {
|
|
123
|
-
const set = /* @__PURE__ */ new Set();
|
|
124
|
-
for (const term of post.terms) {
|
|
125
|
-
if (term.taxonomy === "category") set.add(term.slug);
|
|
126
|
-
}
|
|
127
|
-
return set;
|
|
128
|
-
}
|
|
129
122
|
function isInCollectionMode(collections) {
|
|
130
123
|
return !!collections && collections.length > 0;
|
|
131
124
|
}
|
|
132
125
|
function resolvePostCollection(post, collections) {
|
|
133
|
-
if (post.collectionKey)
|
|
134
|
-
|
|
135
|
-
if (explicit) return explicit;
|
|
136
|
-
}
|
|
137
|
-
const slugs = categorySlugSet(post);
|
|
138
|
-
for (const collection of collections) {
|
|
139
|
-
if (collection.categories.length === 0) return collection;
|
|
140
|
-
if (collection.categories.some((slug) => slugs.has(slug))) return collection;
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
126
|
+
if (!post.collectionKey) return null;
|
|
127
|
+
return collections.find((c) => c.key === post.collectionKey) ?? null;
|
|
143
128
|
}
|
|
144
129
|
function resolvePostUrl(siteUrl, post, collections) {
|
|
145
130
|
const collection = resolvePostCollection(post, collections);
|
|
@@ -162,13 +147,6 @@ function validateCollections(collections) {
|
|
|
162
147
|
const seenKey = /* @__PURE__ */ new Set();
|
|
163
148
|
const seenBase = /* @__PURE__ */ new Set();
|
|
164
149
|
const categoryOwner = /* @__PURE__ */ new Map();
|
|
165
|
-
collections.forEach((c, i) => {
|
|
166
|
-
if (c.categories.length === 0 && i < collections.length - 1) {
|
|
167
|
-
problems.push(
|
|
168
|
-
`catch-all collection "${c.key}"(\uBE48 categories) \uB4A4\uC758 \uC2A4\uD2B8\uB9BC\uC740 \uB3C4\uB2EC \uBD88\uAC00 \u2014 \uB9E8 \uB4A4\uB85C \uC62E\uAE38 \uAC83.`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
150
|
for (const c of collections) {
|
|
173
151
|
if (seenKey.has(c.key)) problems.push(`\uC911\uBCF5 collection key: "${c.key}"`);
|
|
174
152
|
seenKey.add(c.key);
|
|
@@ -349,11 +327,58 @@ function verifyPreviewToken(secret, token) {
|
|
|
349
327
|
// src/sanitize.ts
|
|
350
328
|
var DANGEROUS_BLOCK_RE = /<(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset)\b[\s\S]*?<\/\1\s*>/gi;
|
|
351
329
|
var DANGEROUS_VOID_RE = /<\/?(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset|base|meta|link|applet|param)\b[^>]*>/gi;
|
|
352
|
-
var ON_EVENT_ATTR_DQ_RE =
|
|
353
|
-
var ON_EVENT_ATTR_SQ_RE =
|
|
354
|
-
var ON_EVENT_ATTR_UQ_RE =
|
|
355
|
-
var
|
|
356
|
-
var
|
|
330
|
+
var ON_EVENT_ATTR_DQ_RE = /[\s/]+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
|
|
331
|
+
var ON_EVENT_ATTR_SQ_RE = /[\s/]+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
|
|
332
|
+
var ON_EVENT_ATTR_UQ_RE = /[\s/]+on[a-z]+\s*=\s*[^\s>]+/gi;
|
|
333
|
+
var URL_ATTR_RE = /([\s/])(href|src|xlink:href|srcdoc|formaction|action|poster)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
334
|
+
var ALLOWED_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "mailto", "tel"]);
|
|
335
|
+
var NAMED_ENTITY = {
|
|
336
|
+
colon: ":",
|
|
337
|
+
tab: " ",
|
|
338
|
+
newline: "\n",
|
|
339
|
+
sol: "/",
|
|
340
|
+
semi: ";",
|
|
341
|
+
num: "#",
|
|
342
|
+
period: ".",
|
|
343
|
+
comma: ",",
|
|
344
|
+
lpar: "(",
|
|
345
|
+
rpar: ")"
|
|
346
|
+
};
|
|
347
|
+
function fromCodePointSafe(n) {
|
|
348
|
+
if (!Number.isFinite(n) || n < 0 || n > 1114111) return "";
|
|
349
|
+
try {
|
|
350
|
+
return String.fromCodePoint(n);
|
|
351
|
+
} catch {
|
|
352
|
+
return "";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function decodeUrlProbe(value) {
|
|
356
|
+
const decoded = value.replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16))).replace(/&#(\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10))).replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);
|
|
357
|
+
return [...decoded].filter((c) => {
|
|
358
|
+
const n = c.codePointAt(0) ?? 0;
|
|
359
|
+
return n > 32 && n !== 127;
|
|
360
|
+
}).join("").toLowerCase();
|
|
361
|
+
}
|
|
362
|
+
function isSafeUrlValue(attr, value) {
|
|
363
|
+
const probe = decodeUrlProbe(value);
|
|
364
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];
|
|
365
|
+
if (!scheme) return true;
|
|
366
|
+
if (ALLOWED_URL_SCHEMES.has(scheme)) return true;
|
|
367
|
+
if (scheme === "data" && attr === "src" && probe.startsWith("data:image/")) {
|
|
368
|
+
return !probe.startsWith("data:image/svg");
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
function neutralizeUrlAttrs(html) {
|
|
373
|
+
return html.replace(
|
|
374
|
+
URL_ATTR_RE,
|
|
375
|
+
(full, sep, attr, eq, dq, sq, uq) => {
|
|
376
|
+
const value = dq ?? sq ?? uq ?? "";
|
|
377
|
+
if (isSafeUrlValue(String(attr).toLowerCase(), value)) return full;
|
|
378
|
+
return `${sep}${attr}${eq}"#"`;
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
}
|
|
357
382
|
function sanitizeHtml(html) {
|
|
358
383
|
if (!html) return html;
|
|
359
384
|
let out = html;
|
|
@@ -362,9 +387,10 @@ function sanitizeHtml(html) {
|
|
|
362
387
|
out = out.replace(DANGEROUS_BLOCK_RE, "").replace(DANGEROUS_VOID_RE, "");
|
|
363
388
|
if (out === prev) break;
|
|
364
389
|
}
|
|
365
|
-
|
|
390
|
+
out = out.replace(ON_EVENT_ATTR_DQ_RE, "").replace(ON_EVENT_ATTR_SQ_RE, "").replace(ON_EVENT_ATTR_UQ_RE, "");
|
|
391
|
+
return neutralizeUrlAttrs(out);
|
|
366
392
|
}
|
|
367
|
-
var PHASE_1_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
|
393
|
+
var PHASE_1_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; font-src 'self' data:; connect-src 'self'; frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
|
368
394
|
|
|
369
395
|
// src/stable-id.ts
|
|
370
396
|
import { uuidv7 } from "uuidv7";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/preview-token.ts","../src/sanitize.ts","../src/stable-id.ts"],"sourcesContent":["// ADR-0034 §5 — Block JSON 스펙 (WP 호환)\n// Gutenberg block shape + 우리 확장:\n// - `_id` (uuidv7 stable across revisions, ADR §2 P5)\n// - `rawHtml` (WP delimiter innerHTML 보존, codex v2 verdict #7)\n// - `metadata.reusableRef` (Phase 2 = flat expand)\n\nimport { z } from \"zod\";\n\n/** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */\nexport const BLOCK_NAME_PATTERN = /^(core|roottale)\\/[a-z0-9-]+$/;\n\nconst blockMetadataSchema = z\n .object({\n bindings: z\n .record(\n z.object({\n source: z.string(),\n args: z.record(z.unknown()).optional(),\n }),\n )\n .optional(),\n name: z.string().optional(),\n reusableRef: z.string().optional(),\n })\n .strict()\n .optional();\n\n/**\n * Block schema (recursive — innerBlocks).\n * `_id` 는 internal-only (export 시 strip, ADR §5).\n */\nexport const blockSchema: z.ZodType<Block> = z.lazy(() =>\n z\n .object({\n _id: z.string().uuid(),\n name: z.string().regex(BLOCK_NAME_PATTERN),\n attributes: z.record(z.unknown()),\n innerBlocks: z.array(blockSchema),\n rawHtml: z.string().optional(),\n metadata: blockMetadataSchema,\n })\n .strict(),\n);\n\nexport interface Block {\n /** uuidv7. Internal-only — never exported as WP delimiter attribute. */\n _id: string;\n /** `core/<x>` or `roottale/<x>` */\n name: string;\n attributes: Record<string, unknown>;\n innerBlocks: Block[];\n /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */\n rawHtml?: string;\n metadata?: {\n bindings?: Record<string, { source: string; args?: Record<string, unknown> }>;\n name?: string;\n reusableRef?: string;\n };\n}\n\nexport function isValidBlock(value: unknown): value is Block {\n return blockSchema.safeParse(value).success;\n}\n\nexport function parseBlock(value: unknown): Block {\n return blockSchema.parse(value);\n}\n\n/**\n * Phase 1 core block names (10) — ADR-0034 §5.\n * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.\n */\nexport const PHASE_1_CORE_BLOCKS = [\n \"core/paragraph\",\n \"core/heading\",\n \"core/image\",\n \"core/list\",\n \"core/quote\",\n \"core/code\",\n \"core/columns\",\n \"core/group\",\n \"core/separator\",\n \"core/spacer\",\n] as const;\nexport type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];\n","// ADR-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - **선언 순서 = 우선순위.** 글이 여러 스트림 카테고리를 동시에 가지면 먼저 선언된\n * 것이 이긴다(first-wins). route 팩토리는 deterministic·fail-soft.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /** 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이. */\n readonly basePath: string;\n /**\n * 이 스트림에 속하는 category slug 들. **빈 배열 = catch-all** — 앞선 스트림이\n * 잡지 않은 글을 전부 흡수한다(선언 순서상 보통 맨 뒤. 예: 공지=[notice] 먼저,\n * 블로그=[] catch-all 뒤). catch-all 뒤의 스트림은 도달 불가.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A) — 글에 명시된 섹션 key. 있으면 카테고리 slug\n * 파생보다 **우선**한다. 미설정(레거시 글)이면 기존 카테고리 파생으로 fallback.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\nfunction categorySlugSet(post: RoutablePost): Set<string> {\n const set = new Set<string>();\n for (const term of post.terms) {\n if (term.taxonomy === \"category\") set.add(term.slug);\n }\n return set;\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — **선언 순서 = 우선순위**(first-wins). 어느\n * 스트림에도 안 속하면 null (상세 라우트 없는 글 → sitemap 제외 신호).\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n // Option A — 명시 collectionKey 우선. 일치하는 섹션이 있으면 즉시 반환.\n if (post.collectionKey) {\n const explicit = collections.find((c) => c.key === post.collectionKey);\n if (explicit) return explicit;\n }\n // Fallback — 카테고리 slug 파생(레거시/미마이그레이션 글, 또는 stale key).\n const slugs = categorySlugSet(post);\n for (const collection of collections) {\n // 빈 categories = catch-all → 여기 도달한 글(앞선 스트림 미매칭)을 전부 흡수.\n if (collection.categories.length === 0) return collection;\n if (collection.categories.some((slug) => slugs.has(slug))) return collection;\n }\n return null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하면 null.\n * slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림 없으면\n * null. astro 처럼 상대 href 가 필요한 소비자용(`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection 에 *최종 귀속*되는 글만 — 전체 정렬 리스트로 first-wins 해석\n * (단순 카테고리 매칭이 아니라 우선순위 적용). 한 글이 여러 리스트에 중복되는 것을\n * 방지(codex 지적).\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. 문제 메시지 배열 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n collections.forEach((c, i) => {\n // catch-all(빈 categories) 뒤의 스트림은 영원히 도달 불가.\n if (c.categories.length === 0 && i < collections.length - 1) {\n problems.push(\n `catch-all collection \"${c.key}\"(빈 categories) 뒤의 스트림은 도달 불가 — 맨 뒤로 옮길 것.`,\n );\n }\n });\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\n","// ADR-0034 §5 + §6 — WP delimiter parse/serialize\n//\n// WP 의 block serialization 은 HTML 주석 형태:\n// <!-- wp:paragraph {\"attr\":1} -->\n// <p>본문</p>\n// <!-- /wp:paragraph -->\n//\n// Round-trip 룰 (codex v2 verdict #7 + v3 verdict #4):\n// - `_id` 는 export 시 strip (WP-side import 가 unknown attribute 로 인식해 깨짐 차단)\n// - `rawHtml` ↔ innerHTML\n// - 우리가 schema 정의 안 한 block = Tiptap opaque atom node (편집 불가, 보존)\n\nimport type { Block } from \"./block\";\n\n/**\n * Block → WP delimiter 형식 직렬화 (export).\n * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.\n */\nexport function serializeBlock(block: Block): string {\n const exportable = sanitizeAttributesForExport(block);\n const attrsJson = serializeAttributes(exportable.attributes);\n const innerHtml = block.rawHtml ?? \"\";\n const innerSerialized = (block.innerBlocks ?? [])\n .map((b) => serializeBlock(b))\n .join(\"\\n\");\n\n // self-closing 형태 (innerHTML + innerBlocks 모두 없으면)\n if (!innerHtml && innerSerialized.length === 0) {\n return attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->`\n : `<!-- wp:${normalizeName(block.name)} /-->`;\n }\n\n const open = attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->`\n : `<!-- wp:${normalizeName(block.name)} -->`;\n const close = `<!-- /wp:${normalizeName(block.name)} -->`;\n\n const body = [innerHtml, innerSerialized].filter(Boolean).join(\"\\n\");\n return `${open}\\n${body}\\n${close}`;\n}\n\n/** WP-side 에서 `core/` 는 보통 namespace 생략 (e.g. `<!-- wp:paragraph -->`) */\nfunction normalizeName(name: string): string {\n return name.startsWith(\"core/\") ? name.slice(\"core/\".length) : name;\n}\n\nfunction serializeAttributes(attrs: Record<string, unknown>): string {\n const keys = Object.keys(attrs);\n if (keys.length === 0) return \"\";\n return JSON.stringify(attrs);\n}\n\nfunction sanitizeAttributesForExport(block: Block): {\n attributes: Record<string, unknown>;\n} {\n // `_id` 와 `metadata.reusableRef` 제거. attributes 안의 임의 키는 그대로 export.\n return { attributes: { ...block.attributes } };\n}\n","// ADR-0034 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// ADR-0034 §7.1.1 test #10 — Draft preview token sign/verify.\n//\n// HMAC-SHA256 signed token + DB row (post_preview_tokens_v2) lookup.\n// Atomic consume = consumedAt update at verify time.\n//\n// Token format: `<postId>.<expiresAt-ms>.<hmac-base64url>`\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nexport interface PreviewTokenPayload {\n postId: string;\n expiresAt: Date;\n}\n\nconst DELIMITER = \".\";\n\nfunction base64url(buf: Buffer): string {\n return buf.toString(\"base64url\");\n}\n\nfunction fromBase64url(s: string): Buffer {\n return Buffer.from(s, \"base64url\");\n}\n\nfunction sign(secret: string, message: string): string {\n return base64url(createHmac(\"sha256\", secret).update(message).digest());\n}\n\n/** 새 preview token 생성. DB insert 책임은 caller. */\nexport function issuePreviewToken(\n secret: string,\n payload: PreviewTokenPayload,\n): string {\n const exp = payload.expiresAt.getTime();\n const nonce = base64url(randomBytes(12));\n const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;\n const sig = sign(secret, message);\n return `${message}${DELIMITER}${sig}`;\n}\n\n/** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */\nexport function verifyPreviewToken(\n secret: string,\n token: string,\n): PreviewTokenPayload | null {\n const parts = token.split(DELIMITER);\n if (parts.length !== 4) return null;\n const [postId, expRaw, nonce, sig] = parts as [string, string, string, string];\n const exp = Number(expRaw);\n if (!Number.isFinite(exp)) return null;\n if (Date.now() > exp) return null;\n\n const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);\n const a = fromBase64url(sig);\n const b = fromBase64url(expectedSig);\n if (a.length !== b.length) return null;\n if (!timingSafeEqual(a, b)) return null;\n\n return { postId, expiresAt: new Date(exp) };\n}\n","// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (deny-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// ⚠️ 한계: 이것은 정규식 기반 deny-list 다. 완전한 방어는 DOM 파서 기반\n// allow-list(sanitize-html / DOMPurify) 가 정답이며 [ADR-0034 followup] 으로\n// 격상 예정 — 그 전까지 본 함수 + CSP + (write 경로의) 인증 키 조합으로 방어.\n// Tiptap output 은 제한된 inline HTML 이라 deny-list 로도 acceptance 를 충족한다.\n\n// 닫는 태그까지 통째 제거할 위험 요소 (내용도 폐기).\nconst DANGEROUS_BLOCK_RE =\n /<(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset)\\b[\\s\\S]*?<\\/\\1\\s*>/gi;\n// 자기-닫힘/void 형태나 짝 없는 위험 여는 태그.\nconst DANGEROUS_VOID_RE =\n /<\\/?(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset|base|meta|link|applet|param)\\b[^>]*>/gi;\n// 모든 on* 이벤트 핸들러 — 따옴표/무따옴표 모두.\nconst ON_EVENT_ATTR_DQ_RE = /\\s+on[a-z]+\\s*=\\s*\"(?:[^\"\\\\]|\\\\.)*\"/gi;\nconst ON_EVENT_ATTR_SQ_RE = /\\s+on[a-z]+\\s*=\\s*'(?:[^'\\\\]|\\\\.)*'/gi;\nconst ON_EVENT_ATTR_UQ_RE = /\\s+on[a-z]+\\s*=\\s*[^\\s>]+/gi;\n// href/src/xlink:href/srcdoc/formaction 등에 박힌 위험 scheme.\nconst DANGEROUS_URL_ATTR_RE =\n /\\s+(href|src|xlink:href|srcdoc|formaction|action|data|poster)\\s*=\\s*(['\"])\\s*(?:javascript|vbscript|data(?!:image\\/)):[^'\"]*\\2/gi;\nconst DANGEROUS_URL_ATTR_UQ_RE =\n /\\s+(href|src|xlink:href|srcdoc|formaction|action|data|poster)\\s*=\\s*(?:javascript|vbscript|data(?!:image\\/)):[^\\s>]*/gi;\n\n/**\n * XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리 (렌더 직전 적용).\n * Phase 1 acceptance: `<script>alert(1)</script>` 등 인라인 벡터가 inert.\n */\nexport function sanitizeHtml(html: string): string {\n if (!html) return html;\n let out = html;\n // 위험 태그를 반복 제거 (중첩/주석 우회로 한 패스가 새 토큰을 노출할 수 있어\n // 고정점까지 반복).\n for (let i = 0; i < 3; i++) {\n const prev = out;\n out = out\n .replace(DANGEROUS_BLOCK_RE, \"\")\n .replace(DANGEROUS_VOID_RE, \"\");\n if (out === prev) break;\n }\n return out\n .replace(ON_EVENT_ATTR_DQ_RE, \"\")\n .replace(ON_EVENT_ATTR_SQ_RE, \"\")\n .replace(ON_EVENT_ATTR_UQ_RE, \"\")\n .replace(DANGEROUS_URL_ATTR_RE, ' $1=\"#\"')\n .replace(DANGEROUS_URL_ATTR_UQ_RE, ' $1=\"#\"');\n}\n\n/**\n * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).\n * Astro middleware / Next.js header config 에서 사용.\n */\nexport const PHASE_1_CSP =\n \"default-src 'self'; \" +\n \"script-src 'self'; \" +\n \"style-src 'self' 'unsafe-inline'; \" +\n \"img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; \" +\n \"font-src 'self' data:; \" +\n \"connect-src 'self'; \" +\n \"frame-ancestors 'none'; \" +\n \"base-uri 'self'; \" +\n \"form-action 'self'\";\n","// ADR-0034 §2 P5 — Stable block id (uuidv7 inject)\n//\n// Tiptap 의 BlockId extension 이 본 helper 를 사용해 새 block 에 uuidv7 inject.\n// 기존 block 의 `_id` 는 보존 (revision 간 정합).\n\nimport { uuidv7 } from \"uuidv7\";\n\nimport type { Block } from \"./block\";\n\n/**\n * Block 에 stable id 주입.\n * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).\n */\nexport function ensureStableIds(block: Omit<Block, \"_id\"> & { _id?: string }): Block {\n return {\n ...block,\n _id: block._id ?? uuidv7(),\n innerBlocks: (block.innerBlocks ?? []).map((inner) =>\n ensureStableIds(inner as Omit<Block, \"_id\"> & { _id?: string }),\n ),\n };\n}\n\n/**\n * 한 번에 block tree 전체에 stable id 주입.\n * import 또는 새 post 생성 시 entrypoint.\n */\nexport function injectStableIdsTree(blocks: readonly (Omit<Block, \"_id\"> & { _id?: string })[]): Block[] {\n return blocks.map((b) => ensureStableIds(b));\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/block.ts","../src/bindings.ts","../src/collections.ts","../src/delimiter.ts","../src/embed.ts","../src/preview-token.ts","../src/sanitize.ts","../src/stable-id.ts"],"sourcesContent":["// ADR-0034 §5 — Block JSON 스펙 (WP 호환)\n// Gutenberg block shape + 우리 확장:\n// - `_id` (uuidv7 stable across revisions, ADR §2 P5)\n// - `rawHtml` (WP delimiter innerHTML 보존, codex v2 verdict #7)\n// - `metadata.reusableRef` (Phase 2 = flat expand)\n\nimport { z } from \"zod\";\n\n/** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */\nexport const BLOCK_NAME_PATTERN = /^(core|roottale)\\/[a-z0-9-]+$/;\n\nconst blockMetadataSchema = z\n .object({\n bindings: z\n .record(\n z.object({\n source: z.string(),\n args: z.record(z.unknown()).optional(),\n }),\n )\n .optional(),\n name: z.string().optional(),\n reusableRef: z.string().optional(),\n })\n .strict()\n .optional();\n\n/**\n * Block schema (recursive — innerBlocks).\n * `_id` 는 internal-only (export 시 strip, ADR §5).\n */\nexport const blockSchema: z.ZodType<Block> = z.lazy(() =>\n z\n .object({\n _id: z.string().uuid(),\n name: z.string().regex(BLOCK_NAME_PATTERN),\n attributes: z.record(z.unknown()),\n innerBlocks: z.array(blockSchema),\n rawHtml: z.string().optional(),\n metadata: blockMetadataSchema,\n })\n .strict(),\n);\n\nexport interface Block {\n /** uuidv7. Internal-only — never exported as WP delimiter attribute. */\n _id: string;\n /** `core/<x>` or `roottale/<x>` */\n name: string;\n attributes: Record<string, unknown>;\n innerBlocks: Block[];\n /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */\n rawHtml?: string;\n metadata?: {\n bindings?: Record<string, { source: string; args?: Record<string, unknown> }>;\n name?: string;\n reusableRef?: string;\n };\n}\n\nexport function isValidBlock(value: unknown): value is Block {\n return blockSchema.safeParse(value).success;\n}\n\nexport function parseBlock(value: unknown): Block {\n return blockSchema.parse(value);\n}\n\n/**\n * Phase 1 core block names (10) — ADR-0034 §5.\n * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.\n */\nexport const PHASE_1_CORE_BLOCKS = [\n \"core/paragraph\",\n \"core/heading\",\n \"core/image\",\n \"core/list\",\n \"core/quote\",\n \"core/code\",\n \"core/columns\",\n \"core/group\",\n \"core/separator\",\n \"core/spacer\",\n] as const;\nexport type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];\n","// ADR-0041 §7 — Block Bindings: ACF 필드 → 블록 attribute.\n//\n// 블록의 `metadata.bindings[attr] = { source, args }` 를 만나면 attribute 를\n// 바인딩 값으로 치환한다. 렌더 직전 pure transform — renderBlock 시그니처 불변.\n//\n// canonical source id = `roottale/field`. WP round-trip 위해 `acf/field` alias\n// 인식. resolver 는 주입(렌더러는 cms-fields 비종속) — 값 record 만 흐른다.\n\nimport type { Block } from \"./block\";\n\nexport interface BlockBinding {\n source: string;\n args?: Record<string, unknown>;\n}\n\n/**\n * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.\n * `undefined` 반환 = 미해석(원래 attribute 유지).\n */\nexport type BindingResolver = (\n binding: BlockBinding,\n attribute: string,\n block: Block,\n) => unknown;\n\n/** ACF 필드 바인딩 source id. */\nexport const FIELD_BINDING_SOURCE = \"roottale/field\";\n/** WP ACF 호환 alias (import round-trip). */\nexport const FIELD_BINDING_SOURCE_ALIAS = \"acf/field\";\n\n/**\n * acf 값 record 로부터 필드 바인딩 resolver 생성.\n * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.\n */\nexport function createFieldBindingResolver(\n fields: Record<string, unknown> | undefined | null,\n): BindingResolver {\n const values = fields ?? {};\n return (binding) => {\n if (\n binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const v = values[name];\n return v === undefined ? undefined : v;\n };\n}\n\n/** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */\nexport function chainResolvers(\n ...resolvers: BindingResolver[]\n): BindingResolver {\n return (binding, attribute, block) => {\n for (const r of resolvers) {\n const v = r(binding, attribute, block);\n if (v !== undefined) return v;\n }\n return undefined;\n };\n}\n\nfunction applyToBlock(block: Block, resolver: BindingResolver): Block {\n const bindings = block.metadata?.bindings;\n let attributes = block.attributes;\n if (bindings) {\n let mutated = false;\n const next = { ...block.attributes };\n for (const [attr, binding] of Object.entries(bindings)) {\n const value = resolver(binding, attr, block);\n if (value !== undefined) {\n next[attr] = value;\n mutated = true;\n }\n }\n if (mutated) attributes = next;\n }\n const innerBlocks = block.innerBlocks.length\n ? block.innerBlocks.map((b) => applyToBlock(b, resolver))\n : block.innerBlocks;\n if (attributes === block.attributes && innerBlocks === block.innerBlocks) {\n return block;\n }\n return { ...block, attributes, innerBlocks };\n}\n\n/**\n * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.\n */\nexport function applyBlockBindings(\n blocks: readonly Block[],\n resolver: BindingResolver,\n): Block[] {\n return blocks.map((b) => applyToBlock(b, resolver));\n}\n\n// ── 라이브 Tiptap 렌더 경로용 헬퍼 (cms-field-binding-editor.md D3·D4) ─────────\n//\n// Tiptap doc 의 node.attrs.bindings (BlockBindings extension 이 기록, Block JSON\n// metadata.bindings 와 동일 형태) 를 렌더 시점에 필드 값으로 치환할 때 사용.\n\n/**\n * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`\n * source 만 해석. 빈 값(undefined/null/\"\") = undefined → caller 가 fallback 렌더.\n */\nexport function boundFieldValue(\n bindings: unknown,\n attribute: string,\n fields: Record<string, unknown> | null | undefined,\n): unknown {\n if (!bindings || typeof bindings !== \"object\" || !fields) return undefined;\n const binding = (bindings as Record<string, BlockBinding | undefined>)[\n attribute\n ];\n if (\n !binding ||\n (binding.source !== FIELD_BINDING_SOURCE &&\n binding.source !== FIELD_BINDING_SOURCE_ALIAS)\n ) {\n return undefined;\n }\n const name = binding.args?.name;\n if (typeof name !== \"string\") return undefined;\n const value = fields[name];\n if (value === undefined || value === null || value === \"\") return undefined;\n return value;\n}\n\n/**\n * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해\n * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.\n */\nexport function bindingTextValue(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return undefined;\n}\n\n/**\n * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만\n * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.\n */\nexport function bindingSrcValue(value: unknown): string | undefined {\n if (value && typeof value === \"object\") {\n const url = (value as { url?: unknown }).url;\n if (typeof url === \"string\" && /^https?:\\/\\//i.test(url)) return url;\n return undefined;\n }\n if (typeof value === \"string\" && /^https?:\\/\\//i.test(value)) return value;\n return undefined;\n}\n","/**\n * 다중 스트림 콘텐츠 라우팅 프리미티브 (ADR-0060).\n *\n * 같은 글 풀(post)을 둘 이상의 스트림으로 분리(공지=게시판 /notice, 블로그=그리드\n * /blog 등). `cms-renderer-next` / `cms-renderer-astro` 가 동일 로직을 공유하도록\n * 여기(cms-core)에 둔다 — cms-client·프레임워크 비의존(구조적 타입만).\n *\n * 설계(codex consult `019ef577` 반영):\n * - **활성화 = `collections.length > 0`.** 빈 배열·미전달 = 레거시 단일 /blog.\n * - **선언 순서 = 우선순위.** 글이 여러 스트림 카테고리를 동시에 가지면 먼저 선언된\n * 것이 이긴다(first-wins). route 팩토리는 deterministic·fail-soft.\n * - `RouteCollection` 은 **라우팅 최소셋**만(key/basePath/categories/feed/archives).\n * label/layout/icon 등 표시·작성 메타는 templates `CollectionSchema` 에 둔다.\n */\n\n/** 콘텐츠 스트림 1개 — sitemap/feed/아카이브 파생의 라우팅 단위. */\nexport interface RouteCollection {\n /** 안정 식별자 (예: \"notice\" | \"blog\"). */\n readonly key: string;\n /** 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함, 뒤 슬래시 없이. */\n readonly basePath: string;\n /**\n * 이 섹션이 제공하는 **주제(category) allowlist** — `${basePath}/categories/{slug}`\n * 아카이브 라우트 범위. ADR-0060 Amendment 1 (M4): 섹션 소속은 글의 `collectionKey`\n * 로 정해지므로 이 배열은 더 이상 라우팅 소유권이 아니다(catch-all 개념 폐기). 빈\n * 배열 = 이 섹션은 주제 없음.\n */\n readonly categories: readonly string[];\n /** RSS 피드 포함 여부 (기본 false). */\n readonly feed?: boolean;\n /** `${basePath}/categories/{slug}` 아카이브 emit 여부 (기본 false). */\n readonly archives?: boolean;\n}\n\n/** resolve 입력 — 글의 구조적 최소 형태(cms-client `CmsPostContent` 와 구조 호환). */\nexport interface RoutablePost {\n readonly slug: string;\n /** 글의 주제(category) terms — 아카이브 라우트 필터용. 섹션 판정엔 쓰지 않는다(M4). */\n readonly terms: ReadonlyArray<{ readonly taxonomy: string; readonly slug: string }>;\n /**\n * ADR-0060 Amendment 1 (Option A, M4) — 글에 명시된 섹션 key. 섹션 판정의 유일한\n * 근거. 미설정/미존재면 어느 스트림에도 안 속함(null). 카테고리 파생 fallback 은 제거됨.\n */\n readonly collectionKey?: string | null;\n}\n\nfunction trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** collection 모드인지 — 길이 0(또는 미전달)이면 레거시 단일 /blog 동작. */\nexport function isInCollectionMode(\n collections: readonly RouteCollection[] | undefined,\n): boolean {\n return !!collections && collections.length > 0;\n}\n\n/**\n * 글이 속한 collection 1개를 해석 — ADR-0060 Amendment 1 (Option A, M4): **글의\n * 명시 `collectionKey` 만** 본다. 미설정/미존재 key 면 null(상세 라우트 없는 글 →\n * sitemap 제외 신호). 카테고리 slug 파생·catch-all·first-wins 는 M4 에서 제거됨 —\n * 섹션은 카테고리가 아니라 글에 명시한다. (`categories` 는 이제 주제 allowlist 로만,\n * 아카이브 라우트에서 쓰임.)\n */\nexport function resolvePostCollection(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): RouteCollection | null {\n if (!post.collectionKey) return null;\n return collections.find((c) => c.key === post.collectionKey) ?? null;\n}\n\n/**\n * 글의 정규 절대 URL — 소속 스트림 basePath 기준. 어느 스트림에도 안 속하면 null.\n * slug 는 percent-encoding (한글 slug 안전).\n */\nexport function resolvePostUrl(\n siteUrl: string,\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n return `${trimTrailingSlash(siteUrl)}${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 글의 *경로*(siteUrl 없는 basePath-상대) — 예: \"/blog/hello\". 소속 스트림 없으면\n * null. astro 처럼 상대 href 가 필요한 소비자용(`resolvePostUrl` 은 절대 URL).\n */\nexport function resolvePostPath(\n post: RoutablePost,\n collections: readonly RouteCollection[],\n): string | null {\n const collection = resolvePostCollection(post, collections);\n if (!collection) return null;\n return `${trimTrailingSlash(collection.basePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n/**\n * 특정 collection 에 *최종 귀속*되는 글만 — 전체 정렬 리스트로 first-wins 해석\n * (단순 카테고리 매칭이 아니라 우선순위 적용). 한 글이 여러 리스트에 중복되는 것을\n * 방지(codex 지적).\n */\nexport function postsForCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n key: string,\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections)?.key === key);\n}\n\n/** feed/sitemap 등 파생에서 제외할, 어느 스트림에도 안 속한 글 제거. */\nexport function postsInAnyCollection<T extends RoutablePost>(\n posts: readonly T[],\n collections: readonly RouteCollection[],\n): T[] {\n return posts.filter((p) => resolvePostCollection(p, collections) !== null);\n}\n\n/**\n * config 검증 — 중복 key/basePath, 그리고 **두 스트림에 동시 등장하는 category slug**\n * (라우팅 모호성)를 탐지. 문제 메시지 배열 반환(빈 = 정상). 빌드 타임 fail-fast 용.\n */\nexport function validateCollections(collections: readonly RouteCollection[]): string[] {\n const problems: string[] = [];\n const seenKey = new Set<string>();\n const seenBase = new Set<string>();\n const categoryOwner = new Map<string, string>();\n\n for (const c of collections) {\n if (seenKey.has(c.key)) problems.push(`중복 collection key: \"${c.key}\"`);\n seenKey.add(c.key);\n\n const base = trimTrailingSlash(c.basePath);\n if (seenBase.has(base)) problems.push(`중복 basePath: \"${c.basePath}\"`);\n seenBase.add(base);\n\n if (!c.basePath.startsWith(\"/\")) {\n problems.push(`basePath 는 \"/\" 로 시작해야 함: \"${c.basePath}\" (${c.key})`);\n }\n\n for (const slug of c.categories) {\n const owner = categoryOwner.get(slug);\n if (owner && owner !== c.key) {\n problems.push(\n `category \"${slug}\" 가 collection \"${owner}\" 와 \"${c.key}\" 에 중복 — 한 카테고리는 한 스트림에만.`,\n );\n }\n categoryOwner.set(slug, c.key);\n }\n }\n return problems;\n}\n","// ADR-0034 §5 + §6 — WP delimiter parse/serialize\n//\n// WP 의 block serialization 은 HTML 주석 형태:\n// <!-- wp:paragraph {\"attr\":1} -->\n// <p>본문</p>\n// <!-- /wp:paragraph -->\n//\n// Round-trip 룰 (codex v2 verdict #7 + v3 verdict #4):\n// - `_id` 는 export 시 strip (WP-side import 가 unknown attribute 로 인식해 깨짐 차단)\n// - `rawHtml` ↔ innerHTML\n// - 우리가 schema 정의 안 한 block = Tiptap opaque atom node (편집 불가, 보존)\n\nimport type { Block } from \"./block\";\n\n/**\n * Block → WP delimiter 형식 직렬화 (export).\n * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.\n */\nexport function serializeBlock(block: Block): string {\n const exportable = sanitizeAttributesForExport(block);\n const attrsJson = serializeAttributes(exportable.attributes);\n const innerHtml = block.rawHtml ?? \"\";\n const innerSerialized = (block.innerBlocks ?? [])\n .map((b) => serializeBlock(b))\n .join(\"\\n\");\n\n // self-closing 형태 (innerHTML + innerBlocks 모두 없으면)\n if (!innerHtml && innerSerialized.length === 0) {\n return attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->`\n : `<!-- wp:${normalizeName(block.name)} /-->`;\n }\n\n const open = attrsJson\n ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->`\n : `<!-- wp:${normalizeName(block.name)} -->`;\n const close = `<!-- /wp:${normalizeName(block.name)} -->`;\n\n const body = [innerHtml, innerSerialized].filter(Boolean).join(\"\\n\");\n return `${open}\\n${body}\\n${close}`;\n}\n\n/** WP-side 에서 `core/` 는 보통 namespace 생략 (e.g. `<!-- wp:paragraph -->`) */\nfunction normalizeName(name: string): string {\n return name.startsWith(\"core/\") ? name.slice(\"core/\".length) : name;\n}\n\nfunction serializeAttributes(attrs: Record<string, unknown>): string {\n const keys = Object.keys(attrs);\n if (keys.length === 0) return \"\";\n return JSON.stringify(attrs);\n}\n\nfunction sanitizeAttributesForExport(block: Block): {\n attributes: Record<string, unknown>;\n} {\n // `_id` 와 `metadata.reusableRef` 제거. attributes 안의 임의 키는 그대로 export.\n return { attributes: { ...block.attributes } };\n}\n","// ADR-0034 §5 — `core/embed` 영상 임베드 헬퍼 (provider allowlist)\n//\n// 본 모듈은 editor (URL → embed 노드 정규화) 와 renderer (저장된 embed src 의\n// 출력 시 재검증, defense-in-depth) 양쪽에서 공유. 순수 모듈 — `URL` 만 사용해\n// 브라우저/서버 모두 안전.\n//\n// 보안 원칙: 임의 iframe src 를 신뢰하지 않는다. 신규 삽입은 `normalizeEmbed`\n// 가 allowlist provider 의 canonical embed URL 로만 변환하고, 출력은\n// `isAllowedEmbedSrc` 로 host 를 한 번 더 검증한다 (legacy/import/외부 테넌트가\n// 임의 src 를 DB 에 남겨도 렌더 단계에서 차단).\n\nexport type EmbedProvider = \"youtube\" | \"vimeo\";\n\nexport interface NormalizedEmbed {\n provider: EmbedProvider;\n /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */\n embedSrc: string;\n /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */\n originalUrl: string;\n}\n\nconst YOUTUBE_HOSTS = new Set([\n \"youtube.com\",\n \"www.youtube.com\",\n \"m.youtube.com\",\n \"youtu.be\",\n \"www.youtu.be\",\n]);\n\nconst VIMEO_HOSTS = new Set([\"vimeo.com\", \"www.vimeo.com\", \"player.vimeo.com\"]);\n\n/** 출력(iframe) 시 허용되는 embed src host — render 단계 defense-in-depth. */\nconst ALLOWED_EMBED_HOSTS = new Set([\n \"www.youtube-nocookie.com\",\n \"youtube-nocookie.com\",\n \"www.youtube.com\",\n \"youtube.com\",\n \"player.vimeo.com\",\n]);\n\nconst YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;\nconst VIMEO_ID = /^\\d+$/;\n\n/**\n * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는\n * provider / 파싱 실패 시 `null`.\n */\nexport function normalizeEmbed(input: unknown): NormalizedEmbed | null {\n if (typeof input !== \"string\") return null;\n const raw = input.trim();\n if (raw.length === 0) return null;\n\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n return null;\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") return null;\n\n const host = url.hostname.toLowerCase();\n\n if (YOUTUBE_HOSTS.has(host)) {\n const id = extractYouTubeId(url, host);\n if (id) {\n return {\n provider: \"youtube\",\n embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n if (VIMEO_HOSTS.has(host)) {\n const id = extractVimeoId(url, host);\n if (id) {\n return {\n provider: \"vimeo\",\n embedSrc: `https://player.vimeo.com/video/${id}`,\n originalUrl: raw,\n };\n }\n }\n\n return null;\n}\n\n/**\n * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe\n * 출력 전 호출. 임의 host / 비 https 는 거부.\n */\nexport function isAllowedEmbedSrc(value: unknown): value is string {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol !== \"https:\") return false;\n const host = url.hostname.toLowerCase();\n if (!ALLOWED_EMBED_HOSTS.has(host)) return false;\n // host 뿐 아니라 canonical embed path 형태까지 검증 — 허용 host 의 임의 경로\n // (예: 스크립트·홈) 가 iframe 으로 로드되는 것을 차단.\n if (host === \"player.vimeo.com\") {\n return /^\\/video\\/\\d+$/.test(url.pathname);\n }\n // youtube / youtube-nocookie → /embed/<11-char id>\n return new RegExp(\"^/embed/[A-Za-z0-9_-]{11}$\").test(url.pathname);\n}\n\nfunction extractYouTubeId(url: URL, host: string): string | null {\n if (host === \"youtu.be\" || host === \"www.youtu.be\") {\n const id = url.pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n return YOUTUBE_ID.test(id) ? id : null;\n }\n const v = url.searchParams.get(\"v\");\n if (v && YOUTUBE_ID.test(v)) return v;\n const parts = url.pathname.split(\"/\").filter(Boolean);\n if (\n parts.length >= 2 &&\n (parts[0] === \"embed\" || parts[0] === \"shorts\" || parts[0] === \"v\")\n ) {\n return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;\n }\n return null;\n}\n\nfunction extractVimeoId(url: URL, host: string): string | null {\n const parts = url.pathname.split(\"/\").filter(Boolean);\n let candidate: string | undefined;\n if (host === \"player.vimeo.com\") {\n const vi = parts.indexOf(\"video\");\n candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];\n } else {\n candidate = parts[0];\n }\n return candidate && VIMEO_ID.test(candidate) ? candidate : null;\n}\n","// ADR-0034 §7.1.1 test #10 — Draft preview token sign/verify.\n//\n// HMAC-SHA256 signed token + DB row (post_preview_tokens_v2) lookup.\n// Atomic consume = consumedAt update at verify time.\n//\n// Token format: `<postId>.<expiresAt-ms>.<hmac-base64url>`\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nexport interface PreviewTokenPayload {\n postId: string;\n expiresAt: Date;\n}\n\nconst DELIMITER = \".\";\n\nfunction base64url(buf: Buffer): string {\n return buf.toString(\"base64url\");\n}\n\nfunction fromBase64url(s: string): Buffer {\n return Buffer.from(s, \"base64url\");\n}\n\nfunction sign(secret: string, message: string): string {\n return base64url(createHmac(\"sha256\", secret).update(message).digest());\n}\n\n/** 새 preview token 생성. DB insert 책임은 caller. */\nexport function issuePreviewToken(\n secret: string,\n payload: PreviewTokenPayload,\n): string {\n const exp = payload.expiresAt.getTime();\n const nonce = base64url(randomBytes(12));\n const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;\n const sig = sign(secret, message);\n return `${message}${DELIMITER}${sig}`;\n}\n\n/** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */\nexport function verifyPreviewToken(\n secret: string,\n token: string,\n): PreviewTokenPayload | null {\n const parts = token.split(DELIMITER);\n if (parts.length !== 4) return null;\n const [postId, expRaw, nonce, sig] = parts as [string, string, string, string];\n const exp = Number(expRaw);\n if (!Number.isFinite(exp)) return null;\n if (Date.now() > exp) return null;\n\n const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);\n const a = fromBase64url(sig);\n const b = fromBase64url(expectedSig);\n if (a.length !== b.length) return null;\n if (!timingSafeEqual(a, b)) return null;\n\n return { postId, expiresAt: new Date(exp) };\n}\n","// ADR-0034 §7.1.1 test #7 — XSS sanitize helper (deny-list, 2차 방어).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 렌더 직전\n// 2차 방어로 위험 태그/속성/URL scheme 을 strip 한다.\n//\n// ⚠️ 한계: 이것은 정규식 기반 deny-list 다. 완전한 방어는 DOM 파서 기반\n// allow-list(sanitize-html / DOMPurify) 가 정답이며 [ADR-0034 followup] 으로\n// 격상 예정 — 그 전까지 본 함수 + CSP + (write 경로의) 인증 키 조합으로 방어.\n// Tiptap output 은 제한된 inline HTML 이라 deny-list 로도 acceptance 를 충족한다.\n\n// 닫는 태그까지 통째 제거할 위험 요소 (내용도 폐기).\nconst DANGEROUS_BLOCK_RE =\n /<(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset)\\b[\\s\\S]*?<\\/\\1\\s*>/gi;\n// 자기-닫힘/void 형태나 짝 없는 위험 여는 태그.\nconst DANGEROUS_VOID_RE =\n /<\\/?(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset|base|meta|link|applet|param)\\b[^>]*>/gi;\n// 모든 on* 이벤트 핸들러 — 따옴표/무따옴표 모두.\n// ⚠️ 앵커는 `\\s` 가 아니라 `[\\s/]` 다: HTML5 파서는 `/` 도 속성 구분자로\n// 허용하므로 `<img/src=x/onerror=...>` 처럼 `\\s` 가 없는 토큰을 우회시킬 수 있다.\n// (보안: 슬래시-구분 속성 우회 차단.)\nconst ON_EVENT_ATTR_DQ_RE = /[\\s/]+on[a-z]+\\s*=\\s*\"(?:[^\"\\\\]|\\\\.)*\"/gi;\nconst ON_EVENT_ATTR_SQ_RE = /[\\s/]+on[a-z]+\\s*=\\s*'(?:[^'\\\\]|\\\\.)*'/gi;\nconst ON_EVENT_ATTR_UQ_RE = /[\\s/]+on[a-z]+\\s*=\\s*[^\\s>]+/gi;\n\n// URL 속성(href/src/…) — deny-list 가 아니라 **scheme allow-list** 로 판정한다.\n// HTML entity / 제어문자 인코딩(`javascript:`, `java	script:`,\n// `jav
ascript:`)은 regex 리터럴 매칭을 우회하므로, 값에서 entity 를 decode\n// 하고 ASCII 공백/제어문자를 제거(브라우저 URL 파서와 동일 정규화)한 뒤 scheme 을\n// 검사한다. 허용 외 scheme 은 \"#\" 로 무력화한다.\nconst URL_ATTR_RE =\n /([\\s/])(href|src|xlink:href|srcdoc|formaction|action|poster)(\\s*=\\s*)(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))/gi;\n\nconst ALLOWED_URL_SCHEMES = new Set([\"http\", \"https\", \"mailto\", \"tel\"]);\n\n// scheme 구성에 악용되는 named entity 만 decode (`:`/`	` 등).\nconst NAMED_ENTITY: Record<string, string> = {\n colon: \":\",\n tab: \"\\t\",\n newline: \"\\n\",\n sol: \"/\",\n semi: \";\",\n num: \"#\",\n period: \".\",\n comma: \",\",\n lpar: \"(\",\n rpar: \")\",\n};\n\nfunction fromCodePointSafe(n: number): string {\n if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return \"\";\n try {\n return String.fromCodePoint(n);\n } catch {\n return \"\";\n }\n}\n\n/** URL 값 정규화 — entity decode + 공백/제어문자 제거 + 소문자 (scheme 판정용). */\nfunction decodeUrlProbe(value: string): string {\n const decoded = value\n .replace(/&#x([0-9a-f]+);?/gi, (_, h) => fromCodePointSafe(parseInt(h, 16)))\n .replace(/&#(\\d+);?/g, (_, d) => fromCodePointSafe(parseInt(d, 10)))\n .replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITY[name.toLowerCase()] ?? m);\n // 브라우저는 scheme 판정 시 ASCII 공백/제어문자(탭/개행/CR/NUL 포함)를 제거한다.\n return [...decoded].filter((c) => { const n = c.codePointAt(0) ?? 0; return n > 0x20 && n !== 0x7f; }).join(\"\").toLowerCase();\n}\n\n/** 안전한 URL 값인지 — 상대경로/허용 scheme, data:image/* 는 src 에서만(SVG 제외). */\nfunction isSafeUrlValue(attr: string, value: string): boolean {\n const probe = decodeUrlProbe(value);\n const scheme = /^([a-z][a-z0-9+.-]*):/.exec(probe)?.[1];\n if (!scheme) return true; // 상대경로 / fragment / query\n if (ALLOWED_URL_SCHEMES.has(scheme)) return true;\n if (scheme === \"data\" && attr === \"src\" && probe.startsWith(\"data:image/\")) {\n // data:image/svg+xml 은 스크립트 실행 가능 → 차단.\n return !probe.startsWith(\"data:image/svg\");\n }\n return false;\n}\n\nfunction neutralizeUrlAttrs(html: string): string {\n return html.replace(\n URL_ATTR_RE,\n (full: string, sep, attr, eq, dq, sq, uq) => {\n const value = (dq ?? sq ?? uq ?? \"\") as string;\n if (isSafeUrlValue(String(attr).toLowerCase(), value)) return full;\n return `${sep}${attr}${eq}\"#\"`;\n },\n );\n}\n\n/**\n * XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리 (렌더 직전 적용).\n * Phase 1 acceptance: `<script>alert(1)</script>` 등 인라인 벡터가 inert.\n */\nexport function sanitizeHtml(html: string): string {\n if (!html) return html;\n let out = html;\n // 위험 태그를 반복 제거 (중첩/주석 우회로 한 패스가 새 토큰을 노출할 수 있어\n // 고정점까지 반복).\n for (let i = 0; i < 3; i++) {\n const prev = out;\n out = out\n .replace(DANGEROUS_BLOCK_RE, \"\")\n .replace(DANGEROUS_VOID_RE, \"\");\n if (out === prev) break;\n }\n out = out\n .replace(ON_EVENT_ATTR_DQ_RE, \"\")\n .replace(ON_EVENT_ATTR_SQ_RE, \"\")\n .replace(ON_EVENT_ATTR_UQ_RE, \"\");\n return neutralizeUrlAttrs(out);\n}\n\n/**\n * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).\n * Astro middleware / Next.js header config 에서 사용.\n */\nexport const PHASE_1_CSP =\n \"default-src 'self'; \" +\n \"script-src 'self'; \" +\n \"style-src 'self' 'unsafe-inline'; \" +\n \"img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; \" +\n \"font-src 'self' data:; \" +\n \"connect-src 'self'; \" +\n // core/embed allowlist (embed.ts) 의 영상 iframe — youtube-nocookie / vimeo.\n \"frame-src https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com; \" +\n \"frame-ancestors 'none'; \" +\n \"base-uri 'self'; \" +\n \"form-action 'self'\";\n","// ADR-0034 §2 P5 — Stable block id (uuidv7 inject)\n//\n// Tiptap 의 BlockId extension 이 본 helper 를 사용해 새 block 에 uuidv7 inject.\n// 기존 block 의 `_id` 는 보존 (revision 간 정합).\n\nimport { uuidv7 } from \"uuidv7\";\n\nimport type { Block } from \"./block\";\n\n/**\n * Block 에 stable id 주입.\n * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).\n */\nexport function ensureStableIds(block: Omit<Block, \"_id\"> & { _id?: string }): Block {\n return {\n ...block,\n _id: block._id ?? uuidv7(),\n innerBlocks: (block.innerBlocks ?? []).map((inner) =>\n ensureStableIds(inner as Omit<Block, \"_id\"> & { _id?: string }),\n ),\n };\n}\n\n/**\n * 한 번에 block tree 전체에 stable id 주입.\n * import 또는 새 post 생성 시 entrypoint.\n */\nexport function injectStableIdsTree(blocks: readonly (Omit<Block, \"_id\"> & { _id?: string })[]): Block[] {\n return blocks.map((b) => ensureStableIds(b));\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale CMS core — Block JSON schema (Gutenberg-compatible), serialize, validate. ADR-0034 §3 §5. Transitive dependency of @roottale/cms-renderer-next / @roottale/cms-renderer-astro.",
|
|
6
6
|
"main": "./dist/index.js",
|