@roottale/cms-core 0.2.1 → 0.18.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 39fbe3d: ACF 커스텀 필드 (ADR-0041) M2·M3-D
8
+ - cms-core: Block Bindings 헬퍼 — `applyBlockBindings` + `createFieldBindingResolver`
9
+ (`roottale/field` + `acf/field` alias) + `chainResolvers`.
10
+ - cms-renderer-next/-astro: `renderBoundBlocks(blocks, { fields })` — 바인딩 적용 후 렌더.
11
+ - cms-client: `CmsPostContent.fields` — 서버가 포맷한 커스텀 필드 값
12
+ (image/file/gallery 는 media variant URL 객체). 구 서버는 null.
13
+
14
+ - f313e76: 필드 바인딩 라이브 렌더 (ADR-0041 D3·D4, 바인딩 PR-2)
15
+ - cms-core: `boundFieldValue` / `bindingTextValue` / `bindingSrcValue` — Tiptap
16
+ 라이브 경로의 바인딩 해석 헬퍼 (값은 항상 text, raw HTML 진입 금지).
17
+ - cms-renderer-next: `RenderTiptap` export + `fields` prop — 블록 바인딩을
18
+ 공개 API `post.fields` 값으로 치환(값 없으면 본문 fallback). `RootTaleBlogPost`
19
+ 가 자동 전달 — 고객 사이트 코드 변경 없이 동작.
20
+ - cms-renderer-astro: `renderTiptapDoc(doc, { fields })` 동등 표면 + blog 경로 자동 전달.
21
+
22
+ ## 0.7.0
23
+
24
+ ### Minor Changes
25
+
26
+ - e2067cf: feat: 영상 임베드 블록 + 공개 글 작성자 byline + Astro 목차(TOC)
27
+ - **cms-core**: `normalizeEmbed`/`isAllowedEmbedSrc` 추가 — YouTube·Vimeo
28
+ provider allowlist 기반 임베드 URL 정규화 + 렌더 단계 host·path 재검증(SSRF 방어).
29
+ - **cms-client**: `CmsPostContent.authorName` 노출(공개 표시명, email 비노출).
30
+ - **cms-renderer-next**: `core/embed` 노드 렌더(allowlist iframe, 미허용 시 원본
31
+ 링크 fallback) + 글 상세 작성자 byline.
32
+ - **cms-renderer-astro**: 위와 동등한 embed 렌더 + 작성자 byline + Next 와 동등한
33
+ TOC 지원(`extractToc`/`attachHeadingIds`, `showTableOfContents` 옵션).
34
+
3
35
  ## 0.2.1
4
36
 
5
37
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -34,6 +34,46 @@ declare function parseBlock(value: unknown): Block;
34
34
  declare const PHASE_1_CORE_BLOCKS: readonly ["core/paragraph", "core/heading", "core/image", "core/list", "core/quote", "core/code", "core/columns", "core/group", "core/separator", "core/spacer"];
35
35
  type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];
36
36
 
