@roottale/cms-client 0.43.0 → 0.45.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,11 @@
1
1
  # @roottale/cms-client
2
2
 
3
+ ## 0.45.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 23aa6ae: 카테고리 소속을 콘텐츠 유형 설정과 분리하고, 공개 collections 응답의 categories를 분류 원장에서 조립합니다. 사이트 내부 경로 검증을 강화하고 taxonomy.updated 집계 웹훅 계약과 연동 예제를 추가합니다.
8
+
3
9
  ## 0.43.0
4
10
 
5
11
  ### Minor Changes
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
 
5
5
  // src/server-base.ts
6
6
  var DEFAULT_BASE_URL = "https://api.roottale.com";
7
- var CLIENT_VERSION = true ? "0.43.0" : "dev";
7
+ var CLIENT_VERSION = true ? "0.45.0" : "dev";
8
8
  var CMS_CLIENT_VERSION = CLIENT_VERSION;
9
9
  function authHeaders(apiKey) {
10
10
  return {
package/dist/webhook.d.ts CHANGED
@@ -74,7 +74,6 @@ interface VerifyRootTaleWebhookSuccess {
74
74
  deliveryId: string;
75
75
  /** payload.siteId. */
76
76
  siteId: string;
77
- /** post.published | post.updated | post.deleted */
78
77
  event: string;
79
78
  /** body 의 SHA-256 (base64url). jws 가 binding. */
80
79
  bodyHash: string;
@@ -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 + 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":[]}
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 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.43.0",
3
+ "version": "0.45.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.",
6
6
  "main": "./dist/server.js",