@roottale/cms-client 0.5.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 +19 -0
- package/README.md +18 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/webhook.d.ts +31 -24
- package/dist/webhook.js +28 -17
- package/dist/webhook.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @roottale/cms-client
|
|
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.6.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- 47775b5: ADR-0039 — verifier API hard break to site-scoped API key model. `verifyRootTaleWebhook({ apiKey, apiBase?, expectedSiteId?, ... })`. `expectedSiteId` 는 더 이상 필수 X — site-scoped `ROOTTALE_API_KEY` 가 server-side 에서 site 잠금. JWKS endpoint 가 `/.well-known/jwks.json` (public, multi-tenant) 에서 `/v1/cms/public/jwks` (Bearer auth, site-scope) 로 변경. **고객 측 secret 보관 0 + env 1 개 (`ROOTTALE_API_KEY`)**.
|
|
21
|
+
|
|
3
22
|
## 0.5.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -80,6 +80,24 @@ await fetchPosts({
|
|
|
80
80
|
});
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
## Publish Webhooks and Revalidation
|
|
84
|
+
|
|
85
|
+
Customer sites should expose a server-only webhook endpoint, commonly
|
|
86
|
+
`POST /api/revalidate`, and verify it with `@roottale/cms-client/webhook`.
|
|
87
|
+
|
|
88
|
+
Important cache invalidation rules:
|
|
89
|
+
|
|
90
|
+
- Revalidate the blog list for every signed post or taxonomy/category event.
|
|
91
|
+
Blog cards commonly display category labels, so term changes can stale the
|
|
92
|
+
list even when the post body did not change.
|
|
93
|
+
- Revalidate the detail page for the current slug, any previous slug, and any
|
|
94
|
+
root-relative path hints in the webhook `paths` payload.
|
|
95
|
+
- Treat `revalidate = 1800` or similar ISR settings as fallback only. Webhooks
|
|
96
|
+
are the expected near-real-time path.
|
|
97
|
+
|
|
98
|
+
RootTale currently emits `post.updated` for published post term assignment
|
|
99
|
+
changes and for taxonomy term deletion affecting published posts.
|
|
100
|
+
|
|
83
101
|
## License
|
|
84
102
|
|
|
85
103
|
Proprietary (UNLICENSED). Issued under the RootTale customer contract.
|
package/dist/server.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ interface CmsPostContent {
|
|
|
41
41
|
/** Media id (resolved by renderer / consumer to URL). */
|
|
42
42
|
featuredMediaId: string | null;
|
|
43
43
|
authorId: string | null;
|
|
44
|
+
/** Public display name for the post author. Email is not exposed. */
|
|
45
|
+
authorName: string | null;
|
|
44
46
|
/** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */
|
|
45
47
|
bodyJson: Record<string, unknown>;
|
|
46
48
|
/** Public API only exposes `published`. */
|
package/dist/server.js
CHANGED
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.\n *\n * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.\n * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).\n *\n * 사용 예 (Next.js server component):\n * ```ts\n * import { fetchPosts } from \"@roottale/cms-client/server\";\n *\n * const posts = await fetchPosts({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * limit: 10,\n * });\n * ```\n *\n * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).\n * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.\n */\n\nexport type CmsPostType = \"post\" | \"page\";\n\nexport type CmsTaxonomyKind = \"category\" | \"tag\" | \"custom\";\n\nexport interface CmsTermRef {\n id: string;\n taxonomy: CmsTaxonomyKind;\n slug: string;\n name: string;\n}\n\nexport interface CmsPostContent {\n /** UUIDv7. */\n id: string;\n /** Owning tenant id. */\n tenantId: string;\n /** Owning site id (ADR-0034 P4 dual-key). */\n siteId: string;\n /** WP post type. */\n type: CmsPostType;\n /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\n title: string;\n excerpt: string | null;\n /** Media id (resolved by renderer / consumer to URL). */\n featuredMediaId: string | null;\n authorId: string | null;\n /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */\n bodyJson: Record<string, unknown>;\n /** Public API only exposes `published`. */\n status: \"published\";\n /** Per-post meta (SEO overrides, canonical, og, ...). */\n metaJson: Record<string, unknown>;\n /**\n * Attached taxonomy terms (categories + tags + custom). Server 가 category →\n * tag → custom 순서로 정렬해 보내며, 각 taxonomy 내부는 name asc. attach 가 0 개면\n * 빈 배열. category 만 추출하려면 `terms.filter(t => t.taxonomy === \"category\")`.\n */\n terms: CmsTermRef[];\n /** ISO 8601. */\n publishedAt: string;\n /** ISO 8601. */\n createdAt: string;\n /** ISO 8601. */\n updatedAt: string;\n}\n\nexport interface CmsPostContentPage {\n items: CmsPostContent[];\n nextCursor: string | null;\n hasMore: boolean;\n}\n\nconst DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\nexport interface FetchPostsOptions {\n /** `rtlk_cust_*` API key. Required. */\n apiKey: string;\n /** Override the API base URL. Defaults to `https://api.roottale.com`. */\n baseUrl?: string;\n /** Page size, 1-100, default 20. */\n limit?: number;\n /** Opaque pagination cursor returned from previous call. */\n cursor?: string;\n /** Filter by post type (`post` or `page`). Default = both. */\n type?: CmsPostType;\n /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n}\n\nexport interface FetchPostOptions {\n apiKey: string;\n baseUrl?: string;\n /** Slug (kebab-case) or UUIDv7 id. */\n slugOrId: string;\n /** Override the auto-resolved site id. */\n siteId?: string;\n signal?: AbortSignal;\n}\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (mysite.roottale.com) 에서 저장한 값과 동일한 shape.\n * 미설정 필드는 응답에서 누락 → renderer 의 CSS fallback 이 사용된다.\n */\nexport interface RootTaleTheme {\n colors?: {\n primary?: string;\n primaryForeground?: string;\n foreground?: string;\n background?: string;\n muted?: string;\n mutedForeground?: string;\n border?: string;\n };\n fonts?: {\n body?: string;\n display?: string;\n };\n radius?: {\n md?: string;\n };\n}\n\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n}\n\n/**\n * tenant 의 RootTale 디자인 토큰을 가져온다. admin /design 의 마지막 저장값\n * (없으면 빈 객체) — renderer 가 이 값을 CSS 변수로 주입한다.\n *\n * 자동 fetch (renderer 가 theme prop 없을 때) 와 명시 호출 모두 지원.\n * 네트워크/서버 에러는 throw — 호출자가 fallback 처리.\n */\nexport async function fetchTheme(options: FetchThemeOptions): Promise<RootTaleTheme> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/theme${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n colors?: RootTaleTheme[\"colors\"];\n fonts?: RootTaleTheme[\"fonts\"];\n radius?: RootTaleTheme[\"radius\"];\n };\n const theme: RootTaleTheme = {};\n if (json.colors) theme.colors = json.colors;\n if (json.fonts) theme.fonts = json.fonts;\n if (json.radius) theme.radius = json.radius;\n return theme;\n}\n\nexport class CmsApiError extends Error {\n readonly status: number;\n readonly code: string;\n constructor(message: string, status: number, code: string) {\n super(message);\n this.name = \"CmsApiError\";\n this.status = status;\n this.code = code;\n }\n}\n\nexport async function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.type) params.set(\"type\", options.type);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: CmsPostWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsPostWire;\n return fromWire(json);\n}\n\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n slug: string;\n title: string;\n excerpt: string | null;\n featured_media_id: string | null;\n author_id: string | null;\n body_json: Record<string, unknown>;\n status: \"published\";\n meta_json: Record<string, unknown>;\n /** 0.2.0 이전 서버는 미포함 — backward-compat 차원에서 optional. */\n terms?: CmsTermRef[];\n published_at: string;\n created_at: string;\n updated_at: string;\n};\n\nfunction fromWire(wire: CmsPostWire): CmsPostContent {\n return {\n id: wire.id,\n tenantId: wire.tenant_id,\n siteId: wire.site_id,\n type: wire.type,\n slug: wire.slug,\n title: wire.title,\n excerpt: wire.excerpt,\n featuredMediaId: wire.featured_media_id,\n authorId: wire.author_id,\n bodyJson: wire.body_json,\n status: wire.status,\n metaJson: wire.meta_json,\n terms: wire.terms ?? [],\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n\nasync function toApiError(response: Response): Promise<CmsApiError> {\n let code = \"request_failed\";\n let message = `RootTale CMS API error: ${response.status}`;\n try {\n const body = (await response.json()) as { code?: string; message?: string };\n if (body.code) code = body.code;\n if (body.message) message = body.message;\n } catch {\n /* ignore body parse */\n }\n return new CmsApiError(message, response.status, code);\n}\n\nfunction assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client/server must not run in the browser. \" +\n \"Move this call to a Server Component, getServerSideProps, or a Route Handler. \" +\n \"Shipping API keys to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.\n *\n * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.\n * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).\n *\n * 사용 예 (Next.js server component):\n * ```ts\n * import { fetchPosts } from \"@roottale/cms-client/server\";\n *\n * const posts = await fetchPosts({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * limit: 10,\n * });\n * ```\n *\n * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).\n * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.\n */\n\nexport type CmsPostType = \"post\" | \"page\";\n\nexport type CmsTaxonomyKind = \"category\" | \"tag\" | \"custom\";\n\nexport interface CmsTermRef {\n id: string;\n taxonomy: CmsTaxonomyKind;\n slug: string;\n name: string;\n}\n\nexport interface CmsPostContent {\n /** UUIDv7. */\n id: string;\n /** Owning tenant id. */\n tenantId: string;\n /** Owning site id (ADR-0034 P4 dual-key). */\n siteId: string;\n /** WP post type. */\n type: CmsPostType;\n /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\n title: string;\n excerpt: string | null;\n /** Media id (resolved by renderer / consumer to URL). */\n featuredMediaId: string | null;\n authorId: string | null;\n /** Public display name for the post author. Email is not exposed. */\n authorName: string | null;\n /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */\n bodyJson: Record<string, unknown>;\n /** Public API only exposes `published`. */\n status: \"published\";\n /** Per-post meta (SEO overrides, canonical, og, ...). */\n metaJson: Record<string, unknown>;\n /**\n * Attached taxonomy terms (categories + tags + custom). Server 가 category →\n * tag → custom 순서로 정렬해 보내며, 각 taxonomy 내부는 name asc. attach 가 0 개면\n * 빈 배열. category 만 추출하려면 `terms.filter(t => t.taxonomy === \"category\")`.\n */\n terms: CmsTermRef[];\n /** ISO 8601. */\n publishedAt: string;\n /** ISO 8601. */\n createdAt: string;\n /** ISO 8601. */\n updatedAt: string;\n}\n\nexport interface CmsPostContentPage {\n items: CmsPostContent[];\n nextCursor: string | null;\n hasMore: boolean;\n}\n\nconst DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\nexport interface FetchPostsOptions {\n /** `rtlk_cust_*` API key. Required. */\n apiKey: string;\n /** Override the API base URL. Defaults to `https://api.roottale.com`. */\n baseUrl?: string;\n /** Page size, 1-100, default 20. */\n limit?: number;\n /** Opaque pagination cursor returned from previous call. */\n cursor?: string;\n /** Filter by post type (`post` or `page`). Default = both. */\n type?: CmsPostType;\n /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n}\n\nexport interface FetchPostOptions {\n apiKey: string;\n baseUrl?: string;\n /** Slug (kebab-case) or UUIDv7 id. */\n slugOrId: string;\n /** Override the auto-resolved site id. */\n siteId?: string;\n signal?: AbortSignal;\n}\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (mysite.roottale.com) 에서 저장한 값과 동일한 shape.\n * 미설정 필드는 응답에서 누락 → renderer 의 CSS fallback 이 사용된다.\n */\nexport interface RootTaleTheme {\n colors?: {\n primary?: string;\n primaryForeground?: string;\n foreground?: string;\n background?: string;\n muted?: string;\n mutedForeground?: string;\n border?: string;\n };\n fonts?: {\n body?: string;\n display?: string;\n };\n radius?: {\n md?: string;\n };\n}\n\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n}\n\n/**\n * tenant 의 RootTale 디자인 토큰을 가져온다. admin /design 의 마지막 저장값\n * (없으면 빈 객체) — renderer 가 이 값을 CSS 변수로 주입한다.\n *\n * 자동 fetch (renderer 가 theme prop 없을 때) 와 명시 호출 모두 지원.\n * 네트워크/서버 에러는 throw — 호출자가 fallback 처리.\n */\nexport async function fetchTheme(options: FetchThemeOptions): Promise<RootTaleTheme> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/theme${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n colors?: RootTaleTheme[\"colors\"];\n fonts?: RootTaleTheme[\"fonts\"];\n radius?: RootTaleTheme[\"radius\"];\n };\n const theme: RootTaleTheme = {};\n if (json.colors) theme.colors = json.colors;\n if (json.fonts) theme.fonts = json.fonts;\n if (json.radius) theme.radius = json.radius;\n return theme;\n}\n\nexport class CmsApiError extends Error {\n readonly status: number;\n readonly code: string;\n constructor(message: string, status: number, code: string) {\n super(message);\n this.name = \"CmsApiError\";\n this.status = status;\n this.code = code;\n }\n}\n\nexport async function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.type) params.set(\"type\", options.type);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: CmsPostWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n signal: options.signal,\n });\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsPostWire;\n return fromWire(json);\n}\n\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n slug: string;\n title: string;\n excerpt: string | null;\n featured_media_id: string | null;\n author_id: string | null;\n author_name?: string | null;\n body_json: Record<string, unknown>;\n status: \"published\";\n meta_json: Record<string, unknown>;\n /** 0.2.0 이전 서버는 미포함 — backward-compat 차원에서 optional. */\n terms?: CmsTermRef[];\n published_at: string;\n created_at: string;\n updated_at: string;\n};\n\nfunction fromWire(wire: CmsPostWire): CmsPostContent {\n return {\n id: wire.id,\n tenantId: wire.tenant_id,\n siteId: wire.site_id,\n type: wire.type,\n slug: wire.slug,\n title: wire.title,\n excerpt: wire.excerpt,\n featuredMediaId: wire.featured_media_id,\n authorId: wire.author_id,\n authorName: wire.author_name ?? null,\n bodyJson: wire.body_json,\n status: wire.status,\n metaJson: wire.meta_json,\n terms: wire.terms ?? [],\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n\nasync function toApiError(response: Response): Promise<CmsApiError> {\n let code = \"request_failed\";\n let message = `RootTale CMS API error: ${response.status}`;\n try {\n const body = (await response.json()) as { code?: string; message?: string };\n if (body.code) code = body.code;\n if (body.message) message = body.message;\n } catch {\n /* ignore body parse */\n }\n return new CmsApiError(message, response.status, code);\n}\n\nfunction assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client/server must not run in the browser. \" +\n \"Move this call to a Server Component, getServerSideProps, or a Route Handler. \" +\n \"Shipping API keys to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/webhook.d.ts
CHANGED
|
@@ -4,20 +4,23 @@ export { JWTPayload } from 'jose';
|
|
|
4
4
|
/**
|
|
5
5
|
* `@roottale/cms-client/webhook` — RootTale CMS Publish Webhook verifier.
|
|
6
6
|
*
|
|
7
|
-
* ADR-0039 (Option D, JWKS asymmetric). 외부
|
|
8
|
-
* publish webhook 을 검증한다.
|
|
7
|
+
* ADR-0039 (Option D, JWKS asymmetric + site-scoped API key). 외부 고객 사이트가
|
|
8
|
+
* RootTale CMS 의 publish webhook 을 검증한다. **고객 측 secret 보관 0** —
|
|
9
|
+
* 공개키만으로 검증, 동시에 site 잠금은 site-scoped API key 가 담당.
|
|
9
10
|
*
|
|
10
11
|
* 인증 모델:
|
|
11
12
|
* 서명 = ES256 JWS (alg=ES256, kid=<opaque>). admin/api-core 의 dispatcher 가
|
|
12
13
|
* site_webhook_keys 의 active row 로 서명.
|
|
13
|
-
* 검증 = `api.roottale.com
|
|
14
|
-
* 캐시, kid
|
|
14
|
+
* 검증 = Bearer apiKey 로 `api.roottale.com/v1/cms/public/jwks` fetch (1 시간
|
|
15
|
+
* 캐시, kid 변경 시 즉시 refresh). jose 라이브러리.
|
|
16
|
+
* tenant_api_keys.site_id 기준 *그 사이트의* 공개키만 노출 → site
|
|
17
|
+
* 잠금이 server side 에서 자동으로 됨.
|
|
15
18
|
*
|
|
16
19
|
* Customer 작업:
|
|
17
20
|
* 1) `pnpm add @roottale/cms-client`
|
|
18
|
-
* 2) admin 의 사이트 추가 화면에서
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* 2) admin 의 사이트 추가 화면에서 customer API key 1 개 발급 (site-scoped 자동)
|
|
22
|
+
* 3) `ROOTTALE_API_KEY=rtlk_cust_xxx` env paste
|
|
23
|
+
* 4) /api/revalidate 라우트에서 verifyRootTaleWebhook 호출
|
|
21
24
|
*
|
|
22
25
|
* Next.js Route Handler 예제:
|
|
23
26
|
* ```ts
|
|
@@ -28,7 +31,7 @@ export { JWTPayload } from 'jose';
|
|
|
28
31
|
* const result = await verifyRootTaleWebhook({
|
|
29
32
|
* rawBody,
|
|
30
33
|
* headers: request.headers,
|
|
31
|
-
*
|
|
34
|
+
* apiKey: process.env.ROOTTALE_API_KEY!,
|
|
32
35
|
* });
|
|
33
36
|
* if (!result.ok) return Response.json({ reason: result.reason }, { status: 401 });
|
|
34
37
|
* // ... revalidatePath(result.payload.paths)
|
|
@@ -36,24 +39,30 @@ export { JWTPayload } from 'jose';
|
|
|
36
39
|
* ```
|
|
37
40
|
*/
|
|
38
41
|
|
|
39
|
-
declare const
|
|
42
|
+
declare const DEFAULT_API_BASE = "https://api.roottale.com";
|
|
40
43
|
interface VerifyRootTaleWebhookInput {
|
|
41
44
|
/** `await request.text()` 결과. parse 전에 verify 해야 함. */
|
|
42
45
|
rawBody: string;
|
|
43
46
|
/** request.headers (Headers 또는 dict). */
|
|
44
47
|
headers: Headers | Record<string, string | undefined>;
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* Site-scoped customer API key (`rtlk_cust_*`). admin UI 에서 발급된 그 값.
|
|
50
|
+
* 본 키로 JWKS endpoint 에 Bearer auth → 사이트의 active 공개키만 fetch.
|
|
51
|
+
* CMS content fetch (cms-client/server) 와 *같은 키* 를 사용.
|
|
48
52
|
*/
|
|
49
|
-
|
|
50
|
-
/** (옵션)
|
|
51
|
-
|
|
53
|
+
apiKey: string;
|
|
54
|
+
/** (옵션) API base URL override. 기본 = https://api.roottale.com */
|
|
55
|
+
apiBase?: string;
|
|
56
|
+
/**
|
|
57
|
+
* (옵션) multi-site tenant 에서 추가 hardening 으로 특정 siteId 만 받기.
|
|
58
|
+
* site-scoped key 가 이미 site 를 잠그므로 일반적으론 불필요.
|
|
59
|
+
*/
|
|
60
|
+
expectedSiteId?: string;
|
|
52
61
|
/** (옵션) timestamp drift 허용 (초). 기본 300. */
|
|
53
62
|
timestampWindowSec?: number;
|
|
54
63
|
/**
|
|
55
64
|
* (옵션) replay idempotency hook. jti 가 이미 본 거면 "seen" 반환해서 reject.
|
|
56
|
-
* 미지정 시 replay check skip — ISR revalidate
|
|
65
|
+
* 미지정 시 replay check skip — ISR revalidate 같은 idempotent endpoint 에선 무해.
|
|
57
66
|
*/
|
|
58
67
|
consumeJti?: (jti: string, exp: number) => Promise<"new" | "seen">;
|
|
59
68
|
/** (옵션) 테스트용 now() override. */
|
|
@@ -61,30 +70,28 @@ interface VerifyRootTaleWebhookInput {
|
|
|
61
70
|
}
|
|
62
71
|
interface VerifyRootTaleWebhookSuccess {
|
|
63
72
|
ok: true;
|
|
64
|
-
/** JWS jti
|
|
73
|
+
/** JWS jti — webhook delivery id, X-Roottale-Delivery-Id 헤더와 동일. */
|
|
65
74
|
deliveryId: string;
|
|
66
|
-
/** payload.siteId
|
|
75
|
+
/** payload.siteId. */
|
|
67
76
|
siteId: string;
|
|
68
77
|
/** post.published | post.updated | post.deleted */
|
|
69
78
|
event: string;
|
|
70
|
-
/** body 의 SHA-256 (base64url).
|
|
79
|
+
/** body 의 SHA-256 (base64url). jws 가 binding. */
|
|
71
80
|
bodyHash: string;
|
|
72
81
|
/** 디코딩된 JWS payload. */
|
|
73
82
|
payload: JWTPayload;
|
|
74
83
|
}
|
|
75
84
|
interface VerifyRootTaleWebhookFailure {
|
|
76
85
|
ok: false;
|
|
77
|
-
reason: "missing_signature" | "
|
|
86
|
+
reason: "missing_signature" | "missing_api_key" | "jwks_fetch_failed" | "invalid_signature" | "expired" | "issuer_mismatch" | "audience_mismatch" | "site_id_mismatch" | "body_hash_mismatch" | "timestamp_out_of_window" | "replay_seen" | "internal_error";
|
|
78
87
|
}
|
|
79
88
|
type VerifyRootTaleWebhookResult = VerifyRootTaleWebhookSuccess | VerifyRootTaleWebhookFailure;
|
|
80
89
|
/**
|
|
81
|
-
* Test-only —
|
|
82
|
-
* URL 로 verifyRootTaleWebhook 하면 remote fetch 없이 in-memory 검증.
|
|
83
|
-
* production 코드에선 호출 금지.
|
|
90
|
+
* Test-only — local JWKS 로 cache 채움. production 호출 금지.
|
|
84
91
|
*/
|
|
85
|
-
declare function __useLocalJwksForTesting(
|
|
92
|
+
declare function __useLocalJwksForTesting(apiBase: string, apiKey: string, jwks: {
|
|
86
93
|
keys: JWK[];
|
|
87
94
|
}): void;
|
|
88
95
|
declare function verifyRootTaleWebhook(input: VerifyRootTaleWebhookInput): Promise<VerifyRootTaleWebhookResult>;
|
|
89
96
|
|
|
90
|
-
export {
|
|
97
|
+
export { DEFAULT_API_BASE, type VerifyRootTaleWebhookFailure, type VerifyRootTaleWebhookInput, type VerifyRootTaleWebhookResult, type VerifyRootTaleWebhookSuccess, __useLocalJwksForTesting, verifyRootTaleWebhook };
|
package/dist/webhook.js
CHANGED
|
@@ -5,27 +5,38 @@ import {
|
|
|
5
5
|
errors as joseErrors,
|
|
6
6
|
jwtVerify
|
|
7
7
|
} from "jose";
|
|
8
|
-
var
|
|
8
|
+
var DEFAULT_API_BASE = "https://api.roottale.com";
|
|
9
|
+
var JWKS_PATH = "/v1/cms/public/jwks";
|
|
9
10
|
var DEFAULT_TIMESTAMP_WINDOW_SEC = 300;
|
|
10
11
|
var SIGNATURE_ISSUER = "https://api.roottale.com";
|
|
11
12
|
var SIGNATURE_AUDIENCE = "roottale-webhook";
|
|
12
13
|
var jwksSetCache = /* @__PURE__ */ new Map();
|
|
13
|
-
function
|
|
14
|
-
|
|
14
|
+
function cacheKey(apiBase, apiKey) {
|
|
15
|
+
const prefix = apiKey.slice(0, 12);
|
|
16
|
+
return `${apiBase}::${prefix}.${apiKey.length}`;
|
|
17
|
+
}
|
|
18
|
+
function getJwksSet(apiBase, apiKey) {
|
|
19
|
+
const key = cacheKey(apiBase, apiKey);
|
|
20
|
+
let cached = jwksSetCache.get(key);
|
|
15
21
|
if (!cached) {
|
|
16
|
-
|
|
22
|
+
const url = new URL(JWKS_PATH, apiBase);
|
|
23
|
+
cached = createRemoteJWKSet(url, {
|
|
17
24
|
cacheMaxAge: 60 * 60 * 1e3,
|
|
18
25
|
// 1h
|
|
19
26
|
cooldownDuration: 30 * 1e3,
|
|
20
27
|
// 모르는 kid 재 fetch 최소 간격
|
|
21
|
-
timeoutDuration: 5e3
|
|
28
|
+
timeoutDuration: 5e3,
|
|
29
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
22
30
|
});
|
|
23
|
-
jwksSetCache.set(
|
|
31
|
+
jwksSetCache.set(key, cached);
|
|
24
32
|
}
|
|
25
33
|
return cached;
|
|
26
34
|
}
|
|
27
|
-
function __useLocalJwksForTesting(
|
|
28
|
-
jwksSetCache.set(
|
|
35
|
+
function __useLocalJwksForTesting(apiBase, apiKey, jwks) {
|
|
36
|
+
jwksSetCache.set(
|
|
37
|
+
cacheKey(apiBase, apiKey),
|
|
38
|
+
createLocalJWKSet(jwks)
|
|
39
|
+
);
|
|
29
40
|
}
|
|
30
41
|
function readHeader(headers, name) {
|
|
31
42
|
if (typeof headers.get === "function") {
|
|
@@ -56,13 +67,13 @@ async function sha256Base64Url(input) {
|
|
|
56
67
|
return bytesToBase64Url(new Uint8Array(hashBuf));
|
|
57
68
|
}
|
|
58
69
|
async function verifyRootTaleWebhook(input) {
|
|
59
|
-
if (!input.
|
|
60
|
-
return { ok: false, reason: "
|
|
70
|
+
if (!input.apiKey) {
|
|
71
|
+
return { ok: false, reason: "missing_api_key" };
|
|
61
72
|
}
|
|
62
73
|
const jws = readHeader(input.headers, "x-roottale-signature");
|
|
63
74
|
if (!jws) return { ok: false, reason: "missing_signature" };
|
|
64
|
-
const
|
|
65
|
-
const jwks = getJwksSet(
|
|
75
|
+
const apiBase = input.apiBase ?? DEFAULT_API_BASE;
|
|
76
|
+
const jwks = getJwksSet(apiBase, input.apiKey);
|
|
66
77
|
const windowSec = input.timestampWindowSec ?? DEFAULT_TIMESTAMP_WINDOW_SEC;
|
|
67
78
|
const nowDate = input.now ? new Date(input.now()) : /* @__PURE__ */ new Date();
|
|
68
79
|
let payload;
|
|
@@ -92,13 +103,13 @@ async function verifyRootTaleWebhook(input) {
|
|
|
92
103
|
if (e.code?.startsWith?.("ERR_JWKS")) {
|
|
93
104
|
return { ok: false, reason: "jwks_fetch_failed" };
|
|
94
105
|
}
|
|
95
|
-
if (process.env.VERIFY_DEBUG) {
|
|
96
|
-
console.warn("[verifyRootTaleWebhook] unexpected error:", e);
|
|
97
|
-
}
|
|
98
106
|
return { ok: false, reason: "internal_error" };
|
|
99
107
|
}
|
|
100
108
|
const claimSiteId = payload.site_id;
|
|
101
|
-
if (typeof claimSiteId !== "string"
|
|
109
|
+
if (typeof claimSiteId !== "string") {
|
|
110
|
+
return { ok: false, reason: "site_id_mismatch" };
|
|
111
|
+
}
|
|
112
|
+
if (input.expectedSiteId && claimSiteId !== input.expectedSiteId) {
|
|
102
113
|
return { ok: false, reason: "site_id_mismatch" };
|
|
103
114
|
}
|
|
104
115
|
const claimBodyHash = payload.body_sha256;
|
|
@@ -126,7 +137,7 @@ async function verifyRootTaleWebhook(input) {
|
|
|
126
137
|
};
|
|
127
138
|
}
|
|
128
139
|
export {
|
|
129
|
-
|
|
140
|
+
DEFAULT_API_BASE,
|
|
130
141
|
__useLocalJwksForTesting,
|
|
131
142
|
verifyRootTaleWebhook
|
|
132
143
|
};
|
package/dist/webhook.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/webhook.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/webhook` — RootTale CMS Publish Webhook verifier.\n *\n * ADR-0039 (Option D, JWKS asymmetric). 외부 고객사 사이트가 RootTale CMS 의\n * publish webhook 을 검증한다. 고객 측에 **secret 보관 0** — 공개키만으로 검증.\n *\n * 인증 모델:\n * 서명 = ES256 JWS (alg=ES256, kid=<opaque>). admin/api-core 의 dispatcher 가\n * site_webhook_keys 의 active row 로 서명.\n * 검증 = `api.roottale.com/.well-known/jwks.json` 에서 공개키 fetch (1 시간\n * 캐시, kid 변화 즉시 refresh). jose 라이브러리.\n *\n * Customer 작업:\n * 1) `pnpm add @roottale/cms-client`\n * 2) admin 의 사이트 추가 화면에서 본 사이트의 siteId 값을 받아 env 에 paste:\n * `ROOTTALE_EXPECTED_SITE_ID=<siteId>` (secret 아님, lock-down 용)\n * 3) /api/revalidate 라우트에서 verifyRootTaleWebhook 호출\n *\n * Next.js Route Handler 예제:\n * ```ts\n * import { verifyRootTaleWebhook } from \"@roottale/cms-client/webhook\";\n *\n * export async function POST(request: Request) {\n * const rawBody = await request.text();\n * const result = await verifyRootTaleWebhook({\n * rawBody,\n * headers: request.headers,\n * expectedSiteId: process.env.ROOTTALE_EXPECTED_SITE_ID!,\n * });\n * if (!result.ok) return Response.json({ reason: result.reason }, { status: 401 });\n * // ... revalidatePath(result.payload.paths)\n * }\n * ```\n */\n\nimport {\n type JWK,\n type JWTPayload,\n createLocalJWKSet,\n createRemoteJWKSet,\n errors as joseErrors,\n jwtVerify,\n} from \"jose\";\n\nexport const DEFAULT_JWKS_URL =\n \"https://api.roottale.com/.well-known/jwks.json\";\nconst DEFAULT_TIMESTAMP_WINDOW_SEC = 300;\nconst SIGNATURE_ISSUER = \"https://api.roottale.com\";\nconst SIGNATURE_AUDIENCE = \"roottale-webhook\";\n\nexport interface VerifyRootTaleWebhookInput {\n /** `await request.text()` 결과. parse 전에 verify 해야 함. */\n rawBody: string;\n /** request.headers (Headers 또는 dict). */\n headers: Headers | Record<string, string | undefined>;\n /**\n * 고객 사이트가 *어느 RootTale siteId* 를 신뢰하는지 명시. 필수.\n * production 에서 미설정은 위험 — 다른 사이트로 가는 서명이 통과될 수 있음.\n */\n expectedSiteId: string;\n /** (옵션) JWKS endpoint URL override. 기본 = https://api.roottale.com/.well-known/jwks.json */\n jwksUrl?: string;\n /** (옵션) timestamp drift 허용 (초). 기본 300. */\n timestampWindowSec?: number;\n /**\n * (옵션) replay idempotency hook. jti 가 이미 본 거면 \"seen\" 반환해서 reject.\n * 미지정 시 replay check skip — ISR revalidate 처럼 idempotent 한 endpoint 에선 무해.\n */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n /** (옵션) 테스트용 now() override. */\n now?: () => number;\n}\n\nexport interface VerifyRootTaleWebhookSuccess {\n ok: true;\n /** JWS jti claim — webhook delivery id, X-Roottale-Delivery-Id 헤더와 동일. */\n deliveryId: string;\n /** payload.siteId (= expectedSiteId 가 일치 검증됨). */\n siteId: string;\n /** post.published | post.updated | post.deleted */\n event: string;\n /** body 의 SHA-256 (base64url). caller 가 별도 검증할 필요 X — 이미 jws 가 binding. */\n bodyHash: string;\n /** 디코딩된 JWS payload. */\n payload: JWTPayload;\n}\n\nexport interface VerifyRootTaleWebhookFailure {\n ok: false;\n reason:\n | \"missing_signature\"\n | \"missing_site_id\"\n | \"jwks_fetch_failed\"\n | \"invalid_signature\"\n | \"expired\"\n | \"issuer_mismatch\"\n | \"audience_mismatch\"\n | \"site_id_mismatch\"\n | \"body_hash_mismatch\"\n | \"timestamp_out_of_window\"\n | \"replay_seen\"\n | \"internal_error\";\n}\n\nexport type VerifyRootTaleWebhookResult =\n | VerifyRootTaleWebhookSuccess\n | VerifyRootTaleWebhookFailure;\n\n// ── module-level JWKS cache (process lifetime) ──────────────────────────────\n\ntype JwksResolver = ReturnType<typeof createRemoteJWKSet>;\nconst jwksSetCache = new Map<string, JwksResolver>();\n\nfunction getJwksSet(jwksUrl: string): JwksResolver {\n let cached = jwksSetCache.get(jwksUrl);\n if (!cached) {\n cached = createRemoteJWKSet(new URL(jwksUrl), {\n cacheMaxAge: 60 * 60 * 1000, // 1h\n cooldownDuration: 30 * 1000, // 모르는 kid 재 fetch 최소 간격\n timeoutDuration: 5_000,\n });\n jwksSetCache.set(jwksUrl, cached);\n }\n return cached;\n}\n\n/**\n * Test-only — 특정 jwksUrl 의 lookup 을 local JWKS 로 미리 채움. 호출 후 같은\n * URL 로 verifyRootTaleWebhook 하면 remote fetch 없이 in-memory 검증.\n * production 코드에선 호출 금지.\n */\nexport function __useLocalJwksForTesting(\n jwksUrl: string,\n jwks: { keys: JWK[] },\n): void {\n jwksSetCache.set(jwksUrl, createLocalJWKSet(jwks) as JwksResolver);\n}\n\n// ── helpers ─────────────────────────────────────────────────────────────────\n\nfunction readHeader(\n headers: Headers | Record<string, string | undefined>,\n name: string,\n): string | null {\n if (typeof (headers as Headers).get === \"function\") {\n const v = (headers as Headers).get(name);\n return v ?? null;\n }\n const dict = headers as Record<string, string | undefined>;\n const direct = dict[name];\n if (typeof direct === \"string\") return direct;\n for (const k of Object.keys(dict)) {\n if (k.toLowerCase() === name) {\n const v = dict[k];\n if (typeof v === \"string\") return v;\n }\n }\n return null;\n}\n\nfunction bytesToBase64Url(bytes: Uint8Array): string {\n let s = \"\";\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n const b64 =\n typeof btoa === \"function\" ? btoa(s) : Buffer.from(s, \"binary\").toString(\"base64\");\n return b64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sha256Base64Url(input: string): Promise<string> {\n const enc = new TextEncoder();\n const data = enc.encode(input);\n const hashBuf = await crypto.subtle.digest(\"SHA-256\", data);\n return bytesToBase64Url(new Uint8Array(hashBuf));\n}\n\n// ── public API ──────────────────────────────────────────────────────────────\n\nexport async function verifyRootTaleWebhook(\n input: VerifyRootTaleWebhookInput,\n): Promise<VerifyRootTaleWebhookResult> {\n if (!input.expectedSiteId) {\n return { ok: false, reason: \"missing_site_id\" };\n }\n const jws = readHeader(input.headers, \"x-roottale-signature\");\n if (!jws) return { ok: false, reason: \"missing_signature\" };\n\n const jwksUrl = input.jwksUrl ?? DEFAULT_JWKS_URL;\n const jwks = getJwksSet(jwksUrl);\n\n // timestamp window — jose 의 clockTolerance 옵션으로 ±drift 검사.\n // jose 는 iat/exp 를 검사하므로 window 는 clockTolerance 로 흡수.\n const windowSec = input.timestampWindowSec ?? DEFAULT_TIMESTAMP_WINDOW_SEC;\n const nowDate = input.now ? new Date(input.now()) : new Date();\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(jws, jwks, {\n issuer: SIGNATURE_ISSUER,\n audience: SIGNATURE_AUDIENCE,\n clockTolerance: `${windowSec}s`,\n currentDate: nowDate,\n });\n payload = result.payload;\n } catch (e) {\n if (e instanceof joseErrors.JWTExpired) {\n return { ok: false, reason: \"expired\" };\n }\n if (e instanceof joseErrors.JWTClaimValidationFailed) {\n const claim = (e as { claim?: string }).claim;\n if (claim === \"iss\") return { ok: false, reason: \"issuer_mismatch\" };\n if (claim === \"aud\") return { ok: false, reason: \"audience_mismatch\" };\n if (claim === \"iat\" || claim === \"exp\")\n return { ok: false, reason: \"timestamp_out_of_window\" };\n return { ok: false, reason: \"invalid_signature\" };\n }\n if (\n e instanceof joseErrors.JWSSignatureVerificationFailed ||\n e instanceof joseErrors.JWSInvalid ||\n e instanceof joseErrors.JWTInvalid\n ) {\n return { ok: false, reason: \"invalid_signature\" };\n }\n if ((e as { code?: string }).code?.startsWith?.(\"ERR_JWKS\")) {\n return { ok: false, reason: \"jwks_fetch_failed\" };\n }\n // 디버그: tests 에서 internal_error 가 나오면 console 에 noise 가 도움.\n if (process.env.VERIFY_DEBUG) {\n // eslint-disable-next-line no-console\n console.warn(\"[verifyRootTaleWebhook] unexpected error:\", e);\n }\n return { ok: false, reason: \"internal_error\" };\n }\n\n const claimSiteId = payload.site_id;\n if (typeof claimSiteId !== \"string\" || claimSiteId !== input.expectedSiteId) {\n return { ok: false, reason: \"site_id_mismatch\" };\n }\n\n const claimBodyHash = payload.body_sha256;\n if (typeof claimBodyHash !== \"string\") {\n return { ok: false, reason: \"body_hash_mismatch\" };\n }\n const actualBodyHash = await sha256Base64Url(input.rawBody);\n if (claimBodyHash !== actualBodyHash) {\n return { ok: false, reason: \"body_hash_mismatch\" };\n }\n\n if (input.consumeJti && typeof payload.jti === \"string\") {\n const seen = await input.consumeJti(\n payload.jti,\n typeof payload.exp === \"number\" ? payload.exp : 0,\n );\n if (seen === \"seen\") return { ok: false, reason: \"replay_seen\" };\n }\n\n return {\n ok: true,\n deliveryId: typeof payload.jti === \"string\" ? payload.jti : \"\",\n siteId: claimSiteId,\n event: typeof payload.event === \"string\" ? payload.event : \"\",\n bodyHash: claimBodyHash,\n payload,\n };\n}\n\n// ── re-export type for caller convenience ───────────────────────────────────\nexport type { JWTPayload };\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/webhook.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/webhook` — RootTale CMS Publish Webhook verifier.\n *\n * ADR-0039 (Option D, JWKS asymmetric + site-scoped API key). 외부 고객 사이트가\n * RootTale CMS 의 publish webhook 을 검증한다. **고객 측 secret 보관 0** —\n * 공개키만으로 검증, 동시에 site 잠금은 site-scoped API key 가 담당.\n *\n * 인증 모델:\n * 서명 = ES256 JWS (alg=ES256, kid=<opaque>). admin/api-core 의 dispatcher 가\n * site_webhook_keys 의 active row 로 서명.\n * 검증 = Bearer apiKey 로 `api.roottale.com/v1/cms/public/jwks` fetch (1 시간\n * 캐시, kid 변경 시 즉시 refresh). jose 라이브러리.\n * tenant_api_keys.site_id 기준 *그 사이트의* 공개키만 노출 → site\n * 잠금이 server side 에서 자동으로 됨.\n *\n * Customer 작업:\n * 1) `pnpm add @roottale/cms-client`\n * 2) admin 의 사이트 추가 화면에서 customer API key 1 개 발급 (site-scoped 자동)\n * 3) `ROOTTALE_API_KEY=rtlk_cust_xxx` env paste\n * 4) /api/revalidate 라우트에서 verifyRootTaleWebhook 호출\n *\n * Next.js Route Handler 예제:\n * ```ts\n * import { verifyRootTaleWebhook } from \"@roottale/cms-client/webhook\";\n *\n * export async function POST(request: Request) {\n * const rawBody = await request.text();\n * const result = await verifyRootTaleWebhook({\n * rawBody,\n * headers: request.headers,\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * });\n * if (!result.ok) return Response.json({ reason: result.reason }, { status: 401 });\n * // ... revalidatePath(result.payload.paths)\n * }\n * ```\n */\n\nimport {\n type JWK,\n type JWTPayload,\n createLocalJWKSet,\n createRemoteJWKSet,\n errors as joseErrors,\n jwtVerify,\n} from \"jose\";\n\nexport const DEFAULT_API_BASE = \"https://api.roottale.com\";\nconst JWKS_PATH = \"/v1/cms/public/jwks\";\nconst DEFAULT_TIMESTAMP_WINDOW_SEC = 300;\nconst SIGNATURE_ISSUER = \"https://api.roottale.com\";\nconst SIGNATURE_AUDIENCE = \"roottale-webhook\";\n\nexport interface VerifyRootTaleWebhookInput {\n /** `await request.text()` 결과. parse 전에 verify 해야 함. */\n rawBody: string;\n /** request.headers (Headers 또는 dict). */\n headers: Headers | Record<string, string | undefined>;\n /**\n * Site-scoped customer API key (`rtlk_cust_*`). admin UI 에서 발급된 그 값.\n * 본 키로 JWKS endpoint 에 Bearer auth → 사이트의 active 공개키만 fetch.\n * CMS content fetch (cms-client/server) 와 *같은 키* 를 사용.\n */\n apiKey: string;\n /** (옵션) API base URL override. 기본 = https://api.roottale.com */\n apiBase?: string;\n /**\n * (옵션) multi-site tenant 에서 추가 hardening 으로 특정 siteId 만 받기.\n * site-scoped key 가 이미 site 를 잠그므로 일반적으론 불필요.\n */\n expectedSiteId?: string;\n /** (옵션) timestamp drift 허용 (초). 기본 300. */\n timestampWindowSec?: number;\n /**\n * (옵션) replay idempotency hook. jti 가 이미 본 거면 \"seen\" 반환해서 reject.\n * 미지정 시 replay check skip — ISR revalidate 같은 idempotent endpoint 에선 무해.\n */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n /** (옵션) 테스트용 now() override. */\n now?: () => number;\n}\n\nexport interface VerifyRootTaleWebhookSuccess {\n ok: true;\n /** JWS jti — webhook delivery id, X-Roottale-Delivery-Id 헤더와 동일. */\n deliveryId: string;\n /** payload.siteId. */\n siteId: string;\n /** post.published | post.updated | post.deleted */\n event: string;\n /** body 의 SHA-256 (base64url). jws 가 binding. */\n bodyHash: string;\n /** 디코딩된 JWS payload. */\n payload: JWTPayload;\n}\n\nexport interface VerifyRootTaleWebhookFailure {\n ok: false;\n reason:\n | \"missing_signature\"\n | \"missing_api_key\"\n | \"jwks_fetch_failed\"\n | \"invalid_signature\"\n | \"expired\"\n | \"issuer_mismatch\"\n | \"audience_mismatch\"\n | \"site_id_mismatch\"\n | \"body_hash_mismatch\"\n | \"timestamp_out_of_window\"\n | \"replay_seen\"\n | \"internal_error\";\n}\n\nexport type VerifyRootTaleWebhookResult =\n | VerifyRootTaleWebhookSuccess\n | VerifyRootTaleWebhookFailure;\n\n// ── module-level JWKS cache (process lifetime, keyed by apiBase+apiKey hash) ─\n\ntype JwksResolver = ReturnType<typeof createRemoteJWKSet>;\nconst jwksSetCache = new Map<string, JwksResolver>();\n\nfunction cacheKey(apiBase: string, apiKey: string): string {\n // apiKey 전체를 key 로 쓰면 cache 가 메모리에 평문 보관. 단순 prefix + length.\n const prefix = apiKey.slice(0, 12);\n return `${apiBase}::${prefix}.${apiKey.length}`;\n}\n\nfunction getJwksSet(apiBase: string, apiKey: string): JwksResolver {\n const key = cacheKey(apiBase, apiKey);\n let cached = jwksSetCache.get(key);\n if (!cached) {\n const url = new URL(JWKS_PATH, apiBase);\n cached = createRemoteJWKSet(url, {\n cacheMaxAge: 60 * 60 * 1000, // 1h\n cooldownDuration: 30 * 1000, // 모르는 kid 재 fetch 최소 간격\n timeoutDuration: 5_000,\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n jwksSetCache.set(key, cached);\n }\n return cached;\n}\n\n/**\n * Test-only — local JWKS 로 cache 채움. production 호출 금지.\n */\nexport function __useLocalJwksForTesting(\n apiBase: string,\n apiKey: string,\n jwks: { keys: JWK[] },\n): void {\n jwksSetCache.set(\n cacheKey(apiBase, apiKey),\n createLocalJWKSet(jwks) as JwksResolver,\n );\n}\n\n// ── helpers ─────────────────────────────────────────────────────────────────\n\nfunction readHeader(\n headers: Headers | Record<string, string | undefined>,\n name: string,\n): string | null {\n if (typeof (headers as Headers).get === \"function\") {\n const v = (headers as Headers).get(name);\n return v ?? null;\n }\n const dict = headers as Record<string, string | undefined>;\n const direct = dict[name];\n if (typeof direct === \"string\") return direct;\n for (const k of Object.keys(dict)) {\n if (k.toLowerCase() === name) {\n const v = dict[k];\n if (typeof v === \"string\") return v;\n }\n }\n return null;\n}\n\nfunction bytesToBase64Url(bytes: Uint8Array): string {\n let s = \"\";\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n const b64 =\n typeof btoa === \"function\" ? btoa(s) : Buffer.from(s, \"binary\").toString(\"base64\");\n return b64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nasync function sha256Base64Url(input: string): Promise<string> {\n const enc = new TextEncoder();\n const data = enc.encode(input);\n const hashBuf = await crypto.subtle.digest(\"SHA-256\", data);\n return bytesToBase64Url(new Uint8Array(hashBuf));\n}\n\n// ── public API ──────────────────────────────────────────────────────────────\n\nexport async function verifyRootTaleWebhook(\n input: VerifyRootTaleWebhookInput,\n): Promise<VerifyRootTaleWebhookResult> {\n if (!input.apiKey) {\n return { ok: false, reason: \"missing_api_key\" };\n }\n const jws = readHeader(input.headers, \"x-roottale-signature\");\n if (!jws) return { ok: false, reason: \"missing_signature\" };\n\n const apiBase = input.apiBase ?? DEFAULT_API_BASE;\n const jwks = getJwksSet(apiBase, input.apiKey);\n\n const windowSec = input.timestampWindowSec ?? DEFAULT_TIMESTAMP_WINDOW_SEC;\n const nowDate = input.now ? new Date(input.now()) : new Date();\n\n let payload: JWTPayload;\n try {\n const result = await jwtVerify(jws, jwks, {\n issuer: SIGNATURE_ISSUER,\n audience: SIGNATURE_AUDIENCE,\n clockTolerance: `${windowSec}s`,\n currentDate: nowDate,\n });\n payload = result.payload;\n } catch (e) {\n if (e instanceof joseErrors.JWTExpired) {\n return { ok: false, reason: \"expired\" };\n }\n if (e instanceof joseErrors.JWTClaimValidationFailed) {\n const claim = (e as { claim?: string }).claim;\n if (claim === \"iss\") return { ok: false, reason: \"issuer_mismatch\" };\n if (claim === \"aud\") return { ok: false, reason: \"audience_mismatch\" };\n if (claim === \"iat\" || claim === \"exp\")\n return { ok: false, reason: \"timestamp_out_of_window\" };\n return { ok: false, reason: \"invalid_signature\" };\n }\n if (\n e instanceof joseErrors.JWSSignatureVerificationFailed ||\n e instanceof joseErrors.JWSInvalid ||\n e instanceof joseErrors.JWTInvalid\n ) {\n return { ok: false, reason: \"invalid_signature\" };\n }\n if ((e as { code?: string }).code?.startsWith?.(\"ERR_JWKS\")) {\n return { ok: false, reason: \"jwks_fetch_failed\" };\n }\n return { ok: false, reason: \"internal_error\" };\n }\n\n const claimSiteId = payload.site_id;\n if (typeof claimSiteId !== \"string\") {\n return { ok: false, reason: \"site_id_mismatch\" };\n }\n // expectedSiteId 옵션이 지정됐을 때만 추가 hardening (site-scoped key 가\n // 이미 server side 에서 site 잠금하므로 일반적으론 불필요).\n if (input.expectedSiteId && claimSiteId !== input.expectedSiteId) {\n return { ok: false, reason: \"site_id_mismatch\" };\n }\n\n const claimBodyHash = payload.body_sha256;\n if (typeof claimBodyHash !== \"string\") {\n return { ok: false, reason: \"body_hash_mismatch\" };\n }\n const actualBodyHash = await sha256Base64Url(input.rawBody);\n if (claimBodyHash !== actualBodyHash) {\n return { ok: false, reason: \"body_hash_mismatch\" };\n }\n\n if (input.consumeJti && typeof payload.jti === \"string\") {\n const seen = await input.consumeJti(\n payload.jti,\n typeof payload.exp === \"number\" ? payload.exp : 0,\n );\n if (seen === \"seen\") return { ok: false, reason: \"replay_seen\" };\n }\n\n return {\n ok: true,\n deliveryId: typeof payload.jti === \"string\" ? payload.jti : \"\",\n siteId: claimSiteId,\n event: typeof payload.event === \"string\" ? payload.event : \"\",\n bodyHash: claimBodyHash,\n payload,\n };\n}\n\nexport type { JWTPayload };\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale CMS Public API server-side fetch client (Bearer rtlk_cust_* auth). SSR-only — refuses to run in the browser to prevent key leak (ADR-0023 §5.1 #15). Pairs with @roottale/cms-renderer-next / @roottale/cms-renderer-astro.",
|
|
6
6
|
"main": "./dist/server.js",
|