@roottale/cms-core 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ # @roottale/cms-core
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d1b5d35: cms-\* public renderer 패키지 npm public publish — Phase 1 dogfood.
8
+
9
+ ADR-0029 §0 amend (publish-only dormant) 는 design system 5 패키지 (tokens, ui-css, ui-react, ui-astro, ui-admin) 에 한정. cms-\* 는 별도 정책 — 실행 로직 + 5-20 외부 customer site 직접 의존 + schema 호환 + 보안 경계. Codex consult verdict (session `019e6703…`) 정합.
10
+
11
+ 변경:
12
+ - 4 패키지 `private: true` 해제 + `publishConfig.access: "public"`
13
+ - `@roottale/cms-renderer-next` 에서 `@roottale/tokens/tokens.css` import 제거 — 모든 `--rt-*` 변수에 static fallback 으로 self-contained. tokens dormant 와 무관하게 동작.
14
+ - `RootTaleLeadForm` RSC 추가 — 외부 사이트 진단 폼 (`vertical`/`redirectUrl` props, medical 국외이전 동의 자동).
15
+ - README + repository / homepage / keywords 메타 정비
16
+ - `peerDependencies.react: ^19` 명시 (cms-renderer-next)
17
+ - `cms-renderer-astro` 도 동등 surface 유지를 위해 동시 publish
18
+
19
+ 후속:
20
+ - ADR 신규 — cms-\* publish 정책 (별 PR)
21
+ - Astro 측 LeadForm 컴포넌트 (현재 `@roottale/ui-astro` 위치, `cms-renderer-astro` 로 이동 검토)
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @roottale/cms-core
2
+
3
+ RootTale CMS core — Block JSON schema (Gutenberg/Tiptap-compatible), serialize, validate.
4
+
5
+ Transitive dependency of [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) and [`@roottale/cms-renderer-astro`](https://www.npmjs.com/package/@roottale/cms-renderer-astro). You usually don't install this directly — the renderer pulls it in. Install standalone if you need the types/schema in your own pipeline (custom validators, content migrations, codegen).
6
+
7
+ See ADR-0034 §3 §5 for the schema decision context.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @roottale/cms-core
13
+ ```
14
+
15
+ ## Exports
16
+
17
+ - Block JSON Zod schemas
18
+ - `serializeBlocks` / `parseBlocks`
19
+ - Block id stable hash helpers
20
+ - TypeScript types
21
+
22
+ ## License
23
+
24
+ Proprietary (UNLICENSED). Issued under the RootTale customer contract.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@roottale/cms-core",
3
+ "version": "0.2.0",
4
+ "type": "module",
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",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "README.md",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "dependencies": {
17
+ "uuidv7": "^1.0.0",
18
+ "zod": "^3.23.8"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "typescript": "^5.7.0",
23
+ "vitest": "^2.1.0"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "keywords": [
29
+ "roottale",
30
+ "cms",
31
+ "block-json",
32
+ "schema",
33
+ "tiptap",
34
+ "gutenberg"
35
+ ],
36
+ "license": "UNLICENSED",
37
+ "homepage": "https://github.com/RootTale/roottale-platform/tree/main/packages/cms-core",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/RootTale/roottale-platform.git",
41
+ "directory": "packages/cms-core"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc --noEmit",
45
+ "type-check": "tsc --noEmit",
46
+ "test": "vitest run --passWithNoTests"
47
+ }
48
+ }
@@ -0,0 +1,58 @@
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
+ });
@@ -0,0 +1,57 @@
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 ADDED
@@ -0,0 +1,85 @@
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];
@@ -0,0 +1,59 @@
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 ADDED
@@ -0,0 +1,7 @@
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";
@@ -0,0 +1,60 @@
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
+ }
@@ -0,0 +1,39 @@
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'";
@@ -0,0 +1,30 @@
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
+ }