@roottale/cms-core 0.2.0 → 0.7.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,41 @@
1
1
  # @roottale/cms-core
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e2067cf: feat: 영상 임베드 블록 + 공개 글 작성자 byline + Astro 목차(TOC)
8
+ - **cms-core**: `normalizeEmbed`/`isAllowedEmbedSrc` 추가 — YouTube·Vimeo
9
+ provider allowlist 기반 임베드 URL 정규화 + 렌더 단계 host·path 재검증(SSRF 방어).
10
+ - **cms-client**: `CmsPostContent.authorName` 노출(공개 표시명, email 비노출).
11
+ - **cms-renderer-next**: `core/embed` 노드 렌더(allowlist iframe, 미허용 시 원본
12
+ 링크 fallback) + 글 상세 작성자 byline.
13
+ - **cms-renderer-astro**: 위와 동등한 embed 렌더 + 작성자 byline + Next 와 동등한
14
+ TOC 지원(`extractToc`/`attachHeadingIds`, `showTableOfContents` 옵션).
15
+
16
+ ## 0.2.1
17
+
18
+ ### Patch Changes
19
+
20
+ - dist build 도입 — `.ts` 소스 직접 publish 폐기.
21
+
22
+ 기존 (0.1.0/0.2.0): `exports` 가 `./src/server.ts` 가리킴 → 외부 Next.js
23
+ Turbopack/Webpack 이 npm 모듈에서 TS 자동 컴파일 안 해 build 실패 (customer
24
+ site 가 `transpilePackages` 명시해야 했음).
25
+
26
+ 수정: `tsup` 으로 `dist/*.js` + `dist/*.d.ts` (ESM) 출력. `exports` 가 dist
27
+ 가리킴. customer site 측 `transpilePackages` 불필요.
28
+ - `cms-client@0.1.1` — ESM `dist/server.js` + types
29
+ - `cms-core@0.2.1` — ESM `dist/index.js` + types
30
+ - `cms-renderer-next@0.2.1` — ESM `dist/{server,index}.js` + types + `dist/cms-public.css`
31
+ - `server.tsx` 의 `import "./styles/cms-public.css"` 제거 — customer 가
32
+ `@roottale/cms-renderer-next/styles` 로 명시 import (README 정합).
33
+ - `cms-renderer-astro@0.2.1` — ESM `dist/index.js` + types
34
+
35
+ 후속 (customer site PR):
36
+ - roottale-web / kjmtax / theoneulsan 의 `next.config` 의 `transpilePackages`
37
+ 에서 `@roottale/cms-*` 제거. `pnpm update @roottale/cms-client @roottale/cms-renderer-next` 로 patch 적용.
38
+
3
39
  ## 0.2.0
4
40
 
5
41
  ### Minor Changes