37
+ interface BlockBinding {
38
+ source: string;
39
+ args?: Record<string, unknown>;
40
+ }
41
+ /**
42
+ * 바인딩 해석기. 주어진 binding(+attribute/block)에 대한 값을 반환.
43
+ * `undefined` 반환 = 미해석(원래 attribute 유지).
44
+ */
45
+ type BindingResolver = (binding: BlockBinding, attribute: string, block: Block) => unknown;
46
+ /** ACF 필드 바인딩 source id. */
47
+ declare const FIELD_BINDING_SOURCE = "roottale/field";
48
+ /** WP ACF 호환 alias (import round-trip). */
49
+ declare const FIELD_BINDING_SOURCE_ALIAS = "acf/field";
50
+ /**
51
+ * acf 값 record 로부터 필드 바인딩 resolver 생성.
52
+ * `roottale/field` / `acf/field` source 의 `args.name` 을 record 에서 조회.
53
+ */
54
+ declare function createFieldBindingResolver(fields: Record<string, unknown> | undefined | null): BindingResolver;
55
+ /** 여러 resolver 를 순서대로 시도(첫 non-undefined 채택). */
56
+ declare function chainResolvers(...resolvers: BindingResolver[]): BindingResolver;
57
+ /**
58
+ * 블록 트리에 바인딩을 적용해 새 트리 반환(immutable). 바인딩 없으면 원본 그대로.
59
+ */
60
+ declare function applyBlockBindings(blocks: readonly Block[], resolver: BindingResolver): Block[];
61
+ /**
62
+ * bindings 에서 `attribute` 의 필드 값을 조회. `roottale/field`/`acf/field`
63
+ * source 만 해석. 빈 값(undefined/null/"") = undefined → caller 가 fallback 렌더.
64
+ */
65
+ declare function boundFieldValue(bindings: unknown, attribute: string, fields: Record<string, unknown> | null | undefined): unknown;
66
+ /**
67
+ * content 바인딩 값의 텍스트화. **항상 plain text** — caller 가 escape 해
68
+ * 삽입한다 (바인딩 경로로 raw HTML 진입 금지, D4). string/number 외 = undefined.
69
+ */
70
+ declare function bindingTextValue(value: unknown): string | undefined;
71
+ /**
72
+ * image `src` 바인딩 값 → URL. 포맷된 MediaRef(`{url}`) 또는 http(s) 문자열만
73
+ * 인정 — raw media id(uuid) 는 URL 이 아니므로 fallback.
74
+ */
75
+ declare function bindingSrcValue(value: unknown): string | undefined;
76
+
37
77
  /**
38
78
  * Astro island spec — `client:visible` / `client:idle` / `client:load` 등.
39
79
  * frontend 의 native interactivity. WP Interactivity API 카피 안 함.
@@ -92,6 +132,25 @@ type BlockRegistry = ReadonlyMap<string, BlockDefinition>;
92
132
  */
93
133
  declare function serializeBlock(block: Block): string;
94
134
 
