@roottale/cms-client 0.2.0 → 0.5.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 +14 -6
- package/dist/webhook.d.ts +90 -0
- package/dist/webhook.js +133 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +11 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @roottale/cms-client
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c43ab28: ADR-0039 — `/webhook` subpath export 추가 (0.4.0 에서 누락, 0.5.0 에서 실제 노출). tsup entry 와 package.json exports 동시 갱신 — `@roottale/cms-client/webhook` 로 `verifyRootTaleWebhook` import 가능.
|
|
8
|
+
|
|
9
|
+
## 0.4.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 31900de: ADR-0039 (Option D, JWKS asymmetric) — `@roottale/cms-client/webhook` subpath export 추가. `verifyRootTaleWebhook({ rawBody, headers, expectedSiteId, ... })` 가 RootTale CMS publish webhook 의 ES256 JWS 시그너처를 JWKS (api.roottale.com/.well-known/jwks.json) 기반으로 검증한다. **고객 측 secret 보관 0** — `ROOTTALE_EXPECTED_SITE_ID` env 만 (비밀 아님). issuer/audience/site_id/body_sha256/timestamp 모두 검증. jose 라이브러리 (Node 18+/Workers/Bun/Deno 양립).
|
|
14
|
+
|
|
15
|
+
기존 `/server` 의 fetch helper 는 변동 없음.
|
|
16
|
+
|
|
3
17
|
## 0.2.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
|
@@ -14,7 +28,6 @@
|
|
|
14
28
|
(빈 배열 fallback).
|
|
15
29
|
|
|
16
30
|
신규 타입:
|
|
17
|
-
|
|
18
31
|
- `CmsTermRef { id, taxonomy, slug, name }`
|
|
19
32
|
- `CmsTaxonomyKind = "category" | "tag" | "custom"`
|
|
20
33
|
|
|
@@ -52,7 +65,6 @@
|
|
|
52
65
|
의미의 `theme` 옵션. style="..." 속성으로 CSS 변수 주입 (서버 측 escape 보강).
|
|
53
66
|
|
|
54
67
|
`theme` prop semantics:
|
|
55
|
-
|
|
56
68
|
- `undefined` → 자동 fetch
|
|
57
69
|
- `RootTaleTheme` → 호출자 override
|
|
58
70
|
- `null` → 자동 fetch 건너뜀 (부모 `RootTaleThemeProvider` 활용 시)
|
|
@@ -71,7 +83,6 @@
|
|
|
71
83
|
|
|
72
84
|
수정: `tsup` 으로 `dist/*.js` + `dist/*.d.ts` (ESM) 출력. `exports` 가 dist
|
|
73
85
|
가리킴. customer site 측 `transpilePackages` 불필요.
|
|
74
|
-
|
|
75
86
|
- `cms-client@0.1.1` — ESM `dist/server.js` + types
|
|
76
87
|
- `cms-core@0.2.1` — ESM `dist/index.js` + types
|
|
77
88
|
- `cms-renderer-next@0.2.1` — ESM `dist/{server,index}.js` + types + `dist/cms-public.css`
|
|
@@ -80,7 +91,6 @@
|
|
|
80
91
|
- `cms-renderer-astro@0.2.1` — ESM `dist/index.js` + types
|
|
81
92
|
|
|
82
93
|
후속 (customer site PR):
|
|
83
|
-
|
|
84
94
|
- roottale-web / kjmtax / theoneulsan 의 `next.config` 의 `transpilePackages`
|
|
85
95
|
에서 `@roottale/cms-*` 제거. `pnpm update @roottale/cms-client @roottale/cms-renderer-next` 로 patch 적용.
|
|
86
96
|
|
|
@@ -93,7 +103,6 @@
|
|
|
93
103
|
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…`) 정합.
|
|
94
104
|
|
|
95
105
|
변경:
|
|
96
|
-
|
|
97
106
|
- 4 패키지 `private: true` 해제 + `publishConfig.access: "public"`
|
|
98
107
|
- `@roottale/cms-renderer-next` 에서 `@roottale/tokens/tokens.css` import 제거 — 모든 `--rt-*` 변수에 static fallback 으로 self-contained. tokens dormant 와 무관하게 동작.
|
|
99
108
|
- `RootTaleLeadForm` RSC 추가 — 외부 사이트 진단 폼 (`vertical`/`redirectUrl` props, medical 국외이전 동의 자동).
|
|
@@ -102,6 +111,5 @@
|
|
|
102
111
|
- `cms-renderer-astro` 도 동등 surface 유지를 위해 동시 publish
|
|
103
112
|
|
|
104
113
|
후속:
|
|
105
|
-
|
|
106
114
|
- ADR 신규 — cms-\* publish 정책 (별 PR)
|
|
107
115
|
- Astro 측 LeadForm 컴포넌트 (현재 `@roottale/ui-astro` 위치, `cms-renderer-astro` 로 이동 검토)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { JWTPayload, JWK } from 'jose';
|
|
2
|
+
export { JWTPayload } from 'jose';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@roottale/cms-client/webhook` — RootTale CMS Publish Webhook verifier.
|
|
6
|
+
*
|
|
7
|
+
* ADR-0039 (Option D, JWKS asymmetric). 외부 고객사 사이트가 RootTale CMS 의
|
|
8
|
+
* publish webhook 을 검증한다. 고객 측에 **secret 보관 0** — 공개키만으로 검증.
|
|
9
|
+
*
|
|
10
|
+
* 인증 모델:
|
|
11
|
+
* 서명 = ES256 JWS (alg=ES256, kid=<opaque>). admin/api-core 의 dispatcher 가
|
|
12
|
+
* site_webhook_keys 의 active row 로 서명.
|
|
13
|
+
* 검증 = `api.roottale.com/.well-known/jwks.json` 에서 공개키 fetch (1 시간
|
|
14
|
+
* 캐시, kid 변화 즉시 refresh). jose 라이브러리.
|
|
15
|
+
*
|
|
16
|
+
* Customer 작업:
|
|
17
|
+
* 1) `pnpm add @roottale/cms-client`
|
|
18
|
+
* 2) admin 의 사이트 추가 화면에서 본 사이트의 siteId 값을 받아 env 에 paste:
|
|
19
|
+
* `ROOTTALE_EXPECTED_SITE_ID=<siteId>` (secret 아님, lock-down 용)
|
|
20
|
+
* 3) /api/revalidate 라우트에서 verifyRootTaleWebhook 호출
|
|
21
|
+
*
|
|
22
|
+
* Next.js Route Handler 예제:
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { verifyRootTaleWebhook } from "@roottale/cms-client/webhook";
|
|
25
|
+
*
|
|
26
|
+
* export async function POST(request: Request) {
|
|
27
|
+
* const rawBody = await request.text();
|
|
28
|
+
* const result = await verifyRootTaleWebhook({
|
|
29
|
+
* rawBody,
|
|
30
|
+
* headers: request.headers,
|
|
31
|
+
* expectedSiteId: process.env.ROOTTALE_EXPECTED_SITE_ID!,
|
|
32
|
+
* });
|
|
33
|
+
* if (!result.ok) return Response.json({ reason: result.reason }, { status: 401 });
|
|
34
|
+
* // ... revalidatePath(result.payload.paths)
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
declare const DEFAULT_JWKS_URL = "https://api.roottale.com/.well-known/jwks.json";
|
|
40
|
+
interface VerifyRootTaleWebhookInput {
|
|
41
|
+
/** `await request.text()` 결과. parse 전에 verify 해야 함. */
|
|
42
|
+
rawBody: string;
|
|
43
|
+
/** request.headers (Headers 또는 dict). */
|
|
44
|
+
headers: Headers | Record<string, string | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* 고객 사이트가 *어느 RootTale siteId* 를 신뢰하는지 명시. 필수.
|
|
47
|
+
* production 에서 미설정은 위험 — 다른 사이트로 가는 서명이 통과될 수 있음.
|
|
48
|
+
*/
|
|
49
|
+
expectedSiteId: string;
|
|
50
|
+
/** (옵션) JWKS endpoint URL override. 기본 = https://api.roottale.com/.well-known/jwks.json */
|
|
51
|
+
jwksUrl?: string;
|
|
52
|
+
/** (옵션) timestamp drift 허용 (초). 기본 300. */
|
|
53
|
+
timestampWindowSec?: number;
|
|
54
|
+
/**
|
|
55
|
+
* (옵션) replay idempotency hook. jti 가 이미 본 거면 "seen" 반환해서 reject.
|
|
56
|
+
* 미지정 시 replay check skip — ISR revalidate 처럼 idempotent 한 endpoint 에선 무해.
|
|
57
|
+
*/
|
|
58
|
+
consumeJti?: (jti: string, exp: number) => Promise<"new" | "seen">;
|
|
59
|
+
/** (옵션) 테스트용 now() override. */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}
|
|
62
|
+
interface VerifyRootTaleWebhookSuccess {
|
|
63
|
+
ok: true;
|
|
64
|
+
/** JWS jti claim — webhook delivery id, X-Roottale-Delivery-Id 헤더와 동일. */
|
|
65
|
+
deliveryId: string;
|
|
66
|
+
/** payload.siteId (= expectedSiteId 가 일치 검증됨). */
|
|
67
|
+
siteId: string;
|
|
68
|
+
/** post.published | post.updated | post.deleted */
|
|
69
|
+
event: string;
|
|
70
|
+
/** body 의 SHA-256 (base64url). caller 가 별도 검증할 필요 X — 이미 jws 가 binding. */
|
|
71
|
+
bodyHash: string;
|
|
72
|
+
/** 디코딩된 JWS payload. */
|
|
73
|
+
payload: JWTPayload;
|
|
74
|
+
}
|
|
75
|
+
interface VerifyRootTaleWebhookFailure {
|
|
76
|
+
ok: false;
|
|
77
|
+
reason: "missing_signature" | "missing_site_id" | "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
|
+
}
|
|
79
|
+
type VerifyRootTaleWebhookResult = VerifyRootTaleWebhookSuccess | VerifyRootTaleWebhookFailure;
|
|
80
|
+
/**
|
|
81
|
+
* Test-only — 특정 jwksUrl 의 lookup 을 local JWKS 로 미리 채움. 호출 후 같은
|
|
82
|
+
* URL 로 verifyRootTaleWebhook 하면 remote fetch 없이 in-memory 검증.
|
|
83
|
+
* production 코드에선 호출 금지.
|
|
84
|
+
*/
|
|
85
|
+
declare function __useLocalJwksForTesting(jwksUrl: string, jwks: {
|
|
86
|
+
keys: JWK[];
|
|
87
|
+
}): void;
|
|
88
|
+
declare function verifyRootTaleWebhook(input: VerifyRootTaleWebhookInput): Promise<VerifyRootTaleWebhookResult>;
|
|
89
|
+
|
|
90
|
+
export { DEFAULT_JWKS_URL, type VerifyRootTaleWebhookFailure, type VerifyRootTaleWebhookInput, type VerifyRootTaleWebhookResult, type VerifyRootTaleWebhookSuccess, __useLocalJwksForTesting, verifyRootTaleWebhook };
|
package/dist/webhook.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// src/webhook.ts
|
|
2
|
+
import {
|
|
3
|
+
createLocalJWKSet,
|
|
4
|
+
createRemoteJWKSet,
|
|
5
|
+
errors as joseErrors,
|
|
6
|
+
jwtVerify
|
|
7
|
+
} from "jose";
|
|
8
|
+
var DEFAULT_JWKS_URL = "https://api.roottale.com/.well-known/jwks.json";
|
|
9
|
+
var DEFAULT_TIMESTAMP_WINDOW_SEC = 300;
|
|
10
|
+
var SIGNATURE_ISSUER = "https://api.roottale.com";
|
|
11
|
+
var SIGNATURE_AUDIENCE = "roottale-webhook";
|
|
12
|
+
var jwksSetCache = /* @__PURE__ */ new Map();
|
|
13
|
+
function getJwksSet(jwksUrl) {
|
|
14
|
+
let cached = jwksSetCache.get(jwksUrl);
|
|
15
|
+
if (!cached) {
|
|
16
|
+
cached = createRemoteJWKSet(new URL(jwksUrl), {
|
|
17
|
+
cacheMaxAge: 60 * 60 * 1e3,
|
|
18
|
+
// 1h
|
|
19
|
+
cooldownDuration: 30 * 1e3,
|
|
20
|
+
// 모르는 kid 재 fetch 최소 간격
|
|
21
|
+
timeoutDuration: 5e3
|
|
22
|
+
});
|
|
23
|
+
jwksSetCache.set(jwksUrl, cached);
|
|
24
|
+
}
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
function __useLocalJwksForTesting(jwksUrl, jwks) {
|
|
28
|
+
jwksSetCache.set(jwksUrl, createLocalJWKSet(jwks));
|
|
29
|
+
}
|
|
30
|
+
function readHeader(headers, name) {
|
|
31
|
+
if (typeof headers.get === "function") {
|
|
32
|
+
const v = headers.get(name);
|
|
33
|
+
return v ?? null;
|
|
34
|
+
}
|
|
35
|
+
const dict = headers;
|
|
36
|
+
const direct = dict[name];
|
|
37
|
+
if (typeof direct === "string") return direct;
|
|
38
|
+
for (const k of Object.keys(dict)) {
|
|
39
|
+
if (k.toLowerCase() === name) {
|
|
40
|
+
const v = dict[k];
|
|
41
|
+
if (typeof v === "string") return v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function bytesToBase64Url(bytes) {
|
|
47
|
+
let s = "";
|
|
48
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
49
|
+
const b64 = typeof btoa === "function" ? btoa(s) : Buffer.from(s, "binary").toString("base64");
|
|
50
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
|
+
}
|
|
52
|
+
async function sha256Base64Url(input) {
|
|
53
|
+
const enc = new TextEncoder();
|
|
54
|
+
const data = enc.encode(input);
|
|
55
|
+
const hashBuf = await crypto.subtle.digest("SHA-256", data);
|
|
56
|
+
return bytesToBase64Url(new Uint8Array(hashBuf));
|
|
57
|
+
}
|
|
58
|
+
async function verifyRootTaleWebhook(input) {
|
|
59
|
+
if (!input.expectedSiteId) {
|
|
60
|
+
return { ok: false, reason: "missing_site_id" };
|
|
61
|
+
}
|
|
62
|
+
const jws = readHeader(input.headers, "x-roottale-signature");
|
|
63
|
+
if (!jws) return { ok: false, reason: "missing_signature" };
|
|
64
|
+
const jwksUrl = input.jwksUrl ?? DEFAULT_JWKS_URL;
|
|
65
|
+
const jwks = getJwksSet(jwksUrl);
|
|
66
|
+
const windowSec = input.timestampWindowSec ?? DEFAULT_TIMESTAMP_WINDOW_SEC;
|
|
67
|
+
const nowDate = input.now ? new Date(input.now()) : /* @__PURE__ */ new Date();
|
|
68
|
+
let payload;
|
|
69
|
+
try {
|
|
70
|
+
const result = await jwtVerify(jws, jwks, {
|
|
71
|
+
issuer: SIGNATURE_ISSUER,
|
|
72
|
+
audience: SIGNATURE_AUDIENCE,
|
|
73
|
+
clockTolerance: `${windowSec}s`,
|
|
74
|
+
currentDate: nowDate
|
|
75
|
+
});
|
|
76
|
+
payload = result.payload;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (e instanceof joseErrors.JWTExpired) {
|
|
79
|
+
return { ok: false, reason: "expired" };
|
|
80
|
+
}
|
|
81
|
+
if (e instanceof joseErrors.JWTClaimValidationFailed) {
|
|
82
|
+
const claim = e.claim;
|
|
83
|
+
if (claim === "iss") return { ok: false, reason: "issuer_mismatch" };
|
|
84
|
+
if (claim === "aud") return { ok: false, reason: "audience_mismatch" };
|
|
85
|
+
if (claim === "iat" || claim === "exp")
|
|
86
|
+
return { ok: false, reason: "timestamp_out_of_window" };
|
|
87
|
+
return { ok: false, reason: "invalid_signature" };
|
|
88
|
+
}
|
|
89
|
+
if (e instanceof joseErrors.JWSSignatureVerificationFailed || e instanceof joseErrors.JWSInvalid || e instanceof joseErrors.JWTInvalid) {
|
|
90
|
+
return { ok: false, reason: "invalid_signature" };
|
|
91
|
+
}
|
|
92
|
+
if (e.code?.startsWith?.("ERR_JWKS")) {
|
|
93
|
+
return { ok: false, reason: "jwks_fetch_failed" };
|
|
94
|
+
}
|
|
95
|
+
if (process.env.VERIFY_DEBUG) {
|
|
96
|
+
console.warn("[verifyRootTaleWebhook] unexpected error:", e);
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, reason: "internal_error" };
|
|
99
|
+
}
|
|
100
|
+
const claimSiteId = payload.site_id;
|
|
101
|
+
if (typeof claimSiteId !== "string" || claimSiteId !== input.expectedSiteId) {
|
|
102
|
+
return { ok: false, reason: "site_id_mismatch" };
|
|
103
|
+
}
|
|
104
|
+
const claimBodyHash = payload.body_sha256;
|
|
105
|
+
if (typeof claimBodyHash !== "string") {
|
|
106
|
+
return { ok: false, reason: "body_hash_mismatch" };
|
|
107
|
+
}
|
|
108
|
+
const actualBodyHash = await sha256Base64Url(input.rawBody);
|
|
109
|
+
if (claimBodyHash !== actualBodyHash) {
|
|
110
|
+
return { ok: false, reason: "body_hash_mismatch" };
|
|
111
|
+
}
|
|
112
|
+
if (input.consumeJti && typeof payload.jti === "string") {
|
|
113
|
+
const seen = await input.consumeJti(
|
|
114
|
+
payload.jti,
|
|
115
|
+
typeof payload.exp === "number" ? payload.exp : 0
|
|
116
|
+
);
|
|
117
|
+
if (seen === "seen") return { ok: false, reason: "replay_seen" };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
deliveryId: typeof payload.jti === "string" ? payload.jti : "",
|
|
122
|
+
siteId: claimSiteId,
|
|
123
|
+
event: typeof payload.event === "string" ? payload.event : "",
|
|
124
|
+
bodyHash: claimBodyHash,
|
|
125
|
+
payload
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export {
|
|
129
|
+
DEFAULT_JWKS_URL,
|
|
130
|
+
__useLocalJwksForTesting,
|
|
131
|
+
verifyRootTaleWebhook
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=webhook.js.map
|
|
@@ -0,0 +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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
"import": "./dist/server.js",
|
|
12
12
|
"default": "./dist/server.js"
|
|
13
13
|
},
|
|
14
|
+
"./webhook": {
|
|
15
|
+
"types": "./dist/webhook.d.ts",
|
|
16
|
+
"import": "./dist/webhook.js",
|
|
17
|
+
"default": "./dist/webhook.js"
|
|
18
|
+
},
|
|
14
19
|
"./package.json": "./package.json"
|
|
15
20
|
},
|
|
16
21
|
"files": [
|
|
@@ -18,11 +23,14 @@
|
|
|
18
23
|
"README.md",
|
|
19
24
|
"CHANGELOG.md"
|
|
20
25
|
],
|
|
21
|
-
"dependencies": {
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"jose": "^5.10.0"
|
|
28
|
+
},
|
|
22
29
|
"devDependencies": {
|
|
23
30
|
"@types/node": "^22.0.0",
|
|
24
31
|
"typescript": "^5.7.0",
|
|
25
|
-
"vitest": "^2.1.0"
|
|
32
|
+
"vitest": "^2.1.0",
|
|
33
|
+
"@roottale/cms-webhooks": "0.0.1"
|
|
26
34
|
},
|
|
27
35
|
"publishConfig": {
|
|
28
36
|
"access": "public"
|