@@ -0,0 +1,149 @@
1
+ import { z, ZodSchema } from 'zod';
2
+
3
+ /** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */
4
+ declare const BLOCK_NAME_PATTERN: RegExp;
5
+ /**
6
+ * Block schema (recursive — innerBlocks).
7
+ * `_id` 는 internal-only (export 시 strip, ADR §5).
8
+ */
9
+ declare const blockSchema: z.ZodType<Block>;
10
+ interface Block {
11
+ /** uuidv7. Internal-only — never exported as WP delimiter attribute. */
12
+ _id: string;
13
+ /** `core/<x>` or `roottale/<x>` */
14
+ name: string;
15
+ attributes: Record<string, unknown>;
16
+ innerBlocks: Block[];
17
+ /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */
18
+ rawHtml?: string;
19
+ metadata?: {
20
+ bindings?: Record<string, {
21
+ source: string;
22
+ args?: Record<string, unknown>;
23
+ }>;
24
+ name?: string;
25
+ reusableRef?: string;
26
+ };
27
+ }
28
+ declare function isValidBlock(value: unknown): value is Block;
29
+ declare function parseBlock(value: unknown): Block;
30
+ /**
31
+ * Phase 1 core block names (10) — ADR-0034 §5.
32
+ * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.
33
+ */
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
+ type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];
36
+
37
+ /**
38
+ * Astro island spec — `client:visible` / `client:idle` / `client:load` 등.
39
+ * frontend 의 native interactivity. WP Interactivity API 카피 안 함.
40
+ */
41
+ interface AstroIslandSpec {
42
+ /** Astro `client:*` directive */
43
+ hydration?: "visible" | "idle" | "load" | "media" | "only";
44
+ /** client-side module path (relative to package) */
45
+ module?: string;
46
+ }
47
+ /**
48
+ * BlockDefinition<A> — block 의 schema + Astro render 어댑터.
49
+ * Phase 5 에 Next public renderer 활성 시 별도 ADR-amend 의 add 의무.
50
+ */
51
+ interface BlockDefinition<A = Record<string, unknown>> {
52
+ /** `core/<x>` or `roottale/<x>` */
53
+ name: string;
54
+ /** display metadata for admin editor */
55
+ meta: {
56
+ title: string;
57
+ description?: string;
58
+ category?: "text" | "media" | "design" | "widgets" | "theme" | "embed";
59
+ icon?: string;
60
+ };
61
+ /** attributes validation */
62
+ schema: ZodSchema<A>;
63
+ /** WP `supports` 등 flags */
64
+ supports?: {
65
+ align?: boolean;
66
+ color?: boolean;
67
+ spacing?: boolean;
68
+ typography?: boolean;
69
+ customClassName?: boolean;
70
+ };
71
+ /** parent block name 강제 (e.g. `core/column` 은 `core/columns` 아래만) */
72
+ parent?: readonly string[];
73
+ /** ancestor block name 강제 */
74
+ ancestor?: readonly string[];
75
+ /** Astro server render — 본 함수는 packages/cms-renderer-astro 에서 호출 */
76
+ astroRender: (block: Block & {
77
+ attributes: A;
78
+ }) => unknown;
79
+ /** Astro client island (optional) */
80
+ clientHydration?: {
81
+ astro?: AstroIslandSpec;
82
+ };
83
+ }
84
+ /**
85
+ * Block registry — Phase 1 에서 register/lookup helper 추가 (현재는 type-only).
86
+ */
87
+ type BlockRegistry = ReadonlyMap<string, BlockDefinition>;
88
+
89
+ /**
90
+ * Block → WP delimiter 형식 직렬화 (export).
91
+ * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.
92
+ */
93
+ declare function serializeBlock(block: Block): string;
94
+
95
+ type EmbedProvider = "youtube" | "vimeo";
96
+ interface NormalizedEmbed {
97
+ provider: EmbedProvider;
98
+ /** allowlist host 의 canonical iframe src (가능 시 privacy-enhanced). */
99
+ embedSrc: string;
100
+ /** 사용자가 입력한 원본 URL — fallback 링크 / round-trip 보존용. */
101
+ originalUrl: string;
102
+ }
103
+ /**
104
+ * 사용자 입력 URL → allowlist provider 의 canonical embed. 지원하지 않는
105
+ * provider / 파싱 실패 시 `null`.
106
+ */
107
+ declare function normalizeEmbed(input: unknown): NormalizedEmbed | null;
108
+ /**
109
+ * 이미 저장된 embed src 가 allowlist host(https) 인지 검증 — 렌더러가 iframe
110
+ * 출력 전 호출. 임의 host / 비 https 는 거부.
111
+ */
112
+ declare function isAllowedEmbedSrc(value: unknown): value is string;
113
+
114
+ interface PreviewTokenPayload {
115
+ postId: string;
116
+ expiresAt: Date;
117
+ }
118
+ /** 새 preview token 생성. DB insert 책임은 caller. */
119
+ declare function issuePreviewToken(secret: string, payload: PreviewTokenPayload): string;
120
+ /** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */
121
+ declare function verifyPreviewToken(secret: string, token: string): PreviewTokenPayload | null;
122
+
123
+ /**
124
+ * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.
125
+ * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.
126
+ */
127
+ declare function sanitizeHtml(html: string): string;
128
+ /**
129
+ * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).
130
+ * Astro middleware / Next.js header config 에서 사용.
131
+ */
132
+ declare const PHASE_1_CSP: string;
133
+
134
+ /**
135
+ * Block 에 stable id 주입.
136
+ * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).
137
+ */
138
+ declare function ensureStableIds(block: Omit<Block, "_id"> & {
139
+ _id?: string;
140
+ }): Block;
141
+ /**
142
+ * 한 번에 block tree 전체에 stable id 주입.
143
+ * import 또는 새 post 생성 시 entrypoint.
144
+ */
145
+ declare function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & {
146
+ _id?: string;
147
+ })[]): Block[];
148
+
149
+ export { type AstroIslandSpec, BLOCK_NAME_PATTERN, type Block, type BlockDefinition, type BlockRegistry, type EmbedProvider, type NormalizedEmbed, PHASE_1_CORE_BLOCKS, PHASE_1_CSP, type Phase1CoreBlockName, type PreviewTokenPayload, blockSchema, ensureStableIds, injectStableIdsTree, isAllowedEmbedSrc, isValidBlock, issuePreviewToken, normalizeEmbed, parseBlock, sanitizeHtml, serializeBlock, verifyPreviewToken };
package/dist/index.js ADDED
@@ -0,0 +1,239 @@
1
+ // src/block.ts
2
+ import { z } from "zod";
3
+ var BLOCK_NAME_PATTERN = /^(core|roottale)\/[a-z0-9-]+$/;
4
+ var blockMetadataSchema = z.object({
5
+ bindings: z.record(
6
+ z.object({
7
+ source: z.string(),
8
+ args: z.record(z.unknown()).optional()
9
+ })
10
+ ).optional(),
11
+ name: z.string().optional(),
12
+ reusableRef: z.string().optional()
13
+ }).strict().optional();
14
+ var blockSchema = z.lazy(
15
+ () => z.object({
16
+ _id: z.string().uuid(),
17
+ name: z.string().regex(BLOCK_NAME_PATTERN),
18
+ attributes: z.record(z.unknown()),
19
+ innerBlocks: z.array(blockSchema),
20
+ rawHtml: z.string().optional(),
21
+ metadata: blockMetadataSchema
22
+ }).strict()
23
+ );
24
+ function isValidBlock(value) {
25
+ return blockSchema.safeParse(value).success;
26
+ }
27
+ function parseBlock(value) {
28
+ return blockSchema.parse(value);
29
+ }
30
+ var PHASE_1_CORE_BLOCKS = [
31
+ "core/paragraph",
32
+ "core/heading",
33
+ "core/image",
34
+ "core/list",
35
+ "core/quote",
36
+ "core/code",
37
+ "core/columns",
38
+ "core/group",
39
+ "core/separator",
40
+ "core/spacer"
41
+ ];
42
+
43
+ // src/delimiter.ts
44
+ function serializeBlock(block) {
45
+ const exportable = sanitizeAttributesForExport(block);
46
+ const attrsJson = serializeAttributes(exportable.attributes);
47
+ const innerHtml = block.rawHtml ?? "";
48
+ const innerSerialized = (block.innerBlocks ?? []).map((b) => serializeBlock(b)).join("\n");
49
+ if (!innerHtml && innerSerialized.length === 0) {
50
+ return attrsJson ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->` : `<!-- wp:${normalizeName(block.name)} /-->`;
51
+ }
52
+ const open = attrsJson ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->` : `<!-- wp:${normalizeName(block.name)} -->`;
53
+ const close = `<!-- /wp:${normalizeName(block.name)} -->`;
54
+ const body = [innerHtml, innerSerialized].filter(Boolean).join("\n");
55
+ return `${open}
56
+ ${body}
57
+ ${close}`;
58
+ }
59
+ function normalizeName(name) {
60
+ return name.startsWith("core/") ? name.slice("core/".length) : name;
61
+ }
62
+ function serializeAttributes(attrs) {
63
+ const keys = Object.keys(attrs);
64
+ if (keys.length === 0) return "";
65
+ return JSON.stringify(attrs);
66
+ }
67
+ function sanitizeAttributesForExport(block) {
68
+ return { attributes: { ...block.attributes } };
69
+ }
70
+
71
+ // src/embed.ts
72
+ var YOUTUBE_HOSTS = /* @__PURE__ */ new Set([
73
+ "youtube.com",
74
+ "www.youtube.com",
75
+ "m.youtube.com",
76
+ "youtu.be",
77
+ "www.youtu.be"
78
+ ]);
79
+ var VIMEO_HOSTS = /* @__PURE__ */ new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
80
+ var ALLOWED_EMBED_HOSTS = /* @__PURE__ */ new Set([
81
+ "www.youtube-nocookie.com",
82
+ "youtube-nocookie.com",
83
+ "www.youtube.com",
84
+ "youtube.com",
85
+ "player.vimeo.com"
86
+ ]);
87
+ var YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
88
+ var VIMEO_ID = /^\d+$/;
89
+ function normalizeEmbed(input) {
90
+ if (typeof input !== "string") return null;
91
+ const raw = input.trim();
92
+ if (raw.length === 0) return null;
93
+ let url;
94
+ try {
95
+ url = new URL(raw);
96
+ } catch {
97
+ return null;
98
+ }
99
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
100
+ const host = url.hostname.toLowerCase();
101
+ if (YOUTUBE_HOSTS.has(host)) {
102
+ const id = extractYouTubeId(url, host);
103
+ if (id) {
104
+ return {
105
+ provider: "youtube",
106
+ embedSrc: `https://www.youtube-nocookie.com/embed/${id}`,
107
+ originalUrl: raw
108
+ };
109
+ }
110
+ }
111
+ if (VIMEO_HOSTS.has(host)) {
112
+ const id = extractVimeoId(url, host);
113
+ if (id) {
114
+ return {
115
+ provider: "vimeo",
116
+ embedSrc: `https://player.vimeo.com/video/${id}`,
117
+ originalUrl: raw
118
+ };
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ function isAllowedEmbedSrc(value) {
124
+ if (typeof value !== "string") return false;
125
+ let url;
126
+ try {
127
+ url = new URL(value);
128
+ } catch {
129
+ return false;
130
+ }
131
+ if (url.protocol !== "https:") return false;
132
+ const host = url.hostname.toLowerCase();
133
+ if (!ALLOWED_EMBED_HOSTS.has(host)) return false;
134
+ if (host === "player.vimeo.com") {
135
+ return /^\/video\/\d+$/.test(url.pathname);
136
+ }
137
+ return new RegExp("^/embed/[A-Za-z0-9_-]{11}$").test(url.pathname);
138
+ }
139
+ function extractYouTubeId(url, host) {
140
+ if (host === "youtu.be" || host === "www.youtu.be") {
141
+ const id = url.pathname.split("/").filter(Boolean)[0] ?? "";
142
+ return YOUTUBE_ID.test(id) ? id : null;
143
+ }
144
+ const v = url.searchParams.get("v");
145
+ if (v && YOUTUBE_ID.test(v)) return v;
146
+ const parts = url.pathname.split("/").filter(Boolean);
147
+ if (parts.length >= 2 && (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "v")) {
148
+ return YOUTUBE_ID.test(parts[1]) ? parts[1] : null;
149
+ }
150
+ return null;
151
+ }
152
+ function extractVimeoId(url, host) {
153
+ const parts = url.pathname.split("/").filter(Boolean);
154
+ let candidate;
155
+ if (host === "player.vimeo.com") {
156
+ const vi = parts.indexOf("video");
157
+ candidate = vi >= 0 ? parts[vi + 1] : parts[parts.length - 1];
158
+ } else {
159
+ candidate = parts[0];
160
+ }
161
+ return candidate && VIMEO_ID.test(candidate) ? candidate : null;
162
+ }
163
+
164
+ // src/preview-token.ts
165
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
166
+ var DELIMITER = ".";
167
+ function base64url(buf) {
168
+ return buf.toString("base64url");
169
+ }
170
+ function fromBase64url(s) {
171
+ return Buffer.from(s, "base64url");
172
+ }
173
+ function sign(secret, message) {
174
+ return base64url(createHmac("sha256", secret).update(message).digest());
175
+ }
176
+ function issuePreviewToken(secret, payload) {
177
+ const exp = payload.expiresAt.getTime();
178
+ const nonce = base64url(randomBytes(12));
179
+ const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;
180
+ const sig = sign(secret, message);
181
+ return `${message}${DELIMITER}${sig}`;
182
+ }
183
+ function verifyPreviewToken(secret, token) {
184
+ const parts = token.split(DELIMITER);
185
+ if (parts.length !== 4) return null;
186
+ const [postId, expRaw, nonce, sig] = parts;
187
+ const exp = Number(expRaw);
188
+ if (!Number.isFinite(exp)) return null;
189
+ if (Date.now() > exp) return null;
190
+ const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);
191
+ const a = fromBase64url(sig);
192
+ const b = fromBase64url(expectedSig);
193
+ if (a.length !== b.length) return null;
194
+ if (!timingSafeEqual(a, b)) return null;
195
+ return { postId, expiresAt: new Date(exp) };
196
+ }
197
+
198
+ // src/sanitize.ts
199
+ var SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
200
+ var ON_EVENT_ATTR_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
201
+ var ON_EVENT_ATTR_RE_SINGLE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
202
+ var JS_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi;
203
+ var DATA_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*data:(?!image\/)[^'"]*\2/gi;
204
+ function sanitizeHtml(html) {
205
+ 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="#"');
206
+ }
207
+ 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'";
208
+
209
+ // src/stable-id.ts
210
+ import { uuidv7 } from "uuidv7";
211
+ function ensureStableIds(block) {
212
+ return {
213
+ ...block,
214
+ _id: block._id ?? uuidv7(),
215
+ innerBlocks: (block.innerBlocks ?? []).map(
216
+ (inner) => ensureStableIds(inner)
217
+ )
218
+ };
219
+ }
220
+ function injectStableIdsTree(blocks) {
221
+ return blocks.map((b) => ensureStableIds(b));
222
+ }
223
+ export {
224
+ BLOCK_NAME_PATTERN,
225
+ PHASE_1_CORE_BLOCKS,
226
+ PHASE_1_CSP,
227
+ blockSchema,
228
+ ensureStableIds,
229
+ injectStableIdsTree,
230
+ isAllowedEmbedSrc,
231
+ isValidBlock,
232
+ issuePreviewToken,
233
+ normalizeEmbed,
234
+ parseBlock,
235
+ sanitizeHtml,
236
+ serializeBlock,
237
+ verifyPreviewToken
238
+ };
239
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/block.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-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 (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":[]}
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@roottale/cms-core",
3
- "version": "0.2.0",
3
+ "version": "0.7.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
- "main": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.ts",
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ },
9
14
  "./package.json": "./package.json"
10
15
  },
11
16
  "files": [
12
- "src/",
17
+ "dist/",
13
18
  "README.md",
14
19
  "CHANGELOG.md"
15
20
  ],
@@ -41,7 +46,7 @@
41
46
  "directory": "packages/cms-core"
42
47
  },
43
48
  "scripts": {
44
- "build": "tsc --noEmit",
49
+ "build": "tsup",
45
50
  "type-check": "tsc --noEmit",
46
51
  "test": "vitest run --passWithNoTests"
47
52
  }
@@ -1,58 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { issuePreviewToken, verifyPreviewToken } from "../preview-token";
4
-
5
- describe("preview-token (HMAC + expiry)", () => {
6
- const secret = "test-secret-do-not-use-in-prod";
7
-
8
- it("issue → verify round-trips postId + expiry", () => {
9
- const expiresAt = new Date(Date.now() + 60_000);
10
- const token = issuePreviewToken(secret, {
11
- postId: "post-abc",
12
- expiresAt,
13
- });
14
- const payload = verifyPreviewToken(secret, token);
15
- expect(payload?.postId).toBe("post-abc");
16
- expect(payload?.expiresAt.getTime()).toBe(expiresAt.getTime());
17
- });
18
-
19
- it("rejects wrong secret", () => {
20
- const token = issuePreviewToken(secret, {
21
- postId: "post-abc",
22
- expiresAt: new Date(Date.now() + 60_000),
23
- });
24
- expect(verifyPreviewToken("other-secret", token)).toBeNull();
25
- });
26
-
27
- it("rejects expired token", () => {
28
- const token = issuePreviewToken(secret, {
29
- postId: "post-abc",
30
- expiresAt: new Date(Date.now() - 1),
31
- });
32
- expect(verifyPreviewToken(secret, token)).toBeNull();
33
- });
34
-
35
- it("rejects tampered postId", () => {
36
- const token = issuePreviewToken(secret, {
37
- postId: "post-abc",
38
- expiresAt: new Date(Date.now() + 60_000),
39
- });
40
- const parts = token.split(".");
41
- // swap postId
42
- parts[0] = "post-evil";
43
- const tampered = parts.join(".");
44
- expect(verifyPreviewToken(secret, tampered)).toBeNull();
45
- });
46
-
47
- it("rejects malformed token (wrong segment count)", () => {
48
- expect(verifyPreviewToken(secret, "only.two")).toBeNull();
49
- expect(verifyPreviewToken(secret, "a.b.c.d.e")).toBeNull();
50
- });
51
-
52
- it("two issues with same payload differ (nonce randomness)", () => {
53
- const expiresAt = new Date(Date.now() + 60_000);
54
- const t1 = issuePreviewToken(secret, { postId: "p", expiresAt });
55
- const t2 = issuePreviewToken(secret, { postId: "p", expiresAt });
56
- expect(t1).not.toBe(t2);
57
- });
58
- });
@@ -1,57 +0,0 @@
1
- // ADR-0034 §5 — BlockDefinition (렌더러 어댑터)
2
- // codex v2 verdict #3 + #11: Astro = 유일 public renderer (Phase 0~4).
3
- // `nextRender` 시그니처 의도적 제거 — Phase 5 에 Next public 활성 시 별도 ADR.
4
-
5
- import type { ZodSchema } from "zod";
6
-
7
- import type { Block } from "./block";
8
-
9
- /**
10
- * Astro island spec — `client:visible` / `client:idle` / `client:load` 등.
11
- * frontend 의 native interactivity. WP Interactivity API 카피 안 함.
12
- */
13
- export interface AstroIslandSpec {
14
- /** Astro `client:*` directive */
15
- hydration?: "visible" | "idle" | "load" | "media" | "only";
16
- /** client-side module path (relative to package) */
17
- module?: string;
18
- }
19
-
20
- /**
21
- * BlockDefinition<A> — block 의 schema + Astro render 어댑터.
22
- * Phase 5 에 Next public renderer 활성 시 별도 ADR-amend 의 add 의무.
23
- */
24
- export interface BlockDefinition<A = Record<string, unknown>> {
25
- /** `core/<x>` or `roottale/<x>` */
26
- name: string;
27
- /** display metadata for admin editor */
28
- meta: {
29
- title: string;
30
- description?: string;
31
- category?: "text" | "media" | "design" | "widgets" | "theme" | "embed";
32
- icon?: string;
33
- };
34
- /** attributes validation */
35
- schema: ZodSchema<A>;
36
- /** WP `supports` 등 flags */
37
- supports?: {
38
- align?: boolean;
39
- color?: boolean;
40
- spacing?: boolean;
41
- typography?: boolean;
42
- customClassName?: boolean;
43
- };
44
- /** parent block name 강제 (e.g. `core/column` 은 `core/columns` 아래만) */
45
- parent?: readonly string[];
46
- /** ancestor block name 강제 */
47
- ancestor?: readonly string[];
48
- /** Astro server render — 본 함수는 packages/cms-renderer-astro 에서 호출 */
49
- astroRender: (block: Block & { attributes: A }) => unknown;
50
- /** Astro client island (optional) */
51
- clientHydration?: { astro?: AstroIslandSpec };
52
- }
53
-
54
- /**
55
- * Block registry — Phase 1 에서 register/lookup helper 추가 (현재는 type-only).
56
- */
57
- export type BlockRegistry = ReadonlyMap<string, BlockDefinition>;
package/src/block.ts DELETED
@@ -1,85 +0,0 @@
1
- // ADR-0034 §5 — Block JSON 스펙 (WP 호환)
2
- // Gutenberg block shape + 우리 확장:
3
- // - `_id` (uuidv7 stable across revisions, ADR §2 P5)
4
- // - `rawHtml` (WP delimiter innerHTML 보존, codex v2 verdict #7)
5
- // - `metadata.reusableRef` (Phase 2 = flat expand)
6
-
7
- import { z } from "zod";
8
-
9
- /** WP-compatible name pattern: `core/<x>` | `roottale/<x>` */
10
- export const BLOCK_NAME_PATTERN = /^(core|roottale)\/[a-z0-9-]+$/;
11
-
12
- const blockMetadataSchema = z
13
- .object({
14
- bindings: z
15
- .record(
16
- z.object({
17
- source: z.string(),
18
- args: z.record(z.unknown()).optional(),
19
- }),
20
- )
21
- .optional(),
22
- name: z.string().optional(),
23
- reusableRef: z.string().optional(),
24
- })
25
- .strict()
26
- .optional();
27
-
28
- /**
29
- * Block schema (recursive — innerBlocks).
30
- * `_id` 는 internal-only (export 시 strip, ADR §5).
31
- */
32
- export const blockSchema: z.ZodType<Block> = z.lazy(() =>
33
- z
34
- .object({
35
- _id: z.string().uuid(),
36
- name: z.string().regex(BLOCK_NAME_PATTERN),
37
- attributes: z.record(z.unknown()),
38
- innerBlocks: z.array(blockSchema),
39
- rawHtml: z.string().optional(),
40
- metadata: blockMetadataSchema,
41
- })
42
- .strict(),
43
- );
44
-
45
- export interface Block {
46
- /** uuidv7. Internal-only — never exported as WP delimiter attribute. */
47
- _id: string;
48
- /** `core/<x>` or `roottale/<x>` */
49
- name: string;
50
- attributes: Record<string, unknown>;
51
- innerBlocks: Block[];
52
- /** WP delimiter innerHTML (preserved for round-trip — codex v2 verdict #7). */
53
- rawHtml?: string;
54
- metadata?: {
55
- bindings?: Record<string, { source: string; args?: Record<string, unknown> }>;
56
- name?: string;
57
- reusableRef?: string;
58
- };
59
- }
60
-
61
- export function isValidBlock(value: unknown): value is Block {
62
- return blockSchema.safeParse(value).success;
63
- }
64
-
65
- export function parseBlock(value: unknown): Block {
66
- return blockSchema.parse(value);
67
- }
68
-
69
- /**
70
- * Phase 1 core block names (10) — ADR-0034 §5.
71
- * Phase 2+ 에 `core/embed`, `core/gallery`, `core/cover` 등 추가.
72
- */
73
- export const PHASE_1_CORE_BLOCKS = [
74
- "core/paragraph",
75
- "core/heading",
76
- "core/image",
77
- "core/list",
78
- "core/quote",
79
- "core/code",
80
- "core/columns",
81
- "core/group",
82
- "core/separator",
83
- "core/spacer",
84
- ] as const;
85
- export type Phase1CoreBlockName = (typeof PHASE_1_CORE_BLOCKS)[number];
package/src/delimiter.ts DELETED
@@ -1,59 +0,0 @@
1
- // ADR-0034 §5 + §6 — WP delimiter parse/serialize
2
- //
3
- // WP 의 block serialization 은 HTML 주석 형태:
4
- // <!-- wp:paragraph {"attr":1} -->
5
- // <p>본문</p>
6
- // <!-- /wp:paragraph -->
7
- //
8
- // Round-trip 룰 (codex v2 verdict #7 + v3 verdict #4):
9
- // - `_id` 는 export 시 strip (WP-side import 가 unknown attribute 로 인식해 깨짐 차단)
10
- // - `rawHtml` ↔ innerHTML
11
- // - 우리가 schema 정의 안 한 block = Tiptap opaque atom node (편집 불가, 보존)
12
-
13
- import type { Block } from "./block";
14
-
15
- /**
16
- * Block → WP delimiter 형식 직렬화 (export).
17
- * `_id` 와 `metadata.reusableRef` 는 strip — WP 호환.
18
- */
19
- export function serializeBlock(block: Block): string {
20
- const exportable = sanitizeAttributesForExport(block);
21
- const attrsJson = serializeAttributes(exportable.attributes);
22
- const innerHtml = block.rawHtml ?? "";
23
- const innerSerialized = (block.innerBlocks ?? [])
24
- .map((b) => serializeBlock(b))
25
- .join("\n");
26
-
27
- // self-closing 형태 (innerHTML + innerBlocks 모두 없으면)
28
- if (!innerHtml && innerSerialized.length === 0) {
29
- return attrsJson
30
- ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} /-->`
31
- : `<!-- wp:${normalizeName(block.name)} /-->`;
32
- }
33
-
34
- const open = attrsJson
35
- ? `<!-- wp:${normalizeName(block.name)} ${attrsJson} -->`
36
- : `<!-- wp:${normalizeName(block.name)} -->`;
37
- const close = `<!-- /wp:${normalizeName(block.name)} -->`;
38
-
39
- const body = [innerHtml, innerSerialized].filter(Boolean).join("\n");
40
- return `${open}\n${body}\n${close}`;
41
- }
42
-
43
- /** WP-side 에서 `core/` 는 보통 namespace 생략 (e.g. `<!-- wp:paragraph -->`) */
44
- function normalizeName(name: string): string {
45
- return name.startsWith("core/") ? name.slice("core/".length) : name;
46
- }
47
-
48
- function serializeAttributes(attrs: Record<string, unknown>): string {
49
- const keys = Object.keys(attrs);
50
- if (keys.length === 0) return "";
51
- return JSON.stringify(attrs);
52
- }
53
-
54
- function sanitizeAttributesForExport(block: Block): {
55
- attributes: Record<string, unknown>;
56
- } {
57
- // `_id` 와 `metadata.reusableRef` 제거. attributes 안의 임의 키는 그대로 export.
58
- return { attributes: { ...block.attributes } };
59
- }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- // @roottale/cms-core — ADR-0034 §3 §5 (Phase 1 Block JSON foundation)
2
- export * from "./block";
3
- export * from "./block-definition";
4
- export * from "./delimiter";
5
- export * from "./preview-token";
6
- export * from "./sanitize";
7
- export * from "./stable-id";
@@ -1,60 +0,0 @@
1
- // ADR-0034 §7.1.1 test #10 — Draft preview token sign/verify.
2
- //
3
- // HMAC-SHA256 signed token + DB row (post_preview_tokens_v2) lookup.
4
- // Atomic consume = consumedAt update at verify time.
5
- //
6
- // Token format: `<postId>.<expiresAt-ms>.<hmac-base64url>`
7
-
8
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
9
-
10
- export interface PreviewTokenPayload {
11
- postId: string;
12
- expiresAt: Date;
13
- }
14
-
15
- const DELIMITER = ".";
16
-
17
- function base64url(buf: Buffer): string {
18
- return buf.toString("base64url");
19
- }
20
-
21
- function fromBase64url(s: string): Buffer {
22
- return Buffer.from(s, "base64url");
23
- }
24
-
25
- function sign(secret: string, message: string): string {
26
- return base64url(createHmac("sha256", secret).update(message).digest());
27
- }
28
-
29
- /** 새 preview token 생성. DB insert 책임은 caller. */
30
- export function issuePreviewToken(
31
- secret: string,
32
- payload: PreviewTokenPayload,
33
- ): string {
34
- const exp = payload.expiresAt.getTime();
35
- const nonce = base64url(randomBytes(12));
36
- const message = `${payload.postId}${DELIMITER}${exp}${DELIMITER}${nonce}`;
37
- const sig = sign(secret, message);
38
- return `${message}${DELIMITER}${sig}`;
39
- }
40
-
41
- /** Token 검증 (서명 + 만료). DB row consumed_at update 는 caller (atomic). */
42
- export function verifyPreviewToken(
43
- secret: string,
44
- token: string,
45
- ): PreviewTokenPayload | null {
46
- const parts = token.split(DELIMITER);
47
- if (parts.length !== 4) return null;
48
- const [postId, expRaw, nonce, sig] = parts as [string, string, string, string];
49
- const exp = Number(expRaw);
50
- if (!Number.isFinite(exp)) return null;
51
- if (Date.now() > exp) return null;
52
-
53
- const expectedSig = sign(secret, `${postId}${DELIMITER}${exp}${DELIMITER}${nonce}`);
54
- const a = fromBase64url(sig);
55
- const b = fromBase64url(expectedSig);
56
- if (a.length !== b.length) return null;
57
- if (!timingSafeEqual(a, b)) return null;
58
-
59
- return { postId, expiresAt: new Date(exp) };
60
- }
package/src/sanitize.ts DELETED
@@ -1,39 +0,0 @@
1
- // ADR-0034 §7.1.1 test #7 — XSS sanitize helper (minimal allow-list).
2
- //
3
- // Phase 1 = strict CSP (`script-src 'self'`) 가 1차 방어. 본 helper 는 2차
4
- // 방어로 Tiptap output 의 inline `<script>` / `on*` attribute / `javascript:`
5
- // URL 을 strip. Phase 2 에 본격 sanitize (sanitize-html 또는 DOMPurify).
6
-
7
- const SCRIPT_TAG_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
8
- const ON_EVENT_ATTR_RE = /\s+on[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi;
9
- const ON_EVENT_ATTR_RE_SINGLE = /\s+on[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi;
10
- const JS_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*javascript:[^'"]*\2/gi;
11
- const DATA_URL_RE = /\s+(href|src)\s*=\s*(['"])\s*data:(?!image\/)[^'"]*\2/gi;
12
-
13
- /**
14
- * 미니멀 XSS sanitize — Tiptap HTML output 또는 rawHtml 의 안전 정리.
15
- * Phase 1 acceptance: `<script>alert(1)</script>` 인라인이 inert 가 되어야 함.
16
- */
17
- export function sanitizeHtml(html: string): string {
18
- return html
19
- .replace(SCRIPT_TAG_RE, "")
20
- .replace(ON_EVENT_ATTR_RE, "")
21
- .replace(ON_EVENT_ATTR_RE_SINGLE, "")
22
- .replace(JS_URL_RE, " $1=\"#\"")
23
- .replace(DATA_URL_RE, " $1=\"#\"");
24
- }
25
-
26
- /**
27
- * 표준 CSP 헤더 값 (Phase 1 acceptance test #7 정합).
28
- * Astro middleware / Next.js header config 에서 사용.
29
- */
30
- export const PHASE_1_CSP =
31
- "default-src 'self'; " +
32
- "script-src 'self'; " +
33
- "style-src 'self' 'unsafe-inline'; " +
34
- "img-src 'self' data: https://imagedelivery.net https://*.r2.cloudflarestorage.com; " +
35
- "font-src 'self' data:; " +
36
- "connect-src 'self'; " +
37
- "frame-ancestors 'none'; " +
38
- "base-uri 'self'; " +
39
- "form-action 'self'";
package/src/stable-id.ts DELETED
@@ -1,30 +0,0 @@
1
- // ADR-0034 §2 P5 — Stable block id (uuidv7 inject)
2
- //
3
- // Tiptap 의 BlockId extension 이 본 helper 를 사용해 새 block 에 uuidv7 inject.
4
- // 기존 block 의 `_id` 는 보존 (revision 간 정합).
5
-
6
- import { uuidv7 } from "uuidv7";
7
-
8
- import type { Block } from "./block";
9
-
10
- /**
11
- * Block 에 stable id 주입.
12
- * 이미 `_id` 있는 block 은 그대로, 없으면 uuidv7 생성. recursive (innerBlocks).
13
- */
14
- export function ensureStableIds(block: Omit<Block, "_id"> & { _id?: string }): Block {
15
- return {
16
- ...block,
17
- _id: block._id ?? uuidv7(),
18
- innerBlocks: (block.innerBlocks ?? []).map((inner) =>
19
- ensureStableIds(inner as Omit<Block, "_id"> & { _id?: string }),
20
- ),
21
- };
22
- }
23
-
24
- /**
25
- * 한 번에 block tree 전체에 stable id 주입.
26
- * import 또는 새 post 생성 시 entrypoint.
27
- */
28
- export function injectStableIdsTree(blocks: readonly (Omit<Block, "_id"> & { _id?: string })[]): Block[] {
29
- return blocks.map((b) => ensureStableIds(b));
30
- }