135
+ type EmbedProvider = "youtube" | "vimeo";
136
+ interface NormalizedEmbed {
137
+ provider: EmbedProvider;
138
+ /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */
139
+ embedSrc: string;
140
+ /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */
141
+ originalUrl: string;
142
+ }
143
+ /**
144
+ * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는
145
+ * provider / 파싱 실패 시 `null`.
146
+ */
147
+ declare function normalizeEmbed(input: unknown): NormalizedEmbed | null;
148
+ /**
149
+ * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe
150
+ * 출력 전 호출. 임의 host / 비 https 는 거부.
151
+ */
152
+ declare function isAllowedEmbedSrc(value: unknown): value is string;
153
+
95
154
  interface PreviewTokenPayload {
96
155
  postId: string;
97
156
  expiresAt: Date;
@@ -102,8 +161,8 @@ declare function issuePreviewToken(secret: string, payload: PreviewTokenPayload)
102
161
  declare function verifyPreviewToken(secret: string, token: string): PreviewTokenPayload | null;
103
162
 
104
163
  /**
105
- * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.
106
- * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.
164
+ * XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리 (렌더 직전 적용).
165
+ * Phase 1 acceptance: `<script>alert(1)</script>` 등 인라인 벡터가 inert.
107
166
  */
108
167
  declare function sanitizeHtml(html: string): string;
109
168
  /**
@@ -127,4 +186,4 @@ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
127
186
  _id?: string;
128
187
  })[]): Block[];
129
188
 
130
- export { type AstroIslandSpec, BLOCK_NAME_PATTERN, type Block, type BlockDefinition, type BlockRegistry, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, blockSchema, ensureStableIds, injectStableIdsTree, isValidBlock, issuePreviewToken, parseBlock, sanitizeHtml, serializeBlock, verifyPreviewToken };
189
+ export { type AstroIslandSpec, BLOCK_NAME_PATTERN, type BindingResolver, type Block, type BlockBinding, type BlockDefinition, type BlockRegistry, type EmbedProvider, FIELD_BINDING_SOURCE, FIELD_BINDING_SOURCE_ALIAS, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, applyBlockBindings, bindingSrcValue, bindingTextValue, blockSchema, boundFieldValue, chainResolvers, createFieldBindingResolver, ensureStableIds, injectStableIdsTree, isAllowedEmbedSrc, isValidBlock, issuePreviewToken, normalizeEmbed, parseBlock, sanitizeHtml, serializeBlock, verifyPreviewToken };
package/dist/index.js CHANGED
@@ -40,6 +40,81 @@ var PHASE_1_CORE_BLOCKS = [
40
40
  "core/spacer"
41
41
  ];
42
42
 
43
+ // src/bindings.ts
44
+ var FIELD_BINDING_SOURCE = "roottale/field";
45
+ var FIELD_BINDING_SOURCE_ALIAS = "acf/field";
46
+ function createFieldBindingResolver(fields) {
47
+ const values = fields ?? {};
48
+ return (binding) => {
49
+ if (binding.source !== FIELD_BINDING_SOURCE && binding.source !== FIELD_BINDING_SOURCE_ALIAS) {
50
+ return void 0;
51
+ }
52
+ const name = binding.args?.name;
53
+ if (typeof name !== "string") return void 0;
54
+ const v = values[name];
55
+ return v === void 0 ? void 0 : v;
56
+ };
57
+ }
58
+ function chainResolvers(...resolvers) {
59
+ return (binding, attribute, block) => {
60
+ for (const r of resolvers) {
61
+ const v = r(binding, attribute, block);
62
+ if (v !== void 0) return v;
63
+ }
64
+ return void 0;
65
+ };
66
+ }
67
+ function applyToBlock(block, resolver) {
68
+ const bindings = block.metadata?.bindings;
69
+ let attributes = block.attributes;
70
+ if (bindings) {
71
+ let mutated = false;
72
+ const next = { ...block.attributes };
73
+ for (const [attr, binding] of Object.entries(bindings)) {
74
+ const value = resolver(binding, attr, block);
75
+ if (value !== void 0) {
76
+ next[attr] = value;
77
+ mutated = true;
78
+ }
79
+ }
80
+ if (mutated) attributes = next;
81
+ }
82
+ const innerBlocks = block.innerBlocks.length ? block.innerBlocks.map((b) => applyToBlock(b, resolver)) : block.innerBlocks;
83
+ if (attributes === block.attributes && innerBlocks === block.innerBlocks) {
84
+ return block;
85
+ }
86
+ return { ...block, attributes, innerBlocks };
87
+ }
88
+ function applyBlockBindings(blocks, resolver) {
89
+ return blocks.map((b) => applyToBlock(b, resolver));
90
+ }
91
+ function boundFieldValue(bindings, attribute, fields) {
92
+ if (!bindings || typeof bindings !== "object" || !fields) return void 0;
93
+ const binding = bindings[attribute];
94
+ if (!binding || binding.source !== FIELD_BINDING_SOURCE && binding.source !== FIELD_BINDING_SOURCE_ALIAS) {
95
+ return void 0;
96
+ }
97
+ const name = binding.args?.name;
98
+ if (typeof name !== "string") return void 0;
99
+ const value = fields[name];
100
+ if (value === void 0 || value === null || value === "") return void 0;
101
+ return value;
102
+ }
103
+ function bindingTextValue(value) {
104
+ if (typeof value === "string") return value;
105
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
106
+ return void 0;
107
+ }
108
+ function bindingSrcValue(value) {
109
+ if (value && typeof value === "object") {
110
+ const url = value.url;
111
+ if (typeof url === "string" && /^https?:\/\//i.test(url)) return url;
112
+ return void 0;
113
+ }
114
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value;
115
+ return void 0;
116
+ }
117
+
43
118
  // src/delimiter.ts
44
119
  function serializeBlock(block) {
45
120
  const exportable = sanitizeAttributesForExport(block);
@@ -68,6 +143,99 @@ function sanitizeAttributesForExport(block) {
68
143
  return { attributes: { ...block.attributes } };
69
144
  }
70
145
 
146
+ // src/embed.ts
147
+ var YOUTUBE_HOSTS = /* @__PURE__ */ new Set([
148
+ "youtube.com",
149
+ "www.youtube.com",
150
+ "m.youtube.com",
151
+ "youtu.be",
152
+ "www.youtu.be"
153
+ ]);
154
+ var VIMEO_HOSTS = /* @__PURE__ */ new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
155
+ var ALLOWED_EMBED_HOSTS = /* @__PURE__ */ new Set([
156
+ "www.youtube-nocookie.com",
157
+ "youtube-nocookie.com",
158
+ "www.youtube.com",
159
+ "youtube.com",
160
+ "player.vimeo.com"
161
+ ]);
162
+ var YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
163
+ var VIMEO_ID = /^\d+$/;
164
+ function normalizeEmbed(input) {
165
+ if (typeof input !== "string") return null;
166
+ const raw = input.trim();
167
+ if (raw.length === 0) return null;
168
+ let url;
169
+ try {
170
+ url = new URL(raw);
171
+ } catch {
172
+ return null;
173
+ }
174
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
175
+ const host = url.hostname.toLowerCase();
176
+ if (YOUTUBE_HOSTS.has(host)) {
177
+ const id = extractYouTubeId(url, host);
178
+ if (id) {
179
+ return {
180
+ provider: "youtube",
181
+ embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,
182
+ originalUrl: raw
183
+ };
184
+ }
185
+ }
186
+ if (VIMEO_HOSTS.has(host)) {
187
+ const id = extractVimeoId(url, host);
188
+ if (id) {
189
+ return {
190
+ provider: "vimeo",
191
+ embedSrc: `https://player.vimeo.com/video/${id}`,
192
+ originalUrl: raw
193
+ };
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ function isAllowedEmbedSrc(value) {
199
+ if (typeof value !== "string") return false;
200
+ let url;
201
+ try {
202
+ url = new URL(value);
203
+ } catch {
204
+ return false;
205
+ }
206
+ if (url.protocol !== "https:") return false;
207
+ const host = url.hostname.toLowerCase();
208
+ if (!ALLOWED_EMBED_HOSTS.has(host)) return false;
209
+ if (host === "player.vimeo.com") {
210
+ return /^\/video\/\d+$/.test(url.pathname);
211
+ }
212
+ return new RegExp("^/embed/[A-Za-z0-9_-]{11}$").test(url.pathname);
213
+ }
214
+ function extractYouTubeId(url, host) {
215
+ if (host === "youtu.be" || host === "www.youtu.be") {
216
+ const id = url.pathname.split("/").filter(Boolean)[0] ?? "";
217
+ return YOUTUBE_ID.test(id) ? id : null;
218
+ }
219
+ const v = url.searchParams.get("v");
220
+ if (v && YOUTUBE_ID.test(v)) return v;
221
+ const parts = url.pathname.split("/").filter(Boolean);
222
+ if (parts.length >= 2 && (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "v")) {
223
+ return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;
224
+ }
225
+ return null;
226
+ }
227
+ function extractVimeoId(url, host) {
228
+ const parts = url.pathname.split("/").filter(Boolean);
229
+ let candidate;
230
+ if (host === "player.vimeo.com") {
231
+ const vi = parts.indexOf("video");
232
+ candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];
233
+ } else {
234
+ candidate = parts[0];
235
+ }
236
+ return candidate && VIMEO_ID.test(candidate) ? candidate : null;
237
+ }
238
+
71
239
  // src/preview-token.ts
72
240
  import { createHmac, randomBytes, timingSafeEqual } from "crypto";
73
241
  var DELIMITER = ".";
@@ -103,13 +271,22 @@ function verifyPreviewToken(secret, token) {
103
271
  }
104
272
 
105
273
  // src/sanitize.ts
106
- var SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
107
- var ON_EVENT_ATTR_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
108
- var ON_EVENT_ATTR_RE_SINGLE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
109
- var JS_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi;
110
- var DATA_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*data:(?!image\/)[^'"]*\2/gi;
274
+ var DANGEROUS_BLOCK_RE = /<(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset)\b[\s\S]*?<\/\1\s*>/gi;
275
+ var DANGEROUS_VOID_RE = /<\/?(script|style|iframe|object|embed|noscript|template|svg|math|form|frame|frameset|base|meta|link|applet|param)\b[^>]*>/gi;
276
+ var ON_EVENT_ATTR_DQ_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
277
+ var ON_EVENT_ATTR_SQ_RE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
278
+ var ON_EVENT_ATTR_UQ_RE = /\s+on[a-z]+\s*=\s*[^\s>]+/gi;
279
+ var DANGEROUS_URL_ATTR_RE = /\s+(href|src|xlink:href|srcdoc|formaction|action|data|poster)\s*=\s*(['"])\s*(?:javascript|vbscript|data(?!:image\/)):[^'"]*\2/gi;
280
+ var DANGEROUS_URL_ATTR_UQ_RE = /\s+(href|src|xlink:href|srcdoc|formaction|action|data|poster)\s*=\s*(?:javascript|vbscript|data(?!:image\/)):[^\s>]*/gi;
111
281
  function sanitizeHtml(html) {
112
- return html.replace(SCRIPT_TAG_RE, "").replace(ON_EVENT_ATTR_RE, "").replace(ON_EVENT_ATTR_RE_SINGLE, "").replace(JS_URL_RE, ' $1="#"').replace(DATA_URL_RE, ' $1="#"');
282
+ if (!html) return html;
283
+ let out = html;
284
+ for (let i = 0; i < 3; i++) {
285
+ const prev = out;
286
+ out = out.replace(DANGEROUS_BLOCK_RE, "").replace(DANGEROUS_VOID_RE, "");
287
+ if (out === prev) break;
288
+ }
289
+ return out.replace(ON_EVENT_ATTR_DQ_RE, "").replace(ON_EVENT_ATTR_SQ_RE, "").replace(ON_EVENT_ATTR_UQ_RE, "").replace(DANGEROUS_URL_ATTR_RE, ' $1="#"').replace(DANGEROUS_URL_ATTR_UQ_RE, ' $1="#"');
113
290
  }
114
291
  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'";
115
292
 
@@ -129,13 +306,23 @@ function injectStableIdsTree(blocks) {
129
306
  }
130
307
  export {
131
308
  BLOCK_NAME_PATTERN,
309
+ FIELD_BINDING_SOURCE,
310
+ FIELD_BINDING_SOURCE_ALIAS,
132
311
  PHASE_1_CORE_BLOCKS,
133
312
  PHASE_1_CSP,
313
+ applyBlockBindings,
314
+ bindingSrcValue,
315
+ bindingTextValue,
134
316
  blockSchema,
317
+ boundFieldValue,
318
+ chainResolvers,
319
+ createFieldBindingResolver,
135
320
  ensureStableIds,
136
321
  injectStableIdsTree,
322
+ isAllowedEmbedSrc,
137
323
  isValidBlock,
138
324
  issuePreviewToken,
325
+ normalizeEmbed,
139
326
  parseBlock,
140
327
  sanitizeHtml,
141
328
  serializeBlock,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/block.ts","../src/delimiter.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-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 §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 (minimal allow-list).\n//\n// Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 2차\n// 방어로 Tiptap output 의 inline `<script>` / `on*` attribute / `javascript:`\n// URL 을 strip. Phase 2 에 본격 sanitize (sanitize-html 또는 DOMPurify).\n\nconst SCRIPT_TAG_RE = /<script\\b[^>]*>[\\s\\S]*?<\\/script\\s*>/gi;\nconst ON_EVENT_ATTR_RE = /\\s+on[a-z]+\\s*=\\s*\"(?:[^\"\\\\]|\\\\.)*\"/gi;\nconst ON_EVENT_ATTR_RE_SINGLE = /\\s+on[a-z]+\\s*=\\s*'(?:[^'\\\\]|\\\\.)*'/gi;\nconst JS_URL_RE = /\\s+(href|src)\\s*=\\s*(['\"])\\s*javascript:[^'\"]*\\2/gi;\nconst DATA_URL_RE = /\\s+(href|src)\\s*=\\s*(['\"])\\s*data:(?!image\\/)[^'\"]*\\2/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 return html\n .replace(SCRIPT_TAG_RE, \"\")\n .replace(ON_EVENT_ATTR_RE, \"\")\n .replace(ON_EVENT_ATTR_RE_SINGLE, \"\")\n .replace(JS_URL_RE, \" $1=\\\"#\\\"\")\n .replace(DATA_URL_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/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","// 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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-core",
3
- "version": "0.2.1",
3
+ "version": "0.18.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",