@rscc/common-core 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rscc/common-core
2
2
 
3
- RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키), SSE 파서,
3
+ RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(traceId·재시도·멱등성 키·쿠키 인증/CSRF), SSE 파서,
4
4
  회복탄력성 유틸(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안 유틸(마스킹·로그 리댁션·
5
5
  웹훅 서명·JWT 디코드), 와이어 계약 헬퍼(날짜·목록 쿼리·벌크·업로드·피처 플래그), 한국 도메인 유틸.
6
6
  **프레임워크 무관, 런타임 의존성 0.** 같은 라이브러리의 Java·Python 구현과 동일한 와이어 계약·공유
@@ -12,7 +12,7 @@ RSCC 공통 코어 — `CommonResponse` 봉투 타입과 API 클라이언트(tra
12
12
  npm i @rscc/common-core
13
13
  ```
14
14
 
15
- - **Node >= 18** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
15
+ - **Node >= 20** 또는 모던 브라우저 (전역 `fetch` / `Headers` / `ReadableStream` / `crypto.subtle` 전제)
16
16
  - ESM + CJS 듀얼 빌드, 타입 선언(`.d.ts` / `.d.cts`) 동봉, `sideEffects: false`
17
17
  - 메인 엔트리 `@rscc/common-core` 는 브라우저 안전. Node 전용 기능은 서브패스 `@rscc/common-core/crypto` 로만 제공
18
18
 
@@ -21,7 +21,8 @@ npm i @rscc/common-core
21
21
  | 모듈 | 공개 API | 설명 |
22
22
  |---|---|---|
23
23
  | types | `CommonResponse<T>` · `PageResponse<T>` · `ResultCode` · `FieldErrorDetail` · `ValidationErrorData` · `isValidationErrorData` | 응답 봉투 · 페이지(page 0 시작) · 결과 코드(값은 문자열) · 검증 오류 봉투 타입 가드 |
24
- | apiClient | `createApiClient` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 |
24
+ | apiClient | `createApiClient` · `ApiError` · `ApiClient` · `ApiClientConfig` · `ApiClientRetryOptions` · `ApiResult<T>` · `ApiRequestInfo` / `ApiResponseInfo` / `ApiErrorInfo` | 봉투 언랩 · 401 콜백 · `X-Trace-Id` 발신/에코 · opt-in 재시도/멱등성 키/`strictJson`/관측 훅 · 인증 모드(`getToken` bearer / `credentials`+`csrf` 쿠키) |
25
+ | csrf | `csrfHeaderFor` · `readCookie` · `isUnsafeMethod` · `DEFAULT_CSRF_COOKIE_NAME` · `DEFAULT_CSRF_HEADER_NAME` · `CsrfOptions` | CSRF double-submit 헤더 계산 — 비안전 메서드 + 쿠키 존재 + 같은 출처/`allowedOrigins` 일 때만. SSR 안전 |
25
26
  | sse | `parseSseFrame` · `readSseStream` · `SseFrameEvent` · `SseSource` · `SseCallbacks` | SSE 프레임 파싱 — 청크·멀티바이트 경계 안전, 모르는 키는 skip |
26
27
  | jwt | `decodeJwtPayload` · `getTokenExpiry` · `isTokenExpired` | base64url 디코드만 — **서명 미검증**, 만료 판단은 fail-closed |
27
28
  | masking | `maskSecret` · `maskName` · `maskPhone` · `maskEmail` · `maskCardNumber` | 시크릿(앞4+뒤4)·이름·휴대폰·이메일·카드번호 마스킹 |
@@ -95,6 +96,58 @@ const { data, traceId, response } = await api.requestWithMeta<UserInfo>("/api/v1
95
96
  - `requestWithMeta` 의 `response` 는 **본문 미소비 원본** — 파싱은 `clone()` 사본으로 하므로 `text()`/`json()`
96
97
  재호출이 가능하다.
97
98
 
99
+ ### 인증 모드 — bearer vs 쿠키(jwt-cookie / session) + CSRF
100
+
101
+ 서버가 인증 자격을 어디로 받는지(서버 설정의 인증 모드)에 맞춰 클라이언트 설정을 고른다.
102
+
103
+ | 서버 모드 | 자격 운반 | 클라이언트 설정 |
104
+ |---|---|---|
105
+ | `bearer` (기본) | `Authorization: Bearer <JWT>` | `getToken` |
106
+ | `jwt-cookie` | HttpOnly 쿠키 (JS 가 토큰을 못 봄) | `credentials` + `csrf` |
107
+ | `session` | 서버 세션 쿠키 | `credentials` + `csrf` |
108
+
109
+ ```ts
110
+ // bearer — 토큰을 앱이 보관하고 헤더로 싣는다 (앱·서버 간 호출, 모바일 등)
111
+ const bearerApi = createApiClient({ baseUrl: "https://api.example.com", getToken: () => tokenStore.get() });
112
+
113
+ // jwt-cookie / session — 같은 출처(리버스 프록시로 프론트와 API 를 한 오리진에, 권장)
114
+ const api = createApiClient({ baseUrl: "/api", credentials: "same-origin", csrf: true });
115
+
116
+ // jwt-cookie / session — 같은 사이트 서브도메인 (app.example.com → api.example.com)
117
+ const subdomainApi = createApiClient({
118
+ baseUrl: "https://api.example.com",
119
+ credentials: "include", // 교차 출처로 쿠키 전송
120
+ csrf: { allowedOrigins: ["https://api.example.com"] }, // 이 오리진에만 CSRF 헤더 허용
121
+ });
122
+
123
+ // 로그인 상태 확인은 토큰 만료 계산(isTokenExpired) 대신 서버 엔드포인트로
124
+ const me = await api.request<UserInfo>("/api/v1/users/me").catch(() => null);
125
+ ```
126
+
127
+ - **`credentials`**: fetch `credentials` 기본값. 미지정이면 속성 자체를 넘기지 않는다(fetch 기본 `"same-origin"`).
128
+ 호출별 `init.credentials` 가 우선한다.
129
+ - **`csrf`** (double-submit): 서버가 내려준 HttpOnly 아닌 쿠키 `XSRF-TOKEN` 값을 `X-XSRF-TOKEN` 헤더로 되돌려 보낸다.
130
+ **비안전 메서드**(GET·HEAD·OPTIONS·TRACE 외)이고 쿠키가 있으며 요청 URL 이 **같은 출처**(상대 URL 포함)이거나
131
+ `allowedOrigins` 에 속할 때만 붙인다 — 교차 출처로는 보내지 않는다(토큰 유출 방지). 호출자가 이미 실은 헤더는
132
+ 덮어쓰지 않고, 재시도마다 쿠키를 다시 읽는다(토큰 회전 대응).
133
+ - `CsrfOptions`: `cookieName`(기본 `XSRF-TOKEN`) · `headerName`(기본 `X-XSRF-TOKEN`) · `allowedOrigins`(오리진 정규화 비교,
134
+ 와일드카드 없음) · `readCookie`(원시 쿠키 문자열 공급자 — `document` 가 없는 React Native·테스트용).
135
+ - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않고 오류도 내지 않는다.
136
+ 모듈 로드 시점에 `document`/`location` 에 접근하지 않는다.
137
+ - 쿠키 모드의 웹 클라이언트는 **토큰을 localStorage/sessionStorage 에 저장하지 않는다** — 웹이 다시 헤더 토큰을 쓰면
138
+ HttpOnly 쿠키로 막은 XSS 토큰 탈취 경로가 되살아난다(`getToken` 은 앱·서버 간 호출용).
139
+ - 서브도메인 토폴로지는 서버 쪽도 맞춰야 한다 — CSRF 쿠키 `Domain` 을 상위 도메인으로(프론트 JS 가 읽도록), CORS 가
140
+ 자격 허용(`Access-Control-Allow-Credentials: true` + 명시 오리진)과 `X-XSRF-TOKEN` 요청 헤더를 허용.
141
+
142
+ 헬퍼를 직접 쓸 수도 있다 (axios 인터셉터 등 다른 HTTP 클라이언트 — 서버는 CSRF 토큰을 헤더로만 받는다):
143
+
144
+ ```ts
145
+ import { csrfHeaderFor, readCookie } from "@rscc/common-core";
146
+
147
+ csrfHeaderFor("/api/v1/items", "POST", true); // ["X-XSRF-TOKEN", "<쿠키 값>"] | null
148
+ readCookie("XSRF-TOKEN"); // document.cookie 에서 첫 값 (디코딩 안 함) | null
149
+ ```
150
+
98
151
  ### 서킷 브레이커 — execute 권장
99
152
 
100
153
  ```ts
@@ -146,10 +199,16 @@ const dec = decryptAesGcm(enc, key); // 키 상이·변조 시 CryptoError
146
199
  (못 읽으면 발신 값을 노출 — 서버가 수신 traceId 를 재사용하므로 동일 값).
147
200
  - 1회성 body(`ReadableStream`/`FormData`)는 재전송이 불가해 `retry` 를 지정해도 1회만 실행된다.
148
201
  - JWT 디코더는 **서명을 검증하지 않는다** — 표시·만료 판단 전용. 인가 판단은 반드시 서버에서.
202
+ 쿠키 인증 모드(HttpOnly 쿠키)에서는 JS 가 토큰을 볼 수 없으므로 `/me` 같은 서버 엔드포인트로 상태를 확인할 것.
203
+ - `csrf` 는 교차 출처 요청에 헤더를 붙이지 않는다 — 서브도메인 API 는 `allowedOrigins` 에 명시해야 한다.
204
+ CSRF 쿠키를 읽으려면 그 쿠키가 프론트 페이지 도메인에서 보여야 한다(서버 `Domain` 설정).
149
205
  - `isValidRrn` 은 체크섬을 포함하지 않는다(2020-10 이후 발급분은 체크섬 불성립) — `rrnChecksumOkLegacy` 는 레거시 정합 검사 전용.
150
206
  - `signWebhook`/`verifyWebhook` 은 WebCrypto 기반이라 **async** 다 (Java/Python 은 동기).
151
207
  - 서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시는 **인스턴스(프로세스) 로컬** 상태다 — 분산 공유되지 않는다.
152
208
  - `@rscc/common-core/crypto` 는 `node:crypto` 를 쓰는 **Node 전용** 서브패스 — 브라우저 번들에 포함하지 말 것.
209
+ - 서브패스는 `exports` 맵으로만 노출된다 — TypeScript `moduleResolution` 이 레거시 `node`(`node10`)면
210
+ `@rscc/common-core/crypto` 의 타입을 찾지 못한다. `node16` / `nodenext` / `bundler` 를 사용할 것
211
+ (메인 엔트리 `@rscc/common-core` 는 모든 모드에서 해석된다).
153
212
 
154
213
  ## 관련 패키지
155
214
 
package/dist/index.cjs CHANGED
@@ -23,6 +23,8 @@ __export(index_exports, {
23
23
  ApiError: () => ApiError,
24
24
  BulkheadFullError: () => BulkheadFullError,
25
25
  CircuitOpenError: () => CircuitOpenError,
26
+ DEFAULT_CSRF_COOKIE_NAME: () => DEFAULT_CSRF_COOKIE_NAME,
27
+ DEFAULT_CSRF_HEADER_NAME: () => DEFAULT_CSRF_HEADER_NAME,
26
28
  ResultCode: () => ResultCode,
27
29
  WEBHOOK_SIGNATURE_HEADER: () => WEBHOOK_SIGNATURE_HEADER,
28
30
  abbreviateAmount: () => abbreviateAmount,
@@ -42,6 +44,7 @@ __export(index_exports, {
42
44
  createFeatureFlags: () => createFeatureFlags,
43
45
  createTokenBucket: () => createTokenBucket,
44
46
  createTtlCache: () => createTtlCache,
47
+ csrfHeaderFor: () => csrfHeaderFor,
45
48
  decodeJwtPayload: () => decodeJwtPayload,
46
49
  decomposeHangul: () => decomposeHangul,
47
50
  formatPhoneNumber: () => formatPhoneNumber,
@@ -52,6 +55,7 @@ __export(index_exports, {
52
55
  isForeignerRrn: () => isForeignerRrn,
53
56
  isRetryableStatus: () => isRetryableStatus,
54
57
  isTokenExpired: () => isTokenExpired,
58
+ isUnsafeMethod: () => isUnsafeMethod,
55
59
  isValidBusinessNumber: () => isValidBusinessNumber,
56
60
  isValidCorporateNumber: () => isValidCorporateNumber,
57
61
  isValidRrn: () => isValidRrn,
@@ -71,6 +75,7 @@ __export(index_exports, {
71
75
  parseSseFrame: () => parseSseFrame,
72
76
  parseWireDateTime: () => parseWireDateTime,
73
77
  pickJosa: () => pickJosa,
78
+ readCookie: () => readCookie,
74
79
  readSseStream: () => readSseStream,
75
80
  retry: () => retry,
76
81
  rrnBirthDate: () => rrnBirthDate,
@@ -112,6 +117,93 @@ var ResultCode = {
112
117
  INTERNAL_SERVER_ERROR: "500"
113
118
  };
114
119
 
120
+ // src/csrf.ts
121
+ var DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
122
+ var DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
123
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
124
+ var SENTINEL_ORIGIN = "http://rscc-csrf.invalid";
125
+ var ABSOLUTE_URL = /^([a-zA-Z][a-zA-Z\d+.-]*):\/\/([^/?#\\]*)/;
126
+ var HAS_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
127
+ var PROTOCOL_RELATIVE = /^[\\/]{2}/;
128
+ var DEFAULT_PORTS = { http: "80", https: "443", ws: "80", wss: "443" };
129
+ function readCookie(name, cookieString) {
130
+ const source = cookieString ?? documentCookie();
131
+ if (!source) return null;
132
+ for (const part of source.split(";")) {
133
+ const pair = part.trim();
134
+ const eq = pair.indexOf("=");
135
+ if (eq < 0) continue;
136
+ if (pair.slice(0, eq) === name) return pair.slice(eq + 1);
137
+ }
138
+ return null;
139
+ }
140
+ function isUnsafeMethod(method) {
141
+ return !SAFE_METHODS.has((method || "GET").toUpperCase());
142
+ }
143
+ function csrfHeaderFor(url, method, options) {
144
+ if (!isUnsafeMethod(method)) return null;
145
+ const opts = options === true || options === void 0 ? {} : options;
146
+ const cookieString = opts.readCookie ? opts.readCookie() : documentCookie();
147
+ if (!cookieString) return null;
148
+ const token = readCookie(opts.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, cookieString);
149
+ if (!token) return null;
150
+ if (!isAllowedTarget(url, opts.allowedOrigins)) return null;
151
+ return [opts.headerName ?? DEFAULT_CSRF_HEADER_NAME, token];
152
+ }
153
+ function documentCookie() {
154
+ if (typeof document === "undefined") return null;
155
+ try {
156
+ return typeof document.cookie === "string" ? document.cookie : null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ function currentLocation() {
162
+ if (typeof location === "undefined") return null;
163
+ try {
164
+ const { href, origin } = location;
165
+ return typeof href === "string" && typeof origin === "string" ? { href, origin } : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+ function isAllowedTarget(url, allowedOrigins) {
171
+ const loc = currentLocation();
172
+ const base = loc ? loc.href : SENTINEL_ORIGIN;
173
+ const selfOrigin = loc ? loc.origin : SENTINEL_ORIGIN;
174
+ const target = originOf(url, base, selfOrigin);
175
+ if (target === null || target === "null") return false;
176
+ if (selfOrigin !== "null" && target === selfOrigin) return true;
177
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
178
+ return allowedOrigins.some((entry) => originOf(entry, base, selfOrigin) === target);
179
+ }
180
+ function originOf(url, base, baseOrigin) {
181
+ try {
182
+ if (typeof URL === "function") {
183
+ const origin = new URL(url, base).origin;
184
+ if (typeof origin === "string") return origin;
185
+ }
186
+ } catch {
187
+ }
188
+ return looseOriginOf(url, baseOrigin);
189
+ }
190
+ function looseOriginOf(url, baseOrigin) {
191
+ const trimmed = url.trim();
192
+ const m = ABSOLUTE_URL.exec(trimmed);
193
+ if (m) {
194
+ const scheme = m[1].toLowerCase();
195
+ const authority = m[2];
196
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
197
+ const hp = /^(.*?)(?::(\d*))?$/.exec(hostPort);
198
+ const host = hp?.[1] ?? "";
199
+ const port = hp?.[2] ?? "";
200
+ if (!host) return null;
201
+ return port === "" || port === DEFAULT_PORTS[scheme] ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
202
+ }
203
+ if (PROTOCOL_RELATIVE.test(trimmed) || HAS_SCHEME.test(trimmed)) return null;
204
+ return baseOrigin;
205
+ }
206
+
115
207
  // src/idempotency.ts
116
208
  var FALLBACK_GROUPS = [8, 4, 4, 4, 12];
117
209
  function generateIdempotencyKey() {
@@ -255,6 +347,8 @@ function createApiClient(config) {
255
347
  async function requestWithMeta(path, init = {}) {
256
348
  const sentTraceId = generateTraceId();
257
349
  const method = (init.method ?? "GET").toUpperCase();
350
+ const url = joinUrl(config.baseUrl, path);
351
+ const credentials = init.credentials ?? config.credentials;
258
352
  const idempotencyHeader = config.idempotency?.header ?? "Idempotency-Key";
259
353
  const idempotencyKey = config.idempotency && (config.idempotency.methods ?? DEFAULT_IDEMPOTENCY_METHODS).includes(method) ? generateIdempotencyKey() : null;
260
354
  const attemptOnce = async () => {
@@ -270,8 +364,16 @@ function createApiClient(config) {
270
364
  const token = config.getToken();
271
365
  if (token) headers.set("Authorization", `Bearer ${token}`);
272
366
  }
367
+ if (config.csrf) {
368
+ const csrfHeader = csrfHeaderFor(url, method, config.csrf);
369
+ if (csrfHeader && !headers.has(csrfHeader[0])) headers.set(csrfHeader[0], csrfHeader[1]);
370
+ }
273
371
  const fetchFn = config.fetchImpl ?? globalThis.fetch;
274
- const response = await fetchFn(joinUrl(config.baseUrl, path), { ...init, headers });
372
+ const response = await fetchFn(url, {
373
+ ...init,
374
+ ...credentials !== void 0 ? { credentials } : {},
375
+ headers
376
+ });
275
377
  const traceId = response.headers.get(traceIdHeader) ?? sentTraceId;
276
378
  let raw = null;
277
379
  try {
@@ -1779,6 +1881,8 @@ function matchesHangul(query, target) {
1779
1881
  ApiError,
1780
1882
  BulkheadFullError,
1781
1883
  CircuitOpenError,
1884
+ DEFAULT_CSRF_COOKIE_NAME,
1885
+ DEFAULT_CSRF_HEADER_NAME,
1782
1886
  ResultCode,
1783
1887
  WEBHOOK_SIGNATURE_HEADER,
1784
1888
  abbreviateAmount,
@@ -1798,6 +1902,7 @@ function matchesHangul(query, target) {
1798
1902
  createFeatureFlags,
1799
1903
  createTokenBucket,
1800
1904
  createTtlCache,
1905
+ csrfHeaderFor,
1801
1906
  decodeJwtPayload,
1802
1907
  decomposeHangul,
1803
1908
  formatPhoneNumber,
@@ -1808,6 +1913,7 @@ function matchesHangul(query, target) {
1808
1913
  isForeignerRrn,
1809
1914
  isRetryableStatus,
1810
1915
  isTokenExpired,
1916
+ isUnsafeMethod,
1811
1917
  isValidBusinessNumber,
1812
1918
  isValidCorporateNumber,
1813
1919
  isValidRrn,
@@ -1827,6 +1933,7 @@ function matchesHangul(query, target) {
1827
1933
  parseSseFrame,
1828
1934
  parseWireDateTime,
1829
1935
  pickJosa,
1936
+ readCookie,
1830
1937
  readSseStream,
1831
1938
  retry,
1832
1939
  rrnBirthDate,
package/dist/index.d.cts CHANGED
@@ -75,6 +75,79 @@ declare const ResultCode: {
75
75
  /** ResultCode 코드 값의 유니온 타입 ("200" | "400" | ...). */
76
76
  type ResultCode = (typeof ResultCode)[keyof typeof ResultCode];
77
77
 
78
+ /**
79
+ * CSRF double-submit 클라이언트 헬퍼 — contracts/session-auth.md §4·§5 (수동 동기화).
80
+ *
81
+ * 쿠키 운반 인증 모드(`jwt-cookie` / `session`)에서 서버는 CSRF 토큰을 HttpOnly 가 아닌 쿠키
82
+ * (`XSRF-TOKEN`)로 발급하고, 클라이언트는 **비안전 메서드** 요청마다 그 값을 헤더(`X-XSRF-TOKEN`)에
83
+ * 되돌려 보낸다. 서버는 쿠키 값과 헤더 값의 동등성만 확인한다.
84
+ *
85
+ * 부착 규칙 (골든 벡터 XC-01~08):
86
+ * - 비안전 메서드(GET·HEAD·OPTIONS·TRACE 외)일 때만.
87
+ * - CSRF 쿠키가 있고 값이 비어 있지 않을 때만 (없으면 부착 안 함 — 오류 없음).
88
+ * - 요청 URL 이 **같은 출처**(상대 URL 포함)이거나 명시한 `allowedOrigins` 에 속할 때만 —
89
+ * 교차 출처로는 보내지 않는다(토큰 유출 방지).
90
+ * - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않는다.
91
+ *
92
+ * SSR 안전: 모듈 로드 시점에 `document`/`location` 에 접근하지 않는다 (호출 시점에만 존재 확인).
93
+ */
94
+ /** CSRF 토큰 쿠키 기본 이름 (contracts/session-auth.md §2.1). */
95
+ declare const DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
96
+ /** CSRF 요청 헤더 기본 이름 — Spring `CookieCsrfTokenRepository` 관례 (contracts/session-auth.md §2.1). */
97
+ declare const DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
98
+ /** CSRF 헤더 자동 부착 옵션 (apiClient `csrf` / useSse `csrf`). */
99
+ interface CsrfOptions {
100
+ /** CSRF 토큰 쿠키 이름. 기본 {@link DEFAULT_CSRF_COOKIE_NAME} (`"XSRF-TOKEN"`) — 서버 설정과 일치시킬 것. */
101
+ cookieName?: string;
102
+ /** CSRF 요청 헤더 이름. 기본 {@link DEFAULT_CSRF_HEADER_NAME} (`"X-XSRF-TOKEN"`). */
103
+ headerName?: string;
104
+ /**
105
+ * 같은 출처 외에 헤더를 보내도 되는 **오리진** 목록 (예: `["https://api.example.com"]`) —
106
+ * 같은 사이트 서브도메인 토폴로지(`app.example.com` → `api.example.com`, contracts §8)용.
107
+ * 항목은 `scheme://host[:port]` 로 정규화해 비교한다(경로·끝 슬래시·대소문자·기본 포트 무관).
108
+ * 와일드카드는 지원하지 않는다. 기본: 없음 (같은 출처만).
109
+ */
110
+ allowedOrigins?: readonly string[];
111
+ /**
112
+ * **원시 쿠키 문자열**(`"a=1; XSRF-TOKEN=abc"` 형태) 공급자 — `document.cookie` 대신 쓴다
113
+ * (React Native·테스트 등 `document` 가 없는 환경). null/undefined 반환은 쿠키 없음.
114
+ * 기본: `document` 가 있으면 `document.cookie`, 없으면 쿠키 없음.
115
+ */
116
+ readCookie?: () => string | null | undefined;
117
+ }
118
+ /**
119
+ * 원시 쿠키 문자열에서 `name` 쿠키 값을 읽는다 (contracts/session-auth.md §2.3).
120
+ *
121
+ * - `;` 로 나누고 각 조각의 앞뒤 공백을 제거한 뒤 **첫 `=`** 기준으로 이름·값을 가른다.
122
+ * - 이름은 **대소문자 구분 정확 일치**, 같은 이름이 여럿이면 **첫 값**(XC-08).
123
+ * - 값은 디코딩하지 않는다(`decodeURIComponent` 미적용). 빈 값(`name=`)은 `""` 를 반환한다.
124
+ * - `cookieString` 생략 시 `document` 가 있으면 `document.cookie` 를 쓰고, 없으면(SSR·Node) null.
125
+ * `document.cookie` 접근 자체가 실패하는 환경(샌드박스 iframe 등)도 null.
126
+ *
127
+ * @returns 쿠키 값, 해당 쿠키가 없으면 null.
128
+ */
129
+ declare function readCookie(name: string, cookieString?: string): string | null;
130
+ /**
131
+ * 비안전 메서드 여부 — GET·HEAD·OPTIONS·TRACE 외 전부 true (대소문자 무관).
132
+ * `method` 생략(또는 빈 문자열)은 fetch 기본값 GET 으로 보아 false.
133
+ */
134
+ declare function isUnsafeMethod(method?: string): boolean;
135
+ /**
136
+ * 요청 하나에 붙일 CSRF 헤더를 계산한다 — 붙여야 하면 `[헤더명, 쿠키값]`, 아니면 null.
137
+ *
138
+ * 판정 순서: 비안전 메서드? → CSRF 쿠키 있음(빈 값 제외)? → 같은 출처(상대 URL, 또는 `location` 이
139
+ * 있을 때 그 오리진과 같은 절대 URL) 또는 `allowedOrigins` 소속? → 부착 (골든 벡터 XC-01~05·07·08).
140
+ * 호출자가 이미 같은 헤더를 실었는지(XC-06)는 호출 측(apiClient·useSse)이 확인한다.
141
+ *
142
+ * 오류를 던지지 않는다 — `document`·`readCookie` 가 모두 없으면(SSR) null. URL 을 해석할 수 없으면
143
+ * 교차 출처로 간주해 null (토큰 유출 방지 쪽으로 실패).
144
+ *
145
+ * @param url 요청 URL (상대/절대). 상대 URL 은 같은 출처로 본다.
146
+ * @param method HTTP 메서드. 생략 시 GET.
147
+ * @param options `true` 또는 생략 = 기본값, 객체 = {@link CsrfOptions}.
148
+ */
149
+ declare function csrfHeaderFor(url: string, method: string | undefined, options?: true | CsrfOptions): [name: string, value: string] | null;
150
+
78
151
  /**
79
152
  * 아웃바운드 회복탄력성 — 지수 백오프 + full jitter 재시도. 런타임 의존성 0(native 만).
80
153
  *
@@ -229,8 +302,34 @@ interface ApiClientConfig {
229
302
  * 상대 baseUrl("/api")도 지원.
230
303
  */
231
304
  baseUrl: string;
232
- /** 토큰 공급자. 지정 시 반환값이 truthy 면 `Authorization: Bearer <token>` 부착. */
305
+ /**
306
+ * 토큰 공급자 (`bearer` 모드). 지정 시 반환값이 truthy 면 `Authorization: Bearer <token>` 부착
307
+ * (호출자가 Authorization 을 이미 실었으면 덮어쓰지 않음). 쿠키 모드(`jwt-cookie`/`session`)의
308
+ * 웹 클라이언트는 쓰지 않는다 — `credentials`·`csrf` 참조.
309
+ */
233
310
  getToken?: () => string | null | undefined;
311
+ /**
312
+ * fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
313
+ * 같은 출처 API 는 `"same-origin"`(fetch 기본값과 같음), 서브도메인 등 교차 출처 API 는 `"include"`.
314
+ *
315
+ * - 미지정 = fetch 에 `credentials` 속성 자체를 넘기지 않는다 (현행 동작 — fetch 기본값 `"same-origin"`).
316
+ * - 호출별 `init.credentials` 가 있으면 그 값이 우선한다.
317
+ */
318
+ credentials?: RequestCredentials;
319
+ /**
320
+ * CSRF double-submit 헤더 자동 부착 (contracts/session-auth.md §4·§5, 골든 벡터 XC-01~08).
321
+ * `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만), 객체 = {@link CsrfOptions}
322
+ * (쿠키·헤더 이름, 서브도메인 API 용 `allowedOrigins`, `document` 없는 환경용 `readCookie`).
323
+ * 미지정/false = 부착 안 함 (현행 동작).
324
+ *
325
+ * - **비안전 메서드**(GET·HEAD·OPTIONS·TRACE 외)이고 CSRF 쿠키가 있으며 요청 URL(baseUrl 조인 후)이
326
+ * 같은 출처(상대 URL 포함)이거나 `allowedOrigins` 에 속할 때만 붙인다 — 교차 출처로는 보내지 않는다.
327
+ * - 호출자가 이미 같은 이름의 헤더를 실었으면 덮어쓰지 않는다 (호출자 우선, XC-06).
328
+ * - 쿠키는 **시도마다 다시 읽는다** — 재시도 사이에 서버가 토큰을 회전해도 새 값을 싣는다.
329
+ * - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않는다(오류 없음).
330
+ * - `getToken`(bearer)과 독립적으로 동작한다.
331
+ */
332
+ csrf?: boolean | CsrfOptions;
234
333
  /** 401 수신 시 throw 직전에 호출되는 콜백 (로그아웃/리다이렉트 등 소비자 정책 주입). */
235
334
  onUnauthorized?: (error: ApiError) => void;
236
335
  /** fetch 구현체 주입 (테스트용). 기본 globalThis.fetch. */
@@ -324,10 +423,13 @@ interface ApiClient {
324
423
  * (data 부재 시 undefined). 비봉투 JSON 은 그대로 반환.
325
424
  * - 실패 봉투 / HTTP 에러는 code·message·traceId 를 담은 ApiError 를 throw.
326
425
  * - 401 은 throw 직전에 onUnauthorized 콜백을 호출한다.
426
+ * - 인증 운반 (contracts/session-auth.md): `bearer` = `getToken` 으로 Authorization 헤더,
427
+ * `jwt-cookie`/`session` = `credentials` + `csrf` (쿠키는 브라우저가 싣고 CSRF 헤더만 자동 부착).
327
428
  * - retry 지정 시 요청을 core retry() 로 감싼다 ({@link ApiClientRetryOptions} —
328
429
  * 멱등 메서드 기본, Retry-After 하한, 시도 간 동일 traceId).
329
430
  *
330
- * 저장소 접근·경로·이벤트명 하드코딩 없음 — 전부 config 주입.
431
+ * 저장소 접근·경로·이벤트명 하드코딩 없음 — 전부 config 주입 (opt-in `csrf` 의 CSRF 쿠키 읽기만 예외 —
432
+ * 기본 `document.cookie`, `readCookie` 로 교체 가능).
331
433
  */
332
434
  declare function createApiClient(config: ApiClientConfig): ApiClient;
333
435
 
@@ -847,7 +949,7 @@ declare function generateIdempotencyKey(): string;
847
949
  * 시맨틱(입력→판정)은 3언어 동일하며 반환 형태만 다르다."
848
950
  *
849
951
  * Node 전용 API(`timingSafeEqual` 등) 미사용 — 브라우저/Node 공용
850
- * (`globalThis.crypto.subtle`, Node 18+ / 모던 브라우저).
952
+ * (`globalThis.crypto.subtle`, Node 20+ / 모던 브라우저).
851
953
  */
852
954
  /** 웹훅 서명 헤더명 기본값 (contracts/webhook-signature.md). */
853
955
  declare const WEBHOOK_SIGNATURE_HEADER = "X-Rscc-Signature";
@@ -1347,4 +1449,4 @@ declare function composeHangul(s: string): string;
1347
1449
  */
1348
1450
  declare function matchesHangul(query: string, target: string): boolean;
1349
1451
 
1350
- export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
1452
+ export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type CsrfOptions, DEFAULT_CSRF_COOKIE_NAME, DEFAULT_CSRF_HEADER_NAME, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, csrfHeaderFor, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isUnsafeMethod, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readCookie, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
package/dist/index.d.ts CHANGED
@@ -75,6 +75,79 @@ declare const ResultCode: {
75
75
  /** ResultCode 코드 값의 유니온 타입 ("200" | "400" | ...). */
76
76
  type ResultCode = (typeof ResultCode)[keyof typeof ResultCode];
77
77
 
78
+ /**
79
+ * CSRF double-submit 클라이언트 헬퍼 — contracts/session-auth.md §4·§5 (수동 동기화).
80
+ *
81
+ * 쿠키 운반 인증 모드(`jwt-cookie` / `session`)에서 서버는 CSRF 토큰을 HttpOnly 가 아닌 쿠키
82
+ * (`XSRF-TOKEN`)로 발급하고, 클라이언트는 **비안전 메서드** 요청마다 그 값을 헤더(`X-XSRF-TOKEN`)에
83
+ * 되돌려 보낸다. 서버는 쿠키 값과 헤더 값의 동등성만 확인한다.
84
+ *
85
+ * 부착 규칙 (골든 벡터 XC-01~08):
86
+ * - 비안전 메서드(GET·HEAD·OPTIONS·TRACE 외)일 때만.
87
+ * - CSRF 쿠키가 있고 값이 비어 있지 않을 때만 (없으면 부착 안 함 — 오류 없음).
88
+ * - 요청 URL 이 **같은 출처**(상대 URL 포함)이거나 명시한 `allowedOrigins` 에 속할 때만 —
89
+ * 교차 출처로는 보내지 않는다(토큰 유출 방지).
90
+ * - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않는다.
91
+ *
92
+ * SSR 안전: 모듈 로드 시점에 `document`/`location` 에 접근하지 않는다 (호출 시점에만 존재 확인).
93
+ */
94
+ /** CSRF 토큰 쿠키 기본 이름 (contracts/session-auth.md §2.1). */
95
+ declare const DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
96
+ /** CSRF 요청 헤더 기본 이름 — Spring `CookieCsrfTokenRepository` 관례 (contracts/session-auth.md §2.1). */
97
+ declare const DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
98
+ /** CSRF 헤더 자동 부착 옵션 (apiClient `csrf` / useSse `csrf`). */
99
+ interface CsrfOptions {
100
+ /** CSRF 토큰 쿠키 이름. 기본 {@link DEFAULT_CSRF_COOKIE_NAME} (`"XSRF-TOKEN"`) — 서버 설정과 일치시킬 것. */
101
+ cookieName?: string;
102
+ /** CSRF 요청 헤더 이름. 기본 {@link DEFAULT_CSRF_HEADER_NAME} (`"X-XSRF-TOKEN"`). */
103
+ headerName?: string;
104
+ /**
105
+ * 같은 출처 외에 헤더를 보내도 되는 **오리진** 목록 (예: `["https://api.example.com"]`) —
106
+ * 같은 사이트 서브도메인 토폴로지(`app.example.com` → `api.example.com`, contracts §8)용.
107
+ * 항목은 `scheme://host[:port]` 로 정규화해 비교한다(경로·끝 슬래시·대소문자·기본 포트 무관).
108
+ * 와일드카드는 지원하지 않는다. 기본: 없음 (같은 출처만).
109
+ */
110
+ allowedOrigins?: readonly string[];
111
+ /**
112
+ * **원시 쿠키 문자열**(`"a=1; XSRF-TOKEN=abc"` 형태) 공급자 — `document.cookie` 대신 쓴다
113
+ * (React Native·테스트 등 `document` 가 없는 환경). null/undefined 반환은 쿠키 없음.
114
+ * 기본: `document` 가 있으면 `document.cookie`, 없으면 쿠키 없음.
115
+ */
116
+ readCookie?: () => string | null | undefined;
117
+ }
118
+ /**
119
+ * 원시 쿠키 문자열에서 `name` 쿠키 값을 읽는다 (contracts/session-auth.md §2.3).
120
+ *
121
+ * - `;` 로 나누고 각 조각의 앞뒤 공백을 제거한 뒤 **첫 `=`** 기준으로 이름·값을 가른다.
122
+ * - 이름은 **대소문자 구분 정확 일치**, 같은 이름이 여럿이면 **첫 값**(XC-08).
123
+ * - 값은 디코딩하지 않는다(`decodeURIComponent` 미적용). 빈 값(`name=`)은 `""` 를 반환한다.
124
+ * - `cookieString` 생략 시 `document` 가 있으면 `document.cookie` 를 쓰고, 없으면(SSR·Node) null.
125
+ * `document.cookie` 접근 자체가 실패하는 환경(샌드박스 iframe 등)도 null.
126
+ *
127
+ * @returns 쿠키 값, 해당 쿠키가 없으면 null.
128
+ */
129
+ declare function readCookie(name: string, cookieString?: string): string | null;
130
+ /**
131
+ * 비안전 메서드 여부 — GET·HEAD·OPTIONS·TRACE 외 전부 true (대소문자 무관).
132
+ * `method` 생략(또는 빈 문자열)은 fetch 기본값 GET 으로 보아 false.
133
+ */
134
+ declare function isUnsafeMethod(method?: string): boolean;
135
+ /**
136
+ * 요청 하나에 붙일 CSRF 헤더를 계산한다 — 붙여야 하면 `[헤더명, 쿠키값]`, 아니면 null.
137
+ *
138
+ * 판정 순서: 비안전 메서드? → CSRF 쿠키 있음(빈 값 제외)? → 같은 출처(상대 URL, 또는 `location` 이
139
+ * 있을 때 그 오리진과 같은 절대 URL) 또는 `allowedOrigins` 소속? → 부착 (골든 벡터 XC-01~05·07·08).
140
+ * 호출자가 이미 같은 헤더를 실었는지(XC-06)는 호출 측(apiClient·useSse)이 확인한다.
141
+ *
142
+ * 오류를 던지지 않는다 — `document`·`readCookie` 가 모두 없으면(SSR) null. URL 을 해석할 수 없으면
143
+ * 교차 출처로 간주해 null (토큰 유출 방지 쪽으로 실패).
144
+ *
145
+ * @param url 요청 URL (상대/절대). 상대 URL 은 같은 출처로 본다.
146
+ * @param method HTTP 메서드. 생략 시 GET.
147
+ * @param options `true` 또는 생략 = 기본값, 객체 = {@link CsrfOptions}.
148
+ */
149
+ declare function csrfHeaderFor(url: string, method: string | undefined, options?: true | CsrfOptions): [name: string, value: string] | null;
150
+
78
151
  /**
79
152
  * 아웃바운드 회복탄력성 — 지수 백오프 + full jitter 재시도. 런타임 의존성 0(native 만).
80
153
  *
@@ -229,8 +302,34 @@ interface ApiClientConfig {
229
302
  * 상대 baseUrl("/api")도 지원.
230
303
  */
231
304
  baseUrl: string;
232
- /** 토큰 공급자. 지정 시 반환값이 truthy 면 `Authorization: Bearer <token>` 부착. */
305
+ /**
306
+ * 토큰 공급자 (`bearer` 모드). 지정 시 반환값이 truthy 면 `Authorization: Bearer <token>` 부착
307
+ * (호출자가 Authorization 을 이미 실었으면 덮어쓰지 않음). 쿠키 모드(`jwt-cookie`/`session`)의
308
+ * 웹 클라이언트는 쓰지 않는다 — `credentials`·`csrf` 참조.
309
+ */
233
310
  getToken?: () => string | null | undefined;
311
+ /**
312
+ * fetch `credentials` 기본값 — 쿠키 운반 인증 모드(`jwt-cookie`/`session`, contracts/session-auth.md §5)용.
313
+ * 같은 출처 API 는 `"same-origin"`(fetch 기본값과 같음), 서브도메인 등 교차 출처 API 는 `"include"`.
314
+ *
315
+ * - 미지정 = fetch 에 `credentials` 속성 자체를 넘기지 않는다 (현행 동작 — fetch 기본값 `"same-origin"`).
316
+ * - 호출별 `init.credentials` 가 있으면 그 값이 우선한다.
317
+ */
318
+ credentials?: RequestCredentials;
319
+ /**
320
+ * CSRF double-submit 헤더 자동 부착 (contracts/session-auth.md §4·§5, 골든 벡터 XC-01~08).
321
+ * `true` = 기본값(쿠키 `XSRF-TOKEN` → 헤더 `X-XSRF-TOKEN`, 같은 출처만), 객체 = {@link CsrfOptions}
322
+ * (쿠키·헤더 이름, 서브도메인 API 용 `allowedOrigins`, `document` 없는 환경용 `readCookie`).
323
+ * 미지정/false = 부착 안 함 (현행 동작).
324
+ *
325
+ * - **비안전 메서드**(GET·HEAD·OPTIONS·TRACE 외)이고 CSRF 쿠키가 있으며 요청 URL(baseUrl 조인 후)이
326
+ * 같은 출처(상대 URL 포함)이거나 `allowedOrigins` 에 속할 때만 붙인다 — 교차 출처로는 보내지 않는다.
327
+ * - 호출자가 이미 같은 이름의 헤더를 실었으면 덮어쓰지 않는다 (호출자 우선, XC-06).
328
+ * - 쿠키는 **시도마다 다시 읽는다** — 재시도 사이에 서버가 토큰을 회전해도 새 값을 싣는다.
329
+ * - `document` 가 없는 환경(SSR·Node)에서는 `readCookie` 주입이 없으면 아무것도 붙이지 않는다(오류 없음).
330
+ * - `getToken`(bearer)과 독립적으로 동작한다.
331
+ */
332
+ csrf?: boolean | CsrfOptions;
234
333
  /** 401 수신 시 throw 직전에 호출되는 콜백 (로그아웃/리다이렉트 등 소비자 정책 주입). */
235
334
  onUnauthorized?: (error: ApiError) => void;
236
335
  /** fetch 구현체 주입 (테스트용). 기본 globalThis.fetch. */
@@ -324,10 +423,13 @@ interface ApiClient {
324
423
  * (data 부재 시 undefined). 비봉투 JSON 은 그대로 반환.
325
424
  * - 실패 봉투 / HTTP 에러는 code·message·traceId 를 담은 ApiError 를 throw.
326
425
  * - 401 은 throw 직전에 onUnauthorized 콜백을 호출한다.
426
+ * - 인증 운반 (contracts/session-auth.md): `bearer` = `getToken` 으로 Authorization 헤더,
427
+ * `jwt-cookie`/`session` = `credentials` + `csrf` (쿠키는 브라우저가 싣고 CSRF 헤더만 자동 부착).
327
428
  * - retry 지정 시 요청을 core retry() 로 감싼다 ({@link ApiClientRetryOptions} —
328
429
  * 멱등 메서드 기본, Retry-After 하한, 시도 간 동일 traceId).
329
430
  *
330
- * 저장소 접근·경로·이벤트명 하드코딩 없음 — 전부 config 주입.
431
+ * 저장소 접근·경로·이벤트명 하드코딩 없음 — 전부 config 주입 (opt-in `csrf` 의 CSRF 쿠키 읽기만 예외 —
432
+ * 기본 `document.cookie`, `readCookie` 로 교체 가능).
331
433
  */
332
434
  declare function createApiClient(config: ApiClientConfig): ApiClient;
333
435
 
@@ -847,7 +949,7 @@ declare function generateIdempotencyKey(): string;
847
949
  * 시맨틱(입력→판정)은 3언어 동일하며 반환 형태만 다르다."
848
950
  *
849
951
  * Node 전용 API(`timingSafeEqual` 등) 미사용 — 브라우저/Node 공용
850
- * (`globalThis.crypto.subtle`, Node 18+ / 모던 브라우저).
952
+ * (`globalThis.crypto.subtle`, Node 20+ / 모던 브라우저).
851
953
  */
852
954
  /** 웹훅 서명 헤더명 기본값 (contracts/webhook-signature.md). */
853
955
  declare const WEBHOOK_SIGNATURE_HEADER = "X-Rscc-Signature";
@@ -1347,4 +1449,4 @@ declare function composeHangul(s: string): string;
1347
1449
  */
1348
1450
  declare function matchesHangul(query: string, target: string): boolean;
1349
1451
 
1350
- export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
1452
+ export { type ApiClient, type ApiClientConfig, type ApiClientRetryOptions, ApiError, type ApiErrorInfo, type ApiRequestInfo, type ApiResponseInfo, type ApiResult, type BulkResult, type BulkResultBuilder, type BulkResultItem, type Bulkhead, BulkheadFullError, type BulkheadOptions, type BusinessDays, type BusinessDaysOptions, type CircuitBreaker, type CircuitBreakerOptions, CircuitOpenError, type CircuitState, type CommonResponse, type CsrfOptions, DEFAULT_CSRF_COOKIE_NAME, DEFAULT_CSRF_HEADER_NAME, type FeatureFlagReader, type FieldErrorDetail, type FileKind, type JosaPair, type ListQueryOptions, type PageResponse, type PhoneType, ResultCode, type RetryOptions, type SortParam, type SseCallbacks, type SseFrameEvent, type SseSource, type TokenBucket, type TokenBucketOptions, type TtlCache, type TtlCacheOptions, type UploadValidationResult, type ValidationErrorData, WEBHOOK_SIGNATURE_HEADER, abbreviateAmount, ageByYear, ageInsurance, ageMan, attachJosa, buildListQuery, bulkFailures, classifyPhoneNumber, composeHangul, createApiClient, createBulkResultBuilder, createBulkhead, createBusinessDays, createCircuitBreaker, createFeatureFlags, createTokenBucket, createTtlCache, csrfHeaderFor, decodeJwtPayload, decomposeHangul, formatPhoneNumber, generateIdempotencyKey, getTokenExpiry, isBulkResult, isChosungQuery, isForeignerRrn, isRetryableStatus, isTokenExpired, isUnsafeMethod, isValidBusinessNumber, isValidCorporateNumber, isValidRrn, isValidationErrorData, kindsForExtension, maskCardNumber, maskEmail, maskName, maskPhone, maskSecret, matchesHangul, normalizeBusinessNumber, normalizePhoneNumber, normalizeRrn, parseFlag, parseRetryAfterMs, parseSseFrame, parseWireDateTime, pickJosa, readCookie, readSseStream, retry, rrnBirthDate, rrnChecksumOkLegacy, sanitizeLogValue, signWebhook, sniffFile, stripZone, toChosung, toE164, toFormalNotation, toKoreanWords, toWireDate, toWireDateTime, validateUpload, verifyWebhook };
package/dist/index.js CHANGED
@@ -20,6 +20,93 @@ var ResultCode = {
20
20
  INTERNAL_SERVER_ERROR: "500"
21
21
  };
22
22
 
23
+ // src/csrf.ts
24
+ var DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
25
+ var DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
26
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
27
+ var SENTINEL_ORIGIN = "http://rscc-csrf.invalid";
28
+ var ABSOLUTE_URL = /^([a-zA-Z][a-zA-Z\d+.-]*):\/\/([^/?#\\]*)/;
29
+ var HAS_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
30
+ var PROTOCOL_RELATIVE = /^[\\/]{2}/;
31
+ var DEFAULT_PORTS = { http: "80", https: "443", ws: "80", wss: "443" };
32
+ function readCookie(name, cookieString) {
33
+ const source = cookieString ?? documentCookie();
34
+ if (!source) return null;
35
+ for (const part of source.split(";")) {
36
+ const pair = part.trim();
37
+ const eq = pair.indexOf("=");
38
+ if (eq < 0) continue;
39
+ if (pair.slice(0, eq) === name) return pair.slice(eq + 1);
40
+ }
41
+ return null;
42
+ }
43
+ function isUnsafeMethod(method) {
44
+ return !SAFE_METHODS.has((method || "GET").toUpperCase());
45
+ }
46
+ function csrfHeaderFor(url, method, options) {
47
+ if (!isUnsafeMethod(method)) return null;
48
+ const opts = options === true || options === void 0 ? {} : options;
49
+ const cookieString = opts.readCookie ? opts.readCookie() : documentCookie();
50
+ if (!cookieString) return null;
51
+ const token = readCookie(opts.cookieName ?? DEFAULT_CSRF_COOKIE_NAME, cookieString);
52
+ if (!token) return null;
53
+ if (!isAllowedTarget(url, opts.allowedOrigins)) return null;
54
+ return [opts.headerName ?? DEFAULT_CSRF_HEADER_NAME, token];
55
+ }
56
+ function documentCookie() {
57
+ if (typeof document === "undefined") return null;
58
+ try {
59
+ return typeof document.cookie === "string" ? document.cookie : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ function currentLocation() {
65
+ if (typeof location === "undefined") return null;
66
+ try {
67
+ const { href, origin } = location;
68
+ return typeof href === "string" && typeof origin === "string" ? { href, origin } : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function isAllowedTarget(url, allowedOrigins) {
74
+ const loc = currentLocation();
75
+ const base = loc ? loc.href : SENTINEL_ORIGIN;
76
+ const selfOrigin = loc ? loc.origin : SENTINEL_ORIGIN;
77
+ const target = originOf(url, base, selfOrigin);
78
+ if (target === null || target === "null") return false;
79
+ if (selfOrigin !== "null" && target === selfOrigin) return true;
80
+ if (!allowedOrigins || allowedOrigins.length === 0) return false;
81
+ return allowedOrigins.some((entry) => originOf(entry, base, selfOrigin) === target);
82
+ }
83
+ function originOf(url, base, baseOrigin) {
84
+ try {
85
+ if (typeof URL === "function") {
86
+ const origin = new URL(url, base).origin;
87
+ if (typeof origin === "string") return origin;
88
+ }
89
+ } catch {
90
+ }
91
+ return looseOriginOf(url, baseOrigin);
92
+ }
93
+ function looseOriginOf(url, baseOrigin) {
94
+ const trimmed = url.trim();
95
+ const m = ABSOLUTE_URL.exec(trimmed);
96
+ if (m) {
97
+ const scheme = m[1].toLowerCase();
98
+ const authority = m[2];
99
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1).toLowerCase();
100
+ const hp = /^(.*?)(?::(\d*))?$/.exec(hostPort);
101
+ const host = hp?.[1] ?? "";
102
+ const port = hp?.[2] ?? "";
103
+ if (!host) return null;
104
+ return port === "" || port === DEFAULT_PORTS[scheme] ? `${scheme}://${host}` : `${scheme}://${host}:${port}`;
105
+ }
106
+ if (PROTOCOL_RELATIVE.test(trimmed) || HAS_SCHEME.test(trimmed)) return null;
107
+ return baseOrigin;
108
+ }
109
+
23
110
  // src/idempotency.ts
24
111
  var FALLBACK_GROUPS = [8, 4, 4, 4, 12];
25
112
  function generateIdempotencyKey() {
@@ -163,6 +250,8 @@ function createApiClient(config) {
163
250
  async function requestWithMeta(path, init = {}) {
164
251
  const sentTraceId = generateTraceId();
165
252
  const method = (init.method ?? "GET").toUpperCase();
253
+ const url = joinUrl(config.baseUrl, path);
254
+ const credentials = init.credentials ?? config.credentials;
166
255
  const idempotencyHeader = config.idempotency?.header ?? "Idempotency-Key";
167
256
  const idempotencyKey = config.idempotency && (config.idempotency.methods ?? DEFAULT_IDEMPOTENCY_METHODS).includes(method) ? generateIdempotencyKey() : null;
168
257
  const attemptOnce = async () => {
@@ -178,8 +267,16 @@ function createApiClient(config) {
178
267
  const token = config.getToken();
179
268
  if (token) headers.set("Authorization", `Bearer ${token}`);
180
269
  }
270
+ if (config.csrf) {
271
+ const csrfHeader = csrfHeaderFor(url, method, config.csrf);
272
+ if (csrfHeader && !headers.has(csrfHeader[0])) headers.set(csrfHeader[0], csrfHeader[1]);
273
+ }
181
274
  const fetchFn = config.fetchImpl ?? globalThis.fetch;
182
- const response = await fetchFn(joinUrl(config.baseUrl, path), { ...init, headers });
275
+ const response = await fetchFn(url, {
276
+ ...init,
277
+ ...credentials !== void 0 ? { credentials } : {},
278
+ headers
279
+ });
183
280
  const traceId = response.headers.get(traceIdHeader) ?? sentTraceId;
184
281
  let raw = null;
185
282
  try {
@@ -1686,6 +1783,8 @@ export {
1686
1783
  ApiError,
1687
1784
  BulkheadFullError,
1688
1785
  CircuitOpenError,
1786
+ DEFAULT_CSRF_COOKIE_NAME,
1787
+ DEFAULT_CSRF_HEADER_NAME,
1689
1788
  ResultCode,
1690
1789
  WEBHOOK_SIGNATURE_HEADER,
1691
1790
  abbreviateAmount,
@@ -1705,6 +1804,7 @@ export {
1705
1804
  createFeatureFlags,
1706
1805
  createTokenBucket,
1707
1806
  createTtlCache,
1807
+ csrfHeaderFor,
1708
1808
  decodeJwtPayload,
1709
1809
  decomposeHangul,
1710
1810
  formatPhoneNumber,
@@ -1715,6 +1815,7 @@ export {
1715
1815
  isForeignerRrn,
1716
1816
  isRetryableStatus,
1717
1817
  isTokenExpired,
1818
+ isUnsafeMethod,
1718
1819
  isValidBusinessNumber,
1719
1820
  isValidCorporateNumber,
1720
1821
  isValidRrn,
@@ -1734,6 +1835,7 @@ export {
1734
1835
  parseSseFrame,
1735
1836
  parseWireDateTime,
1736
1837
  pickJosa,
1838
+ readCookie,
1737
1839
  readSseStream,
1738
1840
  retry,
1739
1841
  rrnBirthDate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rscc/common-core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "RSCC 공통 코어 — CommonResponse 타입·apiClient(traceId·재시도·멱등성 키), SSE 파서, 회복탄력성(retry·서킷 브레이커·토큰버킷·Bulkhead·TTL 캐시), 보안(마스킹·로그 리댁션·웹훅 서명·JWT 디코드·AES-GCM 서브패스), 와이어 계약 헬퍼, 한국 도메인 유틸. 프레임워크 무관, 런타임 의존성 0.",
5
5
  "keywords": [
6
6
  "rscc",
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/Jeonghyeon-Ryu/r-common/issues"
25
25
  },
26
26
  "engines": {
27
- "node": ">=18"
27
+ "node": ">=20"
28
28
  },
29
29
  "publishConfig": {
30
30
  "access": "public"