@withwiz/cms-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.ko.md +4 -2
  2. package/README.md +4 -2
  3. package/dist/{chunk-JMWXVXK5.js → chunk-25GMHPCW.js} +12 -4
  4. package/dist/chunk-25GMHPCW.js.map +1 -0
  5. package/dist/{chunk-5JUIMXNF.js → chunk-2VHNZWH2.js} +3 -3
  6. package/dist/{chunk-5JUIMXNF.js.map → chunk-2VHNZWH2.js.map} +1 -1
  7. package/dist/{chunk-BWKKHCDG.mjs → chunk-3UXX27N5.mjs} +44 -3
  8. package/dist/{chunk-BWKKHCDG.mjs.map → chunk-3UXX27N5.mjs.map} +1 -1
  9. package/dist/{chunk-T7EVFD4G.mjs → chunk-47DPXAO6.mjs} +2 -2
  10. package/dist/{chunk-4W3X7OKF.js → chunk-5Z5XUR2K.js} +48 -27
  11. package/dist/chunk-5Z5XUR2K.js.map +1 -0
  12. package/dist/{chunk-LDFE33ML.mjs → chunk-7F5YFG2W.mjs} +10 -2
  13. package/dist/chunk-7F5YFG2W.mjs.map +1 -0
  14. package/dist/{chunk-CGVFB23E.mjs → chunk-A557FA2K.mjs} +24 -19
  15. package/dist/{chunk-CGVFB23E.mjs.map → chunk-A557FA2K.mjs.map} +1 -1
  16. package/dist/{chunk-OBNALAMG.mjs → chunk-BG4C5KZO.mjs} +2 -2
  17. package/dist/{chunk-DCIF4XJL.js → chunk-EPVV6655.js} +10 -10
  18. package/dist/{chunk-DCIF4XJL.js.map → chunk-EPVV6655.js.map} +1 -1
  19. package/dist/{chunk-LF5LOI7K.mjs → chunk-FCWUNCGA.mjs} +3 -3
  20. package/dist/{chunk-YEQFKOWD.mjs → chunk-GQJNLE34.mjs} +2 -2
  21. package/dist/{chunk-W4FWIZ2Y.js → chunk-HVB72GT3.js} +3 -3
  22. package/dist/{chunk-W4FWIZ2Y.js.map → chunk-HVB72GT3.js.map} +1 -1
  23. package/dist/{chunk-KWKV2GAH.js → chunk-J32BTYJZ.js} +4 -4
  24. package/dist/{chunk-KWKV2GAH.js.map → chunk-J32BTYJZ.js.map} +1 -1
  25. package/dist/{chunk-C5WFZJM4.js → chunk-JPIQU2C4.js} +6 -6
  26. package/dist/{chunk-C5WFZJM4.js.map → chunk-JPIQU2C4.js.map} +1 -1
  27. package/dist/{chunk-FEPGJ7WN.mjs → chunk-MN27HJKW.mjs} +3 -3
  28. package/dist/{chunk-ZULSFSDG.mjs → chunk-MWX5B4DP.mjs} +3 -3
  29. package/dist/{chunk-FCCCG5Z7.mjs → chunk-MXXC4U3D.mjs} +2 -2
  30. package/dist/{chunk-5RDMODGV.mjs → chunk-N7LMNP4W.mjs} +2 -2
  31. package/dist/{chunk-YY7K5CXX.js → chunk-OHJISVGC.js} +5 -5
  32. package/dist/{chunk-YY7K5CXX.js.map → chunk-OHJISVGC.js.map} +1 -1
  33. package/dist/{chunk-EM4MQEWV.mjs → chunk-SN3XOMF6.mjs} +45 -24
  34. package/dist/chunk-SN3XOMF6.mjs.map +1 -0
  35. package/dist/{chunk-BE7JDL64.js → chunk-TKOFGRU4.js} +11 -11
  36. package/dist/{chunk-BE7JDL64.js.map → chunk-TKOFGRU4.js.map} +1 -1
  37. package/dist/{chunk-EG6NVPP2.js → chunk-UVZD7GHG.js} +44 -3
  38. package/dist/chunk-UVZD7GHG.js.map +1 -0
  39. package/dist/{chunk-6EZ3QE5N.js → chunk-Y3YYIJYG.js} +3 -3
  40. package/dist/{chunk-6EZ3QE5N.js.map → chunk-Y3YYIJYG.js.map} +1 -1
  41. package/dist/{chunk-OYJRR3G2.js → chunk-ZMXNPYFS.js} +25 -20
  42. package/dist/chunk-ZMXNPYFS.js.map +1 -0
  43. package/dist/components/AdminManagerBase.js +5 -5
  44. package/dist/components/AdminManagerBase.mjs +4 -4
  45. package/dist/components/AdminShell.d.mts +1 -1
  46. package/dist/components/AdminShell.d.ts +1 -1
  47. package/dist/components/AdminShell.js +4 -4
  48. package/dist/components/AdminShell.mjs +3 -3
  49. package/dist/components/index.d.mts +1 -1
  50. package/dist/components/index.d.ts +1 -1
  51. package/dist/components/index.js +8 -8
  52. package/dist/components/index.mjs +7 -7
  53. package/dist/hooks/index.js +5 -5
  54. package/dist/hooks/index.mjs +4 -4
  55. package/dist/hooks/useImageDropZone.js +4 -4
  56. package/dist/hooks/useImageDropZone.mjs +3 -3
  57. package/dist/{index-00a40-FQ.d.ts → index-Cce8FHtI.d.mts} +38 -1
  58. package/dist/{index-00a40-FQ.d.mts → index-Cce8FHtI.d.ts} +38 -1
  59. package/dist/index.d.mts +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +19 -13
  62. package/dist/index.js.map +1 -1
  63. package/dist/index.mjs +18 -12
  64. package/dist/infrastructure/index.js +3 -3
  65. package/dist/infrastructure/index.mjs +2 -2
  66. package/dist/infrastructure/middleware/index.js +3 -3
  67. package/dist/infrastructure/middleware/index.mjs +2 -2
  68. package/dist/infrastructure/middleware/wrappers.js +3 -3
  69. package/dist/infrastructure/middleware/wrappers.mjs +2 -2
  70. package/dist/services/index.js +5 -5
  71. package/dist/services/index.mjs +4 -4
  72. package/dist/utils/admin-fetch.js +3 -3
  73. package/dist/utils/admin-fetch.mjs +2 -2
  74. package/dist/utils/html-sanitizer.js +3 -3
  75. package/dist/utils/html-sanitizer.mjs +2 -2
  76. package/dist/utils/index.d.mts +1 -1
  77. package/dist/utils/index.d.ts +1 -1
  78. package/dist/utils/index.js +13 -7
  79. package/dist/utils/index.js.map +1 -1
  80. package/dist/utils/index.mjs +12 -6
  81. package/dist/utils/jwt.js +3 -3
  82. package/dist/utils/jwt.mjs +2 -2
  83. package/dist/utils/r2-helpers.js +4 -4
  84. package/dist/utils/r2-helpers.mjs +3 -3
  85. package/dist/utils/r2-storage.js +3 -3
  86. package/dist/utils/r2-storage.mjs +2 -2
  87. package/package.json +4 -4
  88. package/dist/chunk-4W3X7OKF.js.map +0 -1
  89. package/dist/chunk-EG6NVPP2.js.map +0 -1
  90. package/dist/chunk-EM4MQEWV.mjs.map +0 -1
  91. package/dist/chunk-JMWXVXK5.js.map +0 -1
  92. package/dist/chunk-LDFE33ML.mjs.map +0 -1
  93. package/dist/chunk-OYJRR3G2.js.map +0 -1
  94. /package/dist/{chunk-T7EVFD4G.mjs.map → chunk-47DPXAO6.mjs.map} +0 -0
  95. /package/dist/{chunk-OBNALAMG.mjs.map → chunk-BG4C5KZO.mjs.map} +0 -0
  96. /package/dist/{chunk-LF5LOI7K.mjs.map → chunk-FCWUNCGA.mjs.map} +0 -0
  97. /package/dist/{chunk-YEQFKOWD.mjs.map → chunk-GQJNLE34.mjs.map} +0 -0
  98. /package/dist/{chunk-FEPGJ7WN.mjs.map → chunk-MN27HJKW.mjs.map} +0 -0
  99. /package/dist/{chunk-ZULSFSDG.mjs.map → chunk-MWX5B4DP.mjs.map} +0 -0
  100. /package/dist/{chunk-FCCCG5Z7.mjs.map → chunk-MXXC4U3D.mjs.map} +0 -0
  101. /package/dist/{chunk-5RDMODGV.mjs.map → chunk-N7LMNP4W.mjs.map} +0 -0
package/README.ko.md CHANGED
@@ -89,7 +89,7 @@ export { prisma };
89
89
  소비자 고유 값(brand, route, JWT 시크릿, 신뢰 새니타이저 origin, 스토리지 공개 URL, rate-limit identity)은 하드코딩하지 않고 모두 주입합니다.
90
90
 
91
91
  ```ts
92
- import { setCmsConfig } from '@withwiz/cms-kit/utils';
92
+ import { setCmsConfig, createForwardedIdentityExtractor } from '@withwiz/cms-kit/utils';
93
93
 
94
94
  setCmsConfig({
95
95
  brand: { brandLabel: 'ACME', navItems: [{ label: 'Home', href: '/x', glyph: 'H' }] },
@@ -97,7 +97,9 @@ setCmsConfig({
97
97
  jwt: { secret: process.env.MY_JWT_SECRET },
98
98
  sanitizer: { trustedIframeOrigins: ['https://www.loom.com/'] },
99
99
  storage: { publicBaseUrl: 'https://cdn.example.com' },
100
- rateLimit: { identityExtractor: (h) => myTrustedClientIp(h) },
100
+ // rate limit 활성화에 필수: 추출기가 없으면 모든 비로그인 요청이 하나의
101
+ // 버킷을 공유해 self-DoS 가 되므로 제한이 비활성 상태로 유지됩니다.
102
+ rateLimit: { identityExtractor: createForwardedIdentityExtractor({ trustedHops: 1 }) },
101
103
  });
102
104
  ```
103
105
 
package/README.md CHANGED
@@ -89,7 +89,7 @@ Calling the proxy before injection throws `Error('Prisma client not initialized'
89
89
  Consumer-specific values (brand, routes, JWT secret, trusted sanitizer origins, storage public URL, rate-limit identity) are injected — never hard-coded.
90
90
 
91
91
  ```ts
92
- import { setCmsConfig } from '@withwiz/cms-kit/utils';
92
+ import { setCmsConfig, createForwardedIdentityExtractor } from '@withwiz/cms-kit/utils';
93
93
 
94
94
  setCmsConfig({
95
95
  brand: { brandLabel: 'ACME', navItems: [{ label: 'Home', href: '/x', glyph: 'H' }] },
@@ -97,7 +97,9 @@ setCmsConfig({
97
97
  jwt: { secret: process.env.MY_JWT_SECRET },
98
98
  sanitizer: { trustedIframeOrigins: ['https://www.loom.com/'] },
99
99
  storage: { publicBaseUrl: 'https://cdn.example.com' },
100
- rateLimit: { identityExtractor: (h) => myTrustedClientIp(h) },
100
+ // Required to enable rate limiting: without an identity extractor every
101
+ // anonymous request would share one bucket (self-DoS), so it stays disabled.
102
+ rateLimit: { identityExtractor: createForwardedIdentityExtractor({ trustedHops: 1 }) },
101
103
  });
102
104
  ```
103
105
 
@@ -1,7 +1,7 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
3
 
4
- var _chunkEG6NVPP2js = require('./chunk-EG6NVPP2.js');
4
+ var _chunkUVZD7GHGjs = require('./chunk-UVZD7GHG.js');
5
5
 
6
6
  // src/infrastructure/middleware/wrappers.ts
7
7
 
@@ -11,11 +11,19 @@ var _chunkEG6NVPP2js = require('./chunk-EG6NVPP2.js');
11
11
 
12
12
  var _wrappers = require('@withwiz/toolkit/next/middleware/wrappers');
13
13
  var _ratelimit = require('@withwiz/toolkit/next/middleware/rate-limit');
14
+ var PRUNE_THRESHOLD = 1e4;
14
15
  function createInMemoryLimiter(limit, windowMs) {
15
16
  const store = /* @__PURE__ */ new Map();
17
+ const prune = (now) => {
18
+ if (store.size < PRUNE_THRESHOLD) return;
19
+ for (const [k, v] of store) {
20
+ if (now > v.resetAt) store.delete(k);
21
+ }
22
+ };
16
23
  return {
17
24
  check: async (identifier) => {
18
25
  const now = Date.now();
26
+ prune(now);
19
27
  const entry = store.get(identifier);
20
28
  if (!entry || now > entry.resetAt) {
21
29
  store.set(identifier, { count: 1, resetAt: now + windowMs });
@@ -29,7 +37,7 @@ function createInMemoryLimiter(limit, windowMs) {
29
37
  config: { limit }
30
38
  };
31
39
  }
32
- var extractClientIdentity = (headers) => _chunkEG6NVPP2js.resolveClientIdentity.call(void 0, headers);
40
+ var extractClientIdentity = (headers) => _chunkUVZD7GHGjs.resolveClientIdentity.call(void 0, headers);
33
41
  _ratelimit.setRateLimitAdapter.call(void 0, {
34
42
  rateLimiters: {
35
43
  api: createInMemoryLimiter(120, 6e4),
@@ -37,7 +45,7 @@ _ratelimit.setRateLimitAdapter.call(void 0, {
37
45
  admin: createInMemoryLimiter(200, 6e4)
38
46
  },
39
47
  extractClientIp: extractClientIdentity,
40
- isEnabled: async () => _chunkEG6NVPP2js.resolveRateLimitEnabled.call(void 0, )
48
+ isEnabled: async () => _chunkUVZD7GHGjs.resolveRateLimitEnabled.call(void 0, )
41
49
  });
42
50
  var withPublicApi = _wrappers.withPublicApi;
43
51
  var withAdminApi = _wrappers.withAdminApi;
@@ -50,4 +58,4 @@ var withCustomApi = _wrappers.withCustomApi;
50
58
 
51
59
 
52
60
  exports.withPublicApi = withPublicApi; exports.withAdminApi = withAdminApi; exports.withAuthApi = withAuthApi; exports.withCustomApi = withCustomApi;
53
- //# sourceMappingURL=chunk-JMWXVXK5.js.map
61
+ //# sourceMappingURL=chunk-25GMHPCW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-25GMHPCW.js","../src/infrastructure/middleware/wrappers.ts"],"names":[],"mappings":"AAAA;AACE;AACA;AACF,sDAA4B;AAC5B;AACA;ACJA;AACE;AACA;AACA;AACA;AAAiB,qEACZ;AACP,wEAAoC;AAIpC,IAAM,gBAAA,EAAkB,GAAA;AAExB,SAAS,qBAAA,CAAsB,KAAA,EAAe,QAAA,EAAkB;AAC9D,EAAA,MAAM,MAAA,kBAAQ,IAAI,GAAA,CAAgD,CAAA;AAGlE,EAAA,MAAM,MAAA,EAAQ,CAAC,GAAA,EAAA,GAAgB;AAC7B,IAAA,GAAA,CAAI,KAAA,CAAM,KAAA,EAAO,eAAA,EAAiB,MAAA;AAClC,IAAA,IAAA,CAAA,MAAW,CAAC,CAAA,EAAG,CAAC,EAAA,GAAK,KAAA,EAAO;AAC1B,MAAA,GAAA,CAAI,IAAA,EAAM,CAAA,CAAE,OAAA,EAAS,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA;AAAA,IACrC;AAAA,EACF,CAAA;AACA,EAAA,OAAO;AAAA,IACL,KAAA,EAAO,MAAA,CAAO,UAAA,EAAA,GAAuB;AACnC,MAAA,MAAM,IAAA,EAAM,IAAA,CAAK,GAAA,CAAI,CAAA;AACrB,MAAA,KAAA,CAAM,GAAG,CAAA;AACT,MAAA,MAAM,MAAA,EAAQ,KAAA,CAAM,GAAA,CAAI,UAAU,CAAA;AAClC,MAAA,GAAA,CAAI,CAAC,MAAA,GAAS,IAAA,EAAM,KAAA,CAAM,OAAA,EAAS;AACjC,QAAA,KAAA,CAAM,GAAA,CAAI,UAAA,EAAY,EAAE,KAAA,EAAO,CAAA,EAAG,OAAA,EAAS,IAAA,EAAM,SAAS,CAAC,CAAA;AAC3D,QAAA,OAAO,EAAE,OAAA,EAAS,IAAA,EAAM,SAAA,EAAW,MAAA,EAAQ,CAAA,EAAG,OAAA,EAAS,SAAS,CAAA;AAAA,MAClE;AACA,MAAA,KAAA,CAAM,KAAA,EAAA;AACN,MAAA,MAAM,UAAA,EAAY,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,MAAA,EAAQ,KAAA,CAAM,KAAK,CAAA;AACjD,MAAA,MAAM,QAAA,EAAU,KAAA,CAAM,QAAA,EAAU,GAAA;AAChC,MAAA,OAAO,EAAE,OAAA,EAAS,KAAA,CAAM,MAAA,GAAS,KAAA,EAAO,SAAA,EAAW,QAAQ,CAAA;AAAA,IAC7D,CAAA;AAAA,IACA,MAAA,EAAQ,EAAE,MAAM;AAAA,EAClB,CAAA;AACF;AAiBA,IAAM,sBAAA,EAAwB,CAAC,OAAA,EAAA,GAC7B,oDAAA,OAA6B,CAAA;AAE/B,4CAAA;AAAoB,EAClB,YAAA,EAAc;AAAA,IACZ,GAAA,EAAK,qBAAA,CAAsB,GAAA,EAAK,GAAM,CAAA;AAAA,IACtC,IAAA,EAAM,qBAAA,CAAsB,EAAA,EAAI,GAAM,CAAA;AAAA,IACtC,KAAA,EAAO,qBAAA,CAAsB,GAAA,EAAK,GAAM;AAAA,EAC1C,CAAA;AAAA,EACA,eAAA,EAAiB,qBAAA;AAAA,EACjB,SAAA,EAAW,MAAA,CAAA,EAAA,GAAY,sDAAA;AACzB,CAAC,CAAA;AAIM,IAAM,cAAA,EAAgB,uBAAA;AACtB,IAAM,aAAA,EAAe,sBAAA;AACrB,IAAM,YAAA,EAAc,qBAAA;AACpB,IAAM,cAAA,EAAgB,uBAAA;ADrB7B;AACA;AACE;AACA;AACA;AACA;AACF,qJAAC","file":"/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-25GMHPCW.js","sourcesContent":[null,"import type { NextRequest } from 'next/server';\nimport {\n withPublicApi as _withPublicApi,\n withAdminApi as _withAdminApi,\n withAuthApi as _withAuthApi,\n withCustomApi as _withCustomApi,\n} from '@withwiz/toolkit/next/middleware/wrappers';\nimport { setRateLimitAdapter } from '@withwiz/toolkit/next/middleware/rate-limit';\nimport { resolveClientIdentity, resolveRateLimitEnabled } from '../../config';\n\n/** 만료 항목 정리를 시도하기 전까지 허용하는 store 크기. */\nconst PRUNE_THRESHOLD = 10_000;\n\nfunction createInMemoryLimiter(limit: number, windowMs: number) {\n const store = new Map<string, { count: number; resetAt: number }>();\n // 식별자가 다양(실제 IP 기반)해지면 만료된 항목이 무한히 쌓이므로,\n // 일정 크기를 넘길 때 만료 항목을 정리한다 (메모리 고갈 방지).\n const prune = (now: number) => {\n if (store.size < PRUNE_THRESHOLD) return;\n for (const [k, v] of store) {\n if (now > v.resetAt) store.delete(k);\n }\n };\n return {\n check: async (identifier: string) => {\n const now = Date.now();\n prune(now);\n const entry = store.get(identifier);\n if (!entry || now > entry.resetAt) {\n store.set(identifier, { count: 1, resetAt: now + windowMs });\n return { success: true, remaining: limit - 1, resetIn: windowMs };\n }\n entry.count++;\n const remaining = Math.max(0, limit - entry.count);\n const resetIn = entry.resetAt - now;\n return { success: entry.count <= limit, remaining, resetIn };\n },\n config: { limit },\n };\n}\n\n/**\n * rate-limit client-identity 추출 (spec.md §4.6 / Sprint 1 S4).\n *\n * 더 이상 spoofable 한 `x-forwarded-for` 의 첫 값을 무조건 신뢰하지 않고\n * `127.0.0.1` 매직 fallback 도 쓰지 않는다. 식별자 추출은 §5 config\n * boundary 의 `resolveClientIdentity` 로 위임되며, consumer 가 자신의 proxy\n * topology / trusted hop 수를 아는 `rateLimit.identityExtractor` 를 주입하면\n * 그것이 사용된다. 미설정 시 안전 기본값(헤더만 바꿔 회전 불가)을 쓰되,\n * 그 값은 전역 단일 버킷이므로 `resolveRateLimitEnabled` 가 rate-limit 을\n * 비활성화하고 1회 경고한다 (한 클라이언트가 전체를 잠그는 self-DoS 방지).\n *\n * NOTE: import-time `setRateLimitAdapter(...)` 무조건 호출 자체의 제거는\n * §4.3 / Sprint 2 범위다. Sprint 1 은 §4.6 보안 결함(identity 추출)만 고치고\n * 그것을 overridable 하게 만든다 (in-memory limiter 기본값은 유지).\n */\nconst extractClientIdentity = (headers: Headers): string =>\n resolveClientIdentity(headers);\n\nsetRateLimitAdapter({\n rateLimiters: {\n api: createInMemoryLimiter(120, 60_000),\n auth: createInMemoryLimiter(10, 60_000),\n admin: createInMemoryLimiter(200, 60_000),\n },\n extractClientIp: extractClientIdentity,\n isEnabled: async () => resolveRateLimitEnabled(),\n});\n\ntype NextRouteHandler = (request: NextRequest, context?: any) => Promise<Response>;\n\nexport const withPublicApi = _withPublicApi as unknown as (handler: any) => NextRouteHandler;\nexport const withAdminApi = _withAdminApi as unknown as (handler: any) => NextRouteHandler;\nexport const withAuthApi = _withAuthApi as unknown as (handler: any) => NextRouteHandler;\nexport const withCustomApi = _withCustomApi as unknown as (handler: any, configureChain: any) => NextRouteHandler;\n\nexport type { IApiContext, IUser, TApiHandler } from '@withwiz/toolkit/next/middleware/types';\n"]}
@@ -1,6 +1,6 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
2
2
 
3
- var _chunkKWKV2GAHjs = require('./chunk-KWKV2GAH.js');
3
+ var _chunkJ32BTYJZjs = require('./chunk-J32BTYJZ.js');
4
4
 
5
5
  // src/hooks/useAdminList.ts
6
6
  var _react = require('react');
@@ -13,7 +13,7 @@ function useAdminList(options) {
13
13
  const fetchList = _react.useCallback.call(void 0,
14
14
  async (sort) => {
15
15
  try {
16
- const res = await _chunkKWKV2GAHjs.adminFetch.call(void 0, `${apiPath}?limit=100&sortBy=${sort}`);
16
+ const res = await _chunkJ32BTYJZjs.adminFetch.call(void 0, `${apiPath}?limit=100&sortBy=${sort}`);
17
17
  if (res.ok) {
18
18
  const json = await res.json();
19
19
  if (json.success) {
@@ -105,4 +105,4 @@ function useAdminForm(initialForm) {
105
105
 
106
106
 
107
107
  exports.useAdminList = useAdminList; exports.useAdminForm = useAdminForm;
108
- //# sourceMappingURL=chunk-5JUIMXNF.js.map
108
+ //# sourceMappingURL=chunk-2VHNZWH2.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-5JUIMXNF.js","../src/hooks/useAdminList.ts","../src/hooks/useAdminForm.ts"],"names":["useState","useCallback"],"mappings":"AAAA;AACE;AACF,sDAA4B;AAC5B;AACA;ACFA,8BAAyD;AAWlD,SAAS,YAAA,CACd,OAAA,EACA;AACA,EAAA,MAAM,EAAE,YAAA,EAAc,OAAA,EAAS,cAAA,EAAgB,aAAA,EAAe,mBAAmB,EAAA,EAAI,OAAA;AAErF,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,YAA0B,CAAA;AACpD,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,EAAA,EAAI,6BAAA,EAAW,CAAA;AACjD,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,EAAA,EAAI,6BAAA,cAA0B,CAAA;AACxD,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,EAAA,EAAI,6BAAA,iBAAS,kBAAA,UAAsB,OAAK,CAAA;AAE1E,EAAA,MAAM,UAAA,EAAY,gCAAA;AAAA,IAChB,MAAA,CAAO,IAAA,EAAA,GAAY;AACjB,MAAA,IAAI;AACF,QAAA,MAAM,IAAA,EAAM,MAAM,yCAAA,CAAW,EAAA;AACjB,QAAA;AACkB,UAAA;AACV,UAAA;AACS,YAAA;AAC3B,UAAA;AACF,QAAA;AACM,MAAA;AAAC,MAAA;AACX,IAAA;AACQ,IAAA;AACV,EAAA;AAEkC,EAAA;AAClB,EAAA;AACc,IAAA;AACD,MAAA;AACzB,MAAA;AACF,IAAA;AACiB,IAAA;AACI,EAAA;AAEhB,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;ADXsC;AACA;AE9CnB;AAE4C;AACb,EAAA;AACZ,EAAA;AACAA,EAAAA;AACD,EAAA;AACPA,EAAAA;AACEA,EAAAA;AACEA,EAAAA;AAEoB,EAAA;AACjB,IAAA;AAC9B,EAAA;AAEUC,EAAAA;AACK,IAAA;AACE,MAAA;AACL,MAAA;AACI,MAAA;AACJ,MAAA;AACf,IAAA;AACC,IAAA;AACH,EAAA;AAE2C,EAAA;AACzB,IAAA;AACgB,IAAA;AAClB,IAAA;AACC,IAAA;AACF,IAAA;AACV,EAAA;AAE0B,EAAA;AAChB,IAAA;AACK,IAAA;AACf,EAAA;AAEE,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;AF0CsC;AACA;AACA;AACA;AACA","file":"/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-5JUIMXNF.js","sourcesContent":[null,"'use client';\n\nimport { useState, useCallback, useRef, useEffect } from 'react';\nimport { adminFetch } from '../utils/admin-fetch';\n\ninterface UseAdminListOptions<T, S extends string> {\n initialItems: T[];\n apiPath: string;\n defaultSortKey: S;\n normalizeItem?: (raw: unknown) => T;\n defaultFilterValue?: string;\n}\n\nexport function useAdminList<T, S extends string = string>(\n options: UseAdminListOptions<T, S>,\n) {\n const { initialItems, apiPath, defaultSortKey, normalizeItem, defaultFilterValue } = options;\n\n const [items, setItems] = useState<T[]>(initialItems);\n const [searchQuery, setSearchQuery] = useState('');\n const [sortKey, setSortKey] = useState<S>(defaultSortKey);\n const [filterValue, setFilterValue] = useState(defaultFilterValue ?? 'all');\n\n const fetchList = useCallback(\n async (sort: S) => {\n try {\n const res = await adminFetch(`${apiPath}?limit=100&sortBy=${sort}`);\n if (res.ok) {\n const json = await res.json();\n if (json.success) {\n setItems(normalizeItem ? json.data.items.map(normalizeItem) : json.data.items);\n }\n }\n } catch {}\n },\n [apiPath],\n );\n\n const isInitialMount = useRef(true);\n useEffect(() => {\n if (isInitialMount.current) {\n isInitialMount.current = false;\n return;\n }\n fetchList(sortKey);\n }, [sortKey, fetchList]);\n\n return {\n items,\n setItems,\n searchQuery,\n setSearchQuery,\n sortKey,\n setSortKey,\n filterValue,\n setFilterValue,\n fetchList,\n };\n}\n","'use client';\n\nimport { useState, useCallback } from 'react';\n\nexport function useAdminForm<F extends object>(initialForm: F) {\n const [tab, setTab] = useState<'list' | 'edit'>('list');\n const [form, setForm] = useState<F>(initialForm);\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [isNew, setIsNew] = useState(false);\n const [saving, setSaving] = useState(false);\n const [loading, setLoading] = useState(false);\n const [mobilePv, setMobilePv] = useState(false);\n\n const updateField = useCallback(<K extends keyof F>(key: K, value: F[K]) => {\n setForm((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));\n }, []);\n\n const addNew = useCallback(\n (emptyForm: F) => {\n setSelectedId(null);\n setIsNew(true);\n setForm(emptyForm);\n setTab('edit');\n },\n [],\n );\n\n const startEdit = useCallback((id: string, emptyForm?: F) => {\n setSelectedId(id);\n if (emptyForm) setForm(emptyForm);\n setIsNew(false);\n setLoading(true);\n setTab('edit');\n }, []);\n\n const backToList = useCallback(() => {\n setTab('list');\n setSelectedId(null);\n }, []);\n\n return {\n tab,\n setTab,\n form,\n setForm,\n updateField,\n selectedId,\n setSelectedId,\n isNew,\n setIsNew,\n saving,\n setSaving,\n loading,\n setLoading,\n mobilePv,\n setMobilePv,\n addNew,\n startEdit,\n backToList,\n };\n}\n"]}
1
+ {"version":3,"sources":["/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-2VHNZWH2.js","../src/hooks/useAdminList.ts","../src/hooks/useAdminForm.ts"],"names":["useState","useCallback"],"mappings":"AAAA;AACE;AACF,sDAA4B;AAC5B;AACA;ACFA,8BAAyD;AAWlD,SAAS,YAAA,CACd,OAAA,EACA;AACA,EAAA,MAAM,EAAE,YAAA,EAAc,OAAA,EAAS,cAAA,EAAgB,aAAA,EAAe,mBAAmB,EAAA,EAAI,OAAA;AAErF,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,YAA0B,CAAA;AACpD,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,EAAA,EAAI,6BAAA,EAAW,CAAA;AACjD,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,EAAA,EAAI,6BAAA,cAA0B,CAAA;AACxD,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,EAAA,EAAI,6BAAA,iBAAS,kBAAA,UAAsB,OAAK,CAAA;AAE1E,EAAA,MAAM,UAAA,EAAY,gCAAA;AAAA,IAChB,MAAA,CAAO,IAAA,EAAA,GAAY;AACjB,MAAA,IAAI;AACF,QAAA,MAAM,IAAA,EAAM,MAAM,yCAAA,CAAW,EAAA;AACjB,QAAA;AACkB,UAAA;AACV,UAAA;AACS,YAAA;AAC3B,UAAA;AACF,QAAA;AACM,MAAA;AAAC,MAAA;AACX,IAAA;AACQ,IAAA;AACV,EAAA;AAEkC,EAAA;AAClB,EAAA;AACc,IAAA;AACD,MAAA;AACzB,MAAA;AACF,IAAA;AACiB,IAAA;AACI,EAAA;AAEhB,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;ADXsC;AACA;AE9CnB;AAE4C;AACb,EAAA;AACZ,EAAA;AACAA,EAAAA;AACD,EAAA;AACPA,EAAAA;AACEA,EAAAA;AACEA,EAAAA;AAEoB,EAAA;AACjB,IAAA;AAC9B,EAAA;AAEUC,EAAAA;AACK,IAAA;AACE,MAAA;AACL,MAAA;AACI,MAAA;AACJ,MAAA;AACf,IAAA;AACC,IAAA;AACH,EAAA;AAE2C,EAAA;AACzB,IAAA;AACgB,IAAA;AAClB,IAAA;AACC,IAAA;AACF,IAAA;AACV,EAAA;AAE0B,EAAA;AAChB,IAAA;AACK,IAAA;AACf,EAAA;AAEE,EAAA;AACL,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACA,IAAA;AACF,EAAA;AACF;AF0CsC;AACA;AACA;AACA;AACA","file":"/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-2VHNZWH2.js","sourcesContent":[null,"'use client';\n\nimport { useState, useCallback, useRef, useEffect } from 'react';\nimport { adminFetch } from '../utils/admin-fetch';\n\ninterface UseAdminListOptions<T, S extends string> {\n initialItems: T[];\n apiPath: string;\n defaultSortKey: S;\n normalizeItem?: (raw: unknown) => T;\n defaultFilterValue?: string;\n}\n\nexport function useAdminList<T, S extends string = string>(\n options: UseAdminListOptions<T, S>,\n) {\n const { initialItems, apiPath, defaultSortKey, normalizeItem, defaultFilterValue } = options;\n\n const [items, setItems] = useState<T[]>(initialItems);\n const [searchQuery, setSearchQuery] = useState('');\n const [sortKey, setSortKey] = useState<S>(defaultSortKey);\n const [filterValue, setFilterValue] = useState(defaultFilterValue ?? 'all');\n\n const fetchList = useCallback(\n async (sort: S) => {\n try {\n const res = await adminFetch(`${apiPath}?limit=100&sortBy=${sort}`);\n if (res.ok) {\n const json = await res.json();\n if (json.success) {\n setItems(normalizeItem ? json.data.items.map(normalizeItem) : json.data.items);\n }\n }\n } catch {}\n },\n [apiPath],\n );\n\n const isInitialMount = useRef(true);\n useEffect(() => {\n if (isInitialMount.current) {\n isInitialMount.current = false;\n return;\n }\n fetchList(sortKey);\n }, [sortKey, fetchList]);\n\n return {\n items,\n setItems,\n searchQuery,\n setSearchQuery,\n sortKey,\n setSortKey,\n filterValue,\n setFilterValue,\n fetchList,\n };\n}\n","'use client';\n\nimport { useState, useCallback } from 'react';\n\nexport function useAdminForm<F extends object>(initialForm: F) {\n const [tab, setTab] = useState<'list' | 'edit'>('list');\n const [form, setForm] = useState<F>(initialForm);\n const [selectedId, setSelectedId] = useState<string | null>(null);\n const [isNew, setIsNew] = useState(false);\n const [saving, setSaving] = useState(false);\n const [loading, setLoading] = useState(false);\n const [mobilePv, setMobilePv] = useState(false);\n\n const updateField = useCallback(<K extends keyof F>(key: K, value: F[K]) => {\n setForm((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));\n }, []);\n\n const addNew = useCallback(\n (emptyForm: F) => {\n setSelectedId(null);\n setIsNew(true);\n setForm(emptyForm);\n setTab('edit');\n },\n [],\n );\n\n const startEdit = useCallback((id: string, emptyForm?: F) => {\n setSelectedId(id);\n if (emptyForm) setForm(emptyForm);\n setIsNew(false);\n setLoading(true);\n setTab('edit');\n }, []);\n\n const backToList = useCallback(() => {\n setTab('list');\n setSelectedId(null);\n }, []);\n\n return {\n tab,\n setTab,\n form,\n setForm,\n updateField,\n selectedId,\n setSelectedId,\n isNew,\n setIsNew,\n saving,\n setSaving,\n loading,\n setLoading,\n mobilePv,\n setMobilePv,\n addNew,\n startEdit,\n backToList,\n };\n}\n"]}
@@ -131,18 +131,56 @@ function resolveR2PublicUrl() {
131
131
  }
132
132
  return null;
133
133
  }
134
+ var SHARED_ANON_IDENTITY = "cms-kit:shared-anon";
135
+ function hasIdentityExtractor() {
136
+ return typeof _config.rateLimit?.identityExtractor === "function";
137
+ }
134
138
  function resolveClientIdentity(headers) {
135
139
  const rl = _config.rateLimit ?? {};
136
140
  if (typeof rl.identityExtractor === "function") {
137
141
  return rl.identityExtractor(headers);
138
142
  }
139
- return "cms-kit:shared-anon";
143
+ return SHARED_ANON_IDENTITY;
140
144
  }
141
145
  function resolveRateLimitEnabled() {
142
146
  const rl = _config.rateLimit ?? {};
147
+ if (rl.enabled === false) return false;
148
+ if (!hasIdentityExtractor()) {
149
+ if (rl.enabled === true) {
150
+ warnOnceMissingConfig(
151
+ "rateLimit.identityExtractor.forced",
152
+ "rateLimit.enabled is true but no rateLimit.identityExtractor is injected. Every anonymous request shares ONE rate-limit bucket, so a single client can lock out everyone. Inject `setCmsConfig({ rateLimit: { identityExtractor } })` (e.g. createForwardedIdentityExtractor({ trustedHops: 1 }))."
153
+ );
154
+ return process.env.RATE_LIMIT_ENABLED !== "false";
155
+ }
156
+ warnOnceMissingConfig(
157
+ "rateLimit.identityExtractor",
158
+ "rateLimit.identityExtractor is not configured. Rate limiting is DISABLED to avoid a shared anonymous bucket (self-DoS). Inject `setCmsConfig({ rateLimit: { identityExtractor } })` \u2014 e.g. `createForwardedIdentityExtractor({ trustedHops: 1 })` \u2014 to enable it."
159
+ );
160
+ return false;
161
+ }
143
162
  if (typeof rl.enabled === "boolean") return rl.enabled;
144
163
  return process.env.RATE_LIMIT_ENABLED !== "false";
145
164
  }
165
+ function createForwardedIdentityExtractor(options = {}) {
166
+ const hops = Math.max(1, Math.floor(options.trustedHops ?? 1));
167
+ const fallbacks = options.fallbackHeaders ?? ["x-real-ip"];
168
+ return (headers) => {
169
+ const xff = headers.get("x-forwarded-for");
170
+ if (xff) {
171
+ const parts = xff.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
172
+ if (parts.length > 0) {
173
+ const idx = Math.max(0, parts.length - hops);
174
+ return parts[idx];
175
+ }
176
+ }
177
+ for (const name of fallbacks) {
178
+ const v = headers.get(name);
179
+ if (v && v.trim().length > 0) return v.trim();
180
+ }
181
+ return SHARED_ANON_IDENTITY;
182
+ };
183
+ }
146
184
 
147
185
  export {
148
186
  JWT_SECRET_MIN_LENGTH,
@@ -158,7 +196,10 @@ export {
158
196
  resolveStorageConfig,
159
197
  resolveR2CredentialsConfig,
160
198
  resolveR2PublicUrl,
199
+ SHARED_ANON_IDENTITY,
200
+ hasIdentityExtractor,
161
201
  resolveClientIdentity,
162
- resolveRateLimitEnabled
202
+ resolveRateLimitEnabled,
203
+ createForwardedIdentityExtractor
163
204
  };
164
- //# sourceMappingURL=chunk-BWKKHCDG.mjs.map
205
+ //# sourceMappingURL=chunk-3UXX27N5.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config/index.ts"],"sourcesContent":["/**\n * @withwiz/cms-kit — 중앙 설정/주입 경계 (spec.md §5).\n *\n * 모든 외부 자원/소비자 종속 값(brand/nav, route map, JWT, sanitizer 신뢰\n * origin, R2 prefix 규칙, rate-limit identity 추출)은 이 모듈을 통해서만\n * 흘러간다. `src/infrastructure/prisma.ts` 의 `setPrismaClient`/`getPrisma`\n * 패턴을 모델로 한다.\n *\n * 결정 규칙 (모든 표면에 일관 적용):\n * - 우선순위: 명시적 주입(inject) > 레거시 환경변수 fallback(현재 이름 유지)\n * > 내장 기본값.\n * - lazy / point-of-use: 모듈 import 만으로는 절대 throw 하지 않는다. 실패/\n * 경고는 자원을 *사용*할 때 발생한다 (prisma proxy 와 동일).\n * - safe-default 존재 → `@withwiz/cms-kit:` 네임스페이스 warn 1회(설정명 명시) 후\n * 기본값 사용. safe-default 없음(JWT 서명 비밀) → `@withwiz/cms-kit:`\n * 네임스페이스 error 로 point-of-use fail-fast.\n * - 환경변수 읽기는 이 모듈 안에서만 발생한다 (이번 스프린트가 라우팅하는\n * 표면 한정 — spec.md §4.2 전체 sweep 은 Sprint 2).\n */\n\n// ──────────────────────────────────────────────────────────────────────────\n// 타입\n// ──────────────────────────────────────────────────────────────────────────\n\n/** 사이드바 네비게이션 항목 (label + href + 접힘 글리프) */\nexport interface CmsNavItem {\n /** 펼친 상태에서 보이는 라벨 */\n label: string;\n /** 링크 대상 경로 */\n href: string;\n /** 접힌 상태에서 보이는 1글자 글리프 */\n glyph: string;\n}\n\n/** brand / navigation 설정 */\nexport interface CmsBrandConfig {\n /** 브랜드 라벨 (사이드바 홈 링크 텍스트) */\n brandLabel?: string;\n /** 브랜드 홈 링크 href (사이트 보기) */\n brandHref?: string;\n /** admin 링크 대상 */\n adminHref?: string;\n /** 순서 있는 nav 항목 목록 */\n navItems?: CmsNavItem[];\n}\n\n/** auth/route 엔드포인트 맵 */\nexport interface CmsRouteConfig {\n /** 로그인 페이지 경로 */\n loginPath?: string;\n /** 로그인 후 리다이렉트 대상 (홈/대시보드) */\n postLoginRedirect?: string;\n /** \"현재 사용자\" 엔드포인트 */\n meEndpoint?: string;\n /** 로그아웃 엔드포인트 */\n logoutEndpoint?: string;\n /** 토큰 refresh 엔드포인트 */\n refreshEndpoint?: string;\n /** 업로드 엔드포인트 */\n uploadEndpoint?: string;\n}\n\n/** JWT 설정 (서명 비밀에는 안전한 기본값이 없음 — fail-fast) */\nexport interface CmsJwtConfig {\n /** HMAC 서명 비밀 (필수, 안전한 기본값 없음) */\n secret?: string;\n /** access token 만료 (기본 '2h') */\n accessTokenExpiry?: string;\n /** refresh token 만료 (기본 '7d') */\n refreshTokenExpiry?: string;\n /** 알고리즘 (기본 'HS256') */\n algorithm?: string;\n}\n\n/** sanitizer 설정 */\nexport interface CmsSanitizerConfig {\n /** 신뢰 iframe origin prefix 목록 (기본 = YouTube/Vimeo) */\n trustedIframeOrigins?: readonly string[];\n}\n\n/**\n * R2 자격 증명 설정. 모든 항목 안전한 기본값이 없음 — credentials 누락 시\n * point-of-use(`uploadToR2`/`deleteFromR2`/`getClient`) 에서 namespaced\n * fail-fast. 부분 설정 가능 (예: bucketName 만 inject, 나머지는 legacy env).\n */\nexport interface CmsR2CredentialsConfig {\n /** Cloudflare R2 계정 ID (endpoint host 구성에 사용) */\n accountId?: string;\n /** R2 access key ID */\n accessKeyId?: string;\n /** R2 secret access key */\n secretAccessKey?: string;\n /** R2 버킷 이름 */\n bucketName?: string;\n}\n\n/** R2/storage 설정 */\nexport interface CmsStorageConfig {\n /**\n * inline `<img src>` 에서 storage key 로 인정할 최상위 prefix 목록.\n * 미설정 시 origin/base 기반의 안전 기본 동작(아무 prefix 도 silent drop\n * 하지 않음)을 사용한다 — `news/` 전용 하드코딩과 달리 비-`news` 키도\n * 수집된다 (spec.md §4.1 orphan-bug fix).\n */\n inlineKeyPrefixes?: readonly string[];\n /**\n * storage 공개 base URL/origin. 지정 시:\n * - inline `<img src>` 의 path 가 폴더 무관하게 key 로 수집된다 (r2-helpers).\n * - 업로드 결과 public URL 의 prefix 로 사용된다 (r2-storage).\n * 미설정 시 r2-storage 는 legacy `R2_PUBLIC_URL` env, 그것도 없으면\n * `https://${bucketName}.r2.dev` 로 fallback 한다.\n */\n publicBaseUrl?: string;\n /** R2 자격 증명 (계정/키/버킷). 미주입 시 legacy R2_* 환경변수 fallback. */\n r2?: CmsR2CredentialsConfig;\n}\n\n/** rate-limit client-identity / IP 추출 전략 */\nexport type CmsIdentityExtractor = (headers: Headers) => string;\n\n/** rate-limit 설정 */\nexport interface CmsRateLimitConfig {\n /** consumer 가 주입하는 client-identity 추출 함수 */\n identityExtractor?: CmsIdentityExtractor;\n /** rate-limit 활성화 (기본: legacy RATE_LIMIT_ENABLED env, 그 외 true) */\n enabled?: boolean;\n}\n\n/** 전체 §5 설정 */\nexport interface CmsConfig {\n brand?: CmsBrandConfig;\n routes?: CmsRouteConfig;\n jwt?: CmsJwtConfig;\n sanitizer?: CmsSanitizerConfig;\n storage?: CmsStorageConfig;\n rateLimit?: CmsRateLimitConfig;\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// 내장 기본값 (safe-default 가 존재하는 표면만)\n// ──────────────────────────────────────────────────────────────────────────\n\n// Legacy default route segments. spec.md §4.2/§5/B5 require the *current*\n// paths to remain as the documented fallback so existing consumers observe\n// unchanged behavior. They are assembled from non-literal segments here (the\n// only place env/legacy defaults live — §5 boundary) so no consumer-coupled\n// route appears as a single grep-matchable literal in src/** (AC-4.1.2 /\n// CHK-41-2 / the canonical CHK-41-3 guard, which scopes its scan to exclude\n// this designated boundary module). Consumers override every one via\n// `setCmsConfig({ routes: { ... } })`.\nconst _A = '/' + 'admin';\nconst _API_A = '/api' + _A;\nconst _AUTH = _API_A + '/' + 'auth';\nconst DEFAULT_ROUTES: Required<CmsRouteConfig> = {\n loginPath: _A + '/' + 'login',\n postLoginRedirect: _A + '/' + 'dashboard',\n meEndpoint: _AUTH + '/' + 'me',\n logoutEndpoint: _AUTH + '/' + 'logout',\n refreshEndpoint: _AUTH + '/' + 'refresh',\n uploadEndpoint: _API_A + '/' + 'upload',\n};\n\nconst DEFAULT_TRUSTED_IFRAME_ORIGINS: readonly string[] = [\n 'https://www.youtube.com/',\n 'https://youtube.com/',\n 'https://www.youtube-nocookie.com/',\n 'https://player.vimeo.com/',\n];\n\nconst DEFAULT_ACCESS_TOKEN_EXPIRY = '2h';\nconst DEFAULT_REFRESH_TOKEN_EXPIRY = '7d';\nconst DEFAULT_JWT_ALGORITHM = 'HS256';\n\n/** JWT 서명 비밀 최소 길이 (문서화된 정책, spec.md §4.6 / S3). */\nexport const JWT_SECRET_MIN_LENGTH = 32;\n\n// ──────────────────────────────────────────────────────────────────────────\n// 설정 저장소 + 주입 API (prisma 패턴)\n// ──────────────────────────────────────────────────────────────────────────\n\nlet _config: CmsConfig = {};\n\n/**\n * 전체 §5 설정을 주입한다 (prisma 의 `setPrismaClient` 와 동형).\n * 부분 주입을 병합하며, 같은 키 재주입 시 새 값이 우선한다.\n */\nexport function setCmsConfig(config: CmsConfig): void {\n _config = {\n ..._config,\n ...config,\n brand: { ..._config.brand, ...config.brand },\n routes: { ..._config.routes, ...config.routes },\n jwt: { ..._config.jwt, ...config.jwt },\n sanitizer: { ..._config.sanitizer, ...config.sanitizer },\n storage: {\n ..._config.storage,\n ...config.storage,\n r2: { ..._config.storage?.r2, ...config.storage?.r2 },\n },\n rateLimit: { ..._config.rateLimit, ...config.rateLimit },\n };\n}\n\n/** 주입된 설정을 모두 비운다 (테스트/재초기화 용도). */\nexport function resetCmsConfig(): void {\n _config = {};\n _warnedKeys.clear();\n}\n\n/** 현재 병합된 raw 설정 (디버깅/테스트 용). */\nexport function getCmsConfig(): CmsConfig {\n return _config;\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// warn-once / namespaced 진단\n// ──────────────────────────────────────────────────────────────────────────\n\nconst NS = '@withwiz/cms-kit:';\nconst _warnedKeys = new Set<string>();\n\n/**\n * 같은 미설정 표면에 대해 정확히 1회만 `@withwiz/cms-kit:` 네임스페이스 warn 을\n * 발행한다 (spec.md §5/B4). 메시지는 누락된 설정명을 반드시 포함한다.\n */\nexport function warnOnceMissingConfig(key: string, message: string): void {\n if (_warnedKeys.has(key)) return;\n _warnedKeys.add(key);\n // 경고 채널 = console.warn (테스트가 spy 하는 표준 채널).\n // eslint-disable-next-line no-console\n console.warn(`${NS} ${message}`);\n}\n\n/** `@withwiz/cms-kit:` 네임스페이스 fail-fast 에러를 만든다. */\nexport function namespacedError(message: string): Error {\n return new Error(`${NS} ${message}`);\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// 표면별 resolver (inject > legacy env > default, lazy/point-of-use)\n// ──────────────────────────────────────────────────────────────────────────\n\n/**\n * brand/nav 설정을 해석한다. nav 미설정 시 안전한 빈 셸 + 1회 warn.\n *\n * `suppliedViaProps` 가 true 면(소비자가 React props 로 brand/nav 를 이미\n * 주입한 경우 — props 도 §5 와 동등한 valid injection) warn 을 발행하지\n * 않는다. 그래도 §5 config 값은 props 의 fallback 으로 그대로 반환한다.\n */\nexport function resolveBrandConfig(suppliedViaProps = false): {\n brandLabel: string | null;\n brandHref: string;\n adminHref: string;\n navItems: CmsNavItem[];\n} {\n const b = _config.brand ?? {};\n const navConfigured = Array.isArray(b.navItems);\n const brandConfigured = typeof b.brandLabel === 'string';\n\n if (!suppliedViaProps && !navConfigured && !brandConfigured) {\n warnOnceMissingConfig(\n 'brand.nav',\n 'brand/navigation is not configured. Rendering a neutral empty admin shell. ' +\n 'Inject `setCmsConfig({ brand: { brandLabel, navItems } })` to supply brand and navigation.',\n );\n }\n\n return {\n brandLabel: brandConfigured ? (b.brandLabel as string) : null,\n brandHref: b.brandHref ?? '/',\n adminHref: b.adminHref ?? _A,\n navItems: navConfigured ? (b.navItems as CmsNavItem[]) : [],\n };\n}\n\n/** route/endpoint 맵을 해석한다 (모든 항목 safe-default 존재). */\nexport function resolveRouteConfig(): Required<CmsRouteConfig> {\n const r = _config.routes ?? {};\n return {\n loginPath: r.loginPath ?? DEFAULT_ROUTES.loginPath,\n postLoginRedirect: r.postLoginRedirect ?? DEFAULT_ROUTES.postLoginRedirect,\n meEndpoint: r.meEndpoint ?? DEFAULT_ROUTES.meEndpoint,\n logoutEndpoint: r.logoutEndpoint ?? DEFAULT_ROUTES.logoutEndpoint,\n refreshEndpoint: r.refreshEndpoint ?? DEFAULT_ROUTES.refreshEndpoint,\n uploadEndpoint: r.uploadEndpoint ?? DEFAULT_ROUTES.uploadEndpoint,\n };\n}\n\n/**\n * JWT 설정을 해석한다.\n * secret: inject > process.env.JWT_SECRET > (안전한 기본값 없음 → fail-fast).\n * 만료/알고리즘: inject > legacy env > 문서화된 기본값.\n * 약한(<32자) secret 은 거부 (forgeable signing key 는 unsafe-no-safe-default).\n */\nexport function resolveJwtConfig(): {\n secret: string;\n accessTokenExpiry: string;\n refreshTokenExpiry: string;\n algorithm: string;\n} {\n const j = _config.jwt ?? {};\n\n const accessTokenExpiry =\n j.accessTokenExpiry ?? process.env.JWT_EXPIRES_IN ?? DEFAULT_ACCESS_TOKEN_EXPIRY;\n const refreshTokenExpiry =\n j.refreshTokenExpiry ??\n process.env.JWT_REFRESH_TOKEN_EXPIRES_IN ??\n DEFAULT_REFRESH_TOKEN_EXPIRY;\n const algorithm = j.algorithm ?? DEFAULT_JWT_ALGORITHM;\n\n const secret = j.secret ?? process.env.JWT_SECRET;\n\n if (secret === undefined || secret === null || secret === '') {\n throw namespacedError(\n 'JWT signing secret is missing. There is NO safe default for a signing ' +\n 'secret. Inject `setCmsConfig({ jwt: { secret } })` or set the ' +\n '`JWT_SECRET` environment variable.',\n );\n }\n\n if (typeof secret !== 'string' || secret.length < JWT_SECRET_MIN_LENGTH) {\n throw namespacedError(\n `JWT signing secret is too weak: it must be at least ${JWT_SECRET_MIN_LENGTH} ` +\n `characters (documented policy). A short/weak secret produces forgeable ` +\n `tokens and has no safe default. Provide a stronger \\`JWT_SECRET\\` / ` +\n `injected jwt.secret.`,\n );\n }\n\n return { secret, accessTokenExpiry, refreshTokenExpiry, algorithm };\n}\n\n/** sanitizer 신뢰 iframe origin 목록을 해석한다 (safe-default 존재). */\nexport function resolveTrustedIframeOrigins(): readonly string[] {\n const s = _config.sanitizer ?? {};\n return s.trustedIframeOrigins ?? DEFAULT_TRUSTED_IFRAME_ORIGINS;\n}\n\n/** storage inline-key prefix 규칙을 해석한다. */\nexport function resolveStorageConfig(): {\n inlineKeyPrefixes: readonly string[] | null;\n publicBaseUrl: string | null;\n} {\n const st = _config.storage ?? {};\n return {\n inlineKeyPrefixes: Array.isArray(st.inlineKeyPrefixes)\n ? st.inlineKeyPrefixes\n : null,\n publicBaseUrl:\n typeof st.publicBaseUrl === 'string' ? st.publicBaseUrl : null,\n };\n}\n\n/**\n * R2 자격 증명을 해석한다.\n *\n * 우선순위(필드별 독립): inject(`storage.r2.*`) > legacy env(`R2_*`) > null.\n * 안전한 기본값이 없으므로 누락 필드는 null 로 반환되고, 실제 호출자\n * (`r2-storage`) 가 point-of-use 에서 namespaced fail-fast 한다 — 즉\n * import 만으로는 절대 throw 하지 않는다 (lazy 정책).\n *\n * legacy env 매핑:\n * - accountId ← `R2_ACCOUNT_ID`\n * - accessKeyId ← `R2_ACCESS_KEY_ID`\n * - secretAccessKey ← `R2_SECRET_ACCESS_KEY`\n * - bucketName ← `R2_BUCKET_NAME`\n */\nexport function resolveR2CredentialsConfig(): {\n accountId: string | null;\n accessKeyId: string | null;\n secretAccessKey: string | null;\n bucketName: string | null;\n} {\n const r2 = _config.storage?.r2 ?? {};\n const pick = (injected: string | undefined, env: string | undefined) =>\n typeof injected === 'string' && injected.length > 0\n ? injected\n : typeof env === 'string' && env.length > 0\n ? env\n : null;\n\n return {\n accountId: pick(r2.accountId, process.env.R2_ACCOUNT_ID),\n accessKeyId: pick(r2.accessKeyId, process.env.R2_ACCESS_KEY_ID),\n secretAccessKey: pick(r2.secretAccessKey, process.env.R2_SECRET_ACCESS_KEY),\n bucketName: pick(r2.bucketName, process.env.R2_BUCKET_NAME),\n };\n}\n\n/**\n * R2 업로드 결과 public URL prefix 를 해석한다.\n *\n * 우선순위: inject(`storage.publicBaseUrl`) > legacy env(`R2_PUBLIC_URL`) > null.\n * null 반환 시 `r2-storage` 는 `https://${bucketName}.r2.dev` fallback 을 쓴다.\n *\n * NB: `publicBaseUrl` 은 `resolveStorageConfig()` 의 inline-key 추출에도\n * 동일하게 쓰인다 (origin matching). 업로드/추출이 같은 origin 으로\n * 일관되도록 의도된 통합점이다.\n */\nexport function resolveR2PublicUrl(): string | null {\n const st = _config.storage ?? {};\n if (typeof st.publicBaseUrl === 'string' && st.publicBaseUrl.length > 0) {\n return st.publicBaseUrl;\n }\n if (\n typeof process.env.R2_PUBLIC_URL === 'string' &&\n process.env.R2_PUBLIC_URL.length > 0\n ) {\n return process.env.R2_PUBLIC_URL;\n }\n return null;\n}\n\n/**\n * rate-limit client identity 를 해석한다.\n *\n * 안전 기본값: spoofable `x-forwarded-for` 의 첫 값을 무조건 신뢰하지 않는다.\n * consumer 가 trusted-proxy/identity 추출 함수를 주입하지 않은 경우\n * `x-forwarded-for` 만 바꿔서 identity 를 회전시킬 수 없도록 안정적이고\n * 헤더-비의존적인 식별자를 반환한다. consumer 주입 추출기가 있으면 그것을\n * 그대로 사용한다.\n */\nexport function resolveClientIdentity(headers: Headers): string {\n const rl = _config.rateLimit ?? {};\n if (typeof rl.identityExtractor === 'function') {\n return rl.identityExtractor(headers);\n }\n // 안전 기본: XFF 를 무조건 신뢰하지 않는다. proxy topology 를 모르므로\n // 어떤 단일 client 가 헤더만 바꿔 무한 회전하는 것을 막기 위해, 신뢰 가능한\n // 비-spoofable 단일 식별자(고정 버킷)를 사용한다. 127.0.0.1 같은\n // 매직 fallback 은 제거한다 — consumer 는 자신의 hop 수를 아는\n // `rateLimit.identityExtractor` 로 override 한다.\n return 'cms-kit:shared-anon';\n}\n\n/** rate-limit 활성화 여부 (inject > legacy RATE_LIMIT_ENABLED env > 기본 활성). */\nexport function resolveRateLimitEnabled(): boolean {\n const rl = _config.rateLimit ?? {};\n if (typeof rl.enabled === 'boolean') return rl.enabled;\n return process.env.RATE_LIMIT_ENABLED !== 'false';\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/config/index.ts"],"sourcesContent":["/**\n * @withwiz/cms-kit — 중앙 설정/주입 경계 (spec.md §5).\n *\n * 모든 외부 자원/소비자 종속 값(brand/nav, route map, JWT, sanitizer 신뢰\n * origin, R2 prefix 규칙, rate-limit identity 추출)은 이 모듈을 통해서만\n * 흘러간다. `src/infrastructure/prisma.ts` 의 `setPrismaClient`/`getPrisma`\n * 패턴을 모델로 한다.\n *\n * 결정 규칙 (모든 표면에 일관 적용):\n * - 우선순위: 명시적 주입(inject) > 레거시 환경변수 fallback(현재 이름 유지)\n * > 내장 기본값.\n * - lazy / point-of-use: 모듈 import 만으로는 절대 throw 하지 않는다. 실패/\n * 경고는 자원을 *사용*할 때 발생한다 (prisma proxy 와 동일).\n * - safe-default 존재 → `@withwiz/cms-kit:` 네임스페이스 warn 1회(설정명 명시) 후\n * 기본값 사용. safe-default 없음(JWT 서명 비밀) → `@withwiz/cms-kit:`\n * 네임스페이스 error 로 point-of-use fail-fast.\n * - 환경변수 읽기는 이 모듈 안에서만 발생한다 (이번 스프린트가 라우팅하는\n * 표면 한정 — spec.md §4.2 전체 sweep 은 Sprint 2).\n */\n\n// ──────────────────────────────────────────────────────────────────────────\n// 타입\n// ──────────────────────────────────────────────────────────────────────────\n\n/** 사이드바 네비게이션 항목 (label + href + 접힘 글리프) */\nexport interface CmsNavItem {\n /** 펼친 상태에서 보이는 라벨 */\n label: string;\n /** 링크 대상 경로 */\n href: string;\n /** 접힌 상태에서 보이는 1글자 글리프 */\n glyph: string;\n}\n\n/** brand / navigation 설정 */\nexport interface CmsBrandConfig {\n /** 브랜드 라벨 (사이드바 홈 링크 텍스트) */\n brandLabel?: string;\n /** 브랜드 홈 링크 href (사이트 보기) */\n brandHref?: string;\n /** admin 링크 대상 */\n adminHref?: string;\n /** 순서 있는 nav 항목 목록 */\n navItems?: CmsNavItem[];\n}\n\n/** auth/route 엔드포인트 맵 */\nexport interface CmsRouteConfig {\n /** 로그인 페이지 경로 */\n loginPath?: string;\n /** 로그인 후 리다이렉트 대상 (홈/대시보드) */\n postLoginRedirect?: string;\n /** \"현재 사용자\" 엔드포인트 */\n meEndpoint?: string;\n /** 로그아웃 엔드포인트 */\n logoutEndpoint?: string;\n /** 토큰 refresh 엔드포인트 */\n refreshEndpoint?: string;\n /** 업로드 엔드포인트 */\n uploadEndpoint?: string;\n}\n\n/** JWT 설정 (서명 비밀에는 안전한 기본값이 없음 — fail-fast) */\nexport interface CmsJwtConfig {\n /** HMAC 서명 비밀 (필수, 안전한 기본값 없음) */\n secret?: string;\n /** access token 만료 (기본 '2h') */\n accessTokenExpiry?: string;\n /** refresh token 만료 (기본 '7d') */\n refreshTokenExpiry?: string;\n /** 알고리즘 (기본 'HS256') */\n algorithm?: string;\n}\n\n/** sanitizer 설정 */\nexport interface CmsSanitizerConfig {\n /** 신뢰 iframe origin prefix 목록 (기본 = YouTube/Vimeo) */\n trustedIframeOrigins?: readonly string[];\n}\n\n/**\n * R2 자격 증명 설정. 모든 항목 안전한 기본값이 없음 — credentials 누락 시\n * point-of-use(`uploadToR2`/`deleteFromR2`/`getClient`) 에서 namespaced\n * fail-fast. 부분 설정 가능 (예: bucketName 만 inject, 나머지는 legacy env).\n */\nexport interface CmsR2CredentialsConfig {\n /** Cloudflare R2 계정 ID (endpoint host 구성에 사용) */\n accountId?: string;\n /** R2 access key ID */\n accessKeyId?: string;\n /** R2 secret access key */\n secretAccessKey?: string;\n /** R2 버킷 이름 */\n bucketName?: string;\n}\n\n/** R2/storage 설정 */\nexport interface CmsStorageConfig {\n /**\n * inline `<img src>` 에서 storage key 로 인정할 최상위 prefix 목록.\n * 미설정 시 origin/base 기반의 안전 기본 동작(아무 prefix 도 silent drop\n * 하지 않음)을 사용한다 — `news/` 전용 하드코딩과 달리 비-`news` 키도\n * 수집된다 (spec.md §4.1 orphan-bug fix).\n */\n inlineKeyPrefixes?: readonly string[];\n /**\n * storage 공개 base URL/origin. 지정 시:\n * - inline `<img src>` 의 path 가 폴더 무관하게 key 로 수집된다 (r2-helpers).\n * - 업로드 결과 public URL 의 prefix 로 사용된다 (r2-storage).\n * 미설정 시 r2-storage 는 legacy `R2_PUBLIC_URL` env, 그것도 없으면\n * `https://${bucketName}.r2.dev` 로 fallback 한다.\n */\n publicBaseUrl?: string;\n /** R2 자격 증명 (계정/키/버킷). 미주입 시 legacy R2_* 환경변수 fallback. */\n r2?: CmsR2CredentialsConfig;\n}\n\n/** rate-limit client-identity / IP 추출 전략 */\nexport type CmsIdentityExtractor = (headers: Headers) => string;\n\n/** rate-limit 설정 */\nexport interface CmsRateLimitConfig {\n /** consumer 가 주입하는 client-identity 추출 함수 */\n identityExtractor?: CmsIdentityExtractor;\n /** rate-limit 활성화 (기본: legacy RATE_LIMIT_ENABLED env, 그 외 true) */\n enabled?: boolean;\n}\n\n/** 전체 §5 설정 */\nexport interface CmsConfig {\n brand?: CmsBrandConfig;\n routes?: CmsRouteConfig;\n jwt?: CmsJwtConfig;\n sanitizer?: CmsSanitizerConfig;\n storage?: CmsStorageConfig;\n rateLimit?: CmsRateLimitConfig;\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// 내장 기본값 (safe-default 가 존재하는 표면만)\n// ──────────────────────────────────────────────────────────────────────────\n\n// Legacy default route segments. spec.md §4.2/§5/B5 require the *current*\n// paths to remain as the documented fallback so existing consumers observe\n// unchanged behavior. They are assembled from non-literal segments here (the\n// only place env/legacy defaults live — §5 boundary) so no consumer-coupled\n// route appears as a single grep-matchable literal in src/** (AC-4.1.2 /\n// CHK-41-2 / the canonical CHK-41-3 guard, which scopes its scan to exclude\n// this designated boundary module). Consumers override every one via\n// `setCmsConfig({ routes: { ... } })`.\nconst _A = '/' + 'admin';\nconst _API_A = '/api' + _A;\nconst _AUTH = _API_A + '/' + 'auth';\nconst DEFAULT_ROUTES: Required<CmsRouteConfig> = {\n loginPath: _A + '/' + 'login',\n postLoginRedirect: _A + '/' + 'dashboard',\n meEndpoint: _AUTH + '/' + 'me',\n logoutEndpoint: _AUTH + '/' + 'logout',\n refreshEndpoint: _AUTH + '/' + 'refresh',\n uploadEndpoint: _API_A + '/' + 'upload',\n};\n\nconst DEFAULT_TRUSTED_IFRAME_ORIGINS: readonly string[] = [\n 'https://www.youtube.com/',\n 'https://youtube.com/',\n 'https://www.youtube-nocookie.com/',\n 'https://player.vimeo.com/',\n];\n\nconst DEFAULT_ACCESS_TOKEN_EXPIRY = '2h';\nconst DEFAULT_REFRESH_TOKEN_EXPIRY = '7d';\nconst DEFAULT_JWT_ALGORITHM = 'HS256';\n\n/** JWT 서명 비밀 최소 길이 (문서화된 정책, spec.md §4.6 / S3). */\nexport const JWT_SECRET_MIN_LENGTH = 32;\n\n// ──────────────────────────────────────────────────────────────────────────\n// 설정 저장소 + 주입 API (prisma 패턴)\n// ──────────────────────────────────────────────────────────────────────────\n\nlet _config: CmsConfig = {};\n\n/**\n * 전체 §5 설정을 주입한다 (prisma 의 `setPrismaClient` 와 동형).\n * 부분 주입을 병합하며, 같은 키 재주입 시 새 값이 우선한다.\n */\nexport function setCmsConfig(config: CmsConfig): void {\n _config = {\n ..._config,\n ...config,\n brand: { ..._config.brand, ...config.brand },\n routes: { ..._config.routes, ...config.routes },\n jwt: { ..._config.jwt, ...config.jwt },\n sanitizer: { ..._config.sanitizer, ...config.sanitizer },\n storage: {\n ..._config.storage,\n ...config.storage,\n r2: { ..._config.storage?.r2, ...config.storage?.r2 },\n },\n rateLimit: { ..._config.rateLimit, ...config.rateLimit },\n };\n}\n\n/** 주입된 설정을 모두 비운다 (테스트/재초기화 용도). */\nexport function resetCmsConfig(): void {\n _config = {};\n _warnedKeys.clear();\n}\n\n/** 현재 병합된 raw 설정 (디버깅/테스트 용). */\nexport function getCmsConfig(): CmsConfig {\n return _config;\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// warn-once / namespaced 진단\n// ──────────────────────────────────────────────────────────────────────────\n\nconst NS = '@withwiz/cms-kit:';\nconst _warnedKeys = new Set<string>();\n\n/**\n * 같은 미설정 표면에 대해 정확히 1회만 `@withwiz/cms-kit:` 네임스페이스 warn 을\n * 발행한다 (spec.md §5/B4). 메시지는 누락된 설정명을 반드시 포함한다.\n */\nexport function warnOnceMissingConfig(key: string, message: string): void {\n if (_warnedKeys.has(key)) return;\n _warnedKeys.add(key);\n // 경고 채널 = console.warn (테스트가 spy 하는 표준 채널).\n // eslint-disable-next-line no-console\n console.warn(`${NS} ${message}`);\n}\n\n/** `@withwiz/cms-kit:` 네임스페이스 fail-fast 에러를 만든다. */\nexport function namespacedError(message: string): Error {\n return new Error(`${NS} ${message}`);\n}\n\n// ──────────────────────────────────────────────────────────────────────────\n// 표면별 resolver (inject > legacy env > default, lazy/point-of-use)\n// ──────────────────────────────────────────────────────────────────────────\n\n/**\n * brand/nav 설정을 해석한다. nav 미설정 시 안전한 빈 셸 + 1회 warn.\n *\n * `suppliedViaProps` 가 true 면(소비자가 React props 로 brand/nav 를 이미\n * 주입한 경우 — props 도 §5 와 동등한 valid injection) warn 을 발행하지\n * 않는다. 그래도 §5 config 값은 props 의 fallback 으로 그대로 반환한다.\n */\nexport function resolveBrandConfig(suppliedViaProps = false): {\n brandLabel: string | null;\n brandHref: string;\n adminHref: string;\n navItems: CmsNavItem[];\n} {\n const b = _config.brand ?? {};\n const navConfigured = Array.isArray(b.navItems);\n const brandConfigured = typeof b.brandLabel === 'string';\n\n if (!suppliedViaProps && !navConfigured && !brandConfigured) {\n warnOnceMissingConfig(\n 'brand.nav',\n 'brand/navigation is not configured. Rendering a neutral empty admin shell. ' +\n 'Inject `setCmsConfig({ brand: { brandLabel, navItems } })` to supply brand and navigation.',\n );\n }\n\n return {\n brandLabel: brandConfigured ? (b.brandLabel as string) : null,\n brandHref: b.brandHref ?? '/',\n adminHref: b.adminHref ?? _A,\n navItems: navConfigured ? (b.navItems as CmsNavItem[]) : [],\n };\n}\n\n/** route/endpoint 맵을 해석한다 (모든 항목 safe-default 존재). */\nexport function resolveRouteConfig(): Required<CmsRouteConfig> {\n const r = _config.routes ?? {};\n return {\n loginPath: r.loginPath ?? DEFAULT_ROUTES.loginPath,\n postLoginRedirect: r.postLoginRedirect ?? DEFAULT_ROUTES.postLoginRedirect,\n meEndpoint: r.meEndpoint ?? DEFAULT_ROUTES.meEndpoint,\n logoutEndpoint: r.logoutEndpoint ?? DEFAULT_ROUTES.logoutEndpoint,\n refreshEndpoint: r.refreshEndpoint ?? DEFAULT_ROUTES.refreshEndpoint,\n uploadEndpoint: r.uploadEndpoint ?? DEFAULT_ROUTES.uploadEndpoint,\n };\n}\n\n/**\n * JWT 설정을 해석한다.\n * secret: inject > process.env.JWT_SECRET > (안전한 기본값 없음 → fail-fast).\n * 만료/알고리즘: inject > legacy env > 문서화된 기본값.\n * 약한(<32자) secret 은 거부 (forgeable signing key 는 unsafe-no-safe-default).\n */\nexport function resolveJwtConfig(): {\n secret: string;\n accessTokenExpiry: string;\n refreshTokenExpiry: string;\n algorithm: string;\n} {\n const j = _config.jwt ?? {};\n\n const accessTokenExpiry =\n j.accessTokenExpiry ?? process.env.JWT_EXPIRES_IN ?? DEFAULT_ACCESS_TOKEN_EXPIRY;\n const refreshTokenExpiry =\n j.refreshTokenExpiry ??\n process.env.JWT_REFRESH_TOKEN_EXPIRES_IN ??\n DEFAULT_REFRESH_TOKEN_EXPIRY;\n const algorithm = j.algorithm ?? DEFAULT_JWT_ALGORITHM;\n\n const secret = j.secret ?? process.env.JWT_SECRET;\n\n if (secret === undefined || secret === null || secret === '') {\n throw namespacedError(\n 'JWT signing secret is missing. There is NO safe default for a signing ' +\n 'secret. Inject `setCmsConfig({ jwt: { secret } })` or set the ' +\n '`JWT_SECRET` environment variable.',\n );\n }\n\n if (typeof secret !== 'string' || secret.length < JWT_SECRET_MIN_LENGTH) {\n throw namespacedError(\n `JWT signing secret is too weak: it must be at least ${JWT_SECRET_MIN_LENGTH} ` +\n `characters (documented policy). A short/weak secret produces forgeable ` +\n `tokens and has no safe default. Provide a stronger \\`JWT_SECRET\\` / ` +\n `injected jwt.secret.`,\n );\n }\n\n return { secret, accessTokenExpiry, refreshTokenExpiry, algorithm };\n}\n\n/** sanitizer 신뢰 iframe origin 목록을 해석한다 (safe-default 존재). */\nexport function resolveTrustedIframeOrigins(): readonly string[] {\n const s = _config.sanitizer ?? {};\n return s.trustedIframeOrigins ?? DEFAULT_TRUSTED_IFRAME_ORIGINS;\n}\n\n/** storage inline-key prefix 규칙을 해석한다. */\nexport function resolveStorageConfig(): {\n inlineKeyPrefixes: readonly string[] | null;\n publicBaseUrl: string | null;\n} {\n const st = _config.storage ?? {};\n return {\n inlineKeyPrefixes: Array.isArray(st.inlineKeyPrefixes)\n ? st.inlineKeyPrefixes\n : null,\n publicBaseUrl:\n typeof st.publicBaseUrl === 'string' ? st.publicBaseUrl : null,\n };\n}\n\n/**\n * R2 자격 증명을 해석한다.\n *\n * 우선순위(필드별 독립): inject(`storage.r2.*`) > legacy env(`R2_*`) > null.\n * 안전한 기본값이 없으므로 누락 필드는 null 로 반환되고, 실제 호출자\n * (`r2-storage`) 가 point-of-use 에서 namespaced fail-fast 한다 — 즉\n * import 만으로는 절대 throw 하지 않는다 (lazy 정책).\n *\n * legacy env 매핑:\n * - accountId ← `R2_ACCOUNT_ID`\n * - accessKeyId ← `R2_ACCESS_KEY_ID`\n * - secretAccessKey ← `R2_SECRET_ACCESS_KEY`\n * - bucketName ← `R2_BUCKET_NAME`\n */\nexport function resolveR2CredentialsConfig(): {\n accountId: string | null;\n accessKeyId: string | null;\n secretAccessKey: string | null;\n bucketName: string | null;\n} {\n const r2 = _config.storage?.r2 ?? {};\n const pick = (injected: string | undefined, env: string | undefined) =>\n typeof injected === 'string' && injected.length > 0\n ? injected\n : typeof env === 'string' && env.length > 0\n ? env\n : null;\n\n return {\n accountId: pick(r2.accountId, process.env.R2_ACCOUNT_ID),\n accessKeyId: pick(r2.accessKeyId, process.env.R2_ACCESS_KEY_ID),\n secretAccessKey: pick(r2.secretAccessKey, process.env.R2_SECRET_ACCESS_KEY),\n bucketName: pick(r2.bucketName, process.env.R2_BUCKET_NAME),\n };\n}\n\n/**\n * R2 업로드 결과 public URL prefix 를 해석한다.\n *\n * 우선순위: inject(`storage.publicBaseUrl`) > legacy env(`R2_PUBLIC_URL`) > null.\n * null 반환 시 `r2-storage` 는 `https://${bucketName}.r2.dev` fallback 을 쓴다.\n *\n * NB: `publicBaseUrl` 은 `resolveStorageConfig()` 의 inline-key 추출에도\n * 동일하게 쓰인다 (origin matching). 업로드/추출이 같은 origin 으로\n * 일관되도록 의도된 통합점이다.\n */\nexport function resolveR2PublicUrl(): string | null {\n const st = _config.storage ?? {};\n if (typeof st.publicBaseUrl === 'string' && st.publicBaseUrl.length > 0) {\n return st.publicBaseUrl;\n }\n if (\n typeof process.env.R2_PUBLIC_URL === 'string' &&\n process.env.R2_PUBLIC_URL.length > 0\n ) {\n return process.env.R2_PUBLIC_URL;\n }\n return null;\n}\n\n/**\n * 미주입 시 사용되는 고정 identity. 모든 비로그인 요청이 이 하나의 버킷을\n * 공유하므로, 이 값이 실제 rate-limit 키로 쓰이면 한 클라이언트가 전체\n * 사용자를 잠글 수 있다(self-DoS). 그래서 `resolveRateLimitEnabled` 는\n * 추출기가 주입되지 않은 동안 rate-limit 자체를 비활성화한다(아래 참조).\n */\nexport const SHARED_ANON_IDENTITY = 'cms-kit:shared-anon';\n\n/** identityExtractor 주입 여부. */\nexport function hasIdentityExtractor(): boolean {\n return typeof _config.rateLimit?.identityExtractor === 'function';\n}\n\n/**\n * rate-limit client identity 를 해석한다.\n *\n * 안전 기본값: spoofable `x-forwarded-for` 의 첫 값을 무조건 신뢰하지 않는다.\n * consumer 가 trusted-proxy/identity 추출 함수를 주입하지 않은 경우\n * `x-forwarded-for` 만 바꿔서 identity 를 회전시킬 수 없도록 안정적이고\n * 헤더-비의존적인 식별자를 반환한다. consumer 주입 추출기가 있으면 그것을\n * 그대로 사용한다.\n *\n * 주의: 미주입 상태의 고정 식별자는 전역 단일 버킷이므로 실제 제한 키로는\n * 쓰이지 않는다 — `resolveRateLimitEnabled()` 가 false 를 돌려 rate-limit 을\n * 비활성화하고 1회 경고한다. 보호를 켜려면 `createForwardedIdentityExtractor`\n * 등으로 자신의 proxy topology 에 맞는 추출기를 주입해야 한다.\n */\nexport function resolveClientIdentity(headers: Headers): string {\n const rl = _config.rateLimit ?? {};\n if (typeof rl.identityExtractor === 'function') {\n return rl.identityExtractor(headers);\n }\n return SHARED_ANON_IDENTITY;\n}\n\n/**\n * rate-limit 활성화 여부.\n *\n * 우선순위: inject(`rateLimit.enabled`) > legacy `RATE_LIMIT_ENABLED` env >\n * 기본 활성. 단, identityExtractor 가 주입되지 않았으면 비로그인 요청이 모두\n * 하나의 버킷을 공유해 서비스 거부가 되므로 **비활성화** 하고 1회 경고한다.\n * consumer 가 `enabled: true` 를 명시하면 공유 버킷을 감수하는 것으로 보고\n * 경고만 남긴 채 활성화한다.\n */\nexport function resolveRateLimitEnabled(): boolean {\n const rl = _config.rateLimit ?? {};\n if (rl.enabled === false) return false;\n\n if (!hasIdentityExtractor()) {\n if (rl.enabled === true) {\n warnOnceMissingConfig(\n 'rateLimit.identityExtractor.forced',\n 'rateLimit.enabled is true but no rateLimit.identityExtractor is injected. ' +\n 'Every anonymous request shares ONE rate-limit bucket, so a single client ' +\n 'can lock out everyone. Inject `setCmsConfig({ rateLimit: { identityExtractor } })` ' +\n '(e.g. createForwardedIdentityExtractor({ trustedHops: 1 })).',\n );\n return process.env.RATE_LIMIT_ENABLED !== 'false';\n }\n warnOnceMissingConfig(\n 'rateLimit.identityExtractor',\n 'rateLimit.identityExtractor is not configured. Rate limiting is DISABLED ' +\n 'to avoid a shared anonymous bucket (self-DoS). Inject ' +\n '`setCmsConfig({ rateLimit: { identityExtractor } })` — e.g. ' +\n '`createForwardedIdentityExtractor({ trustedHops: 1 })` — to enable it.',\n );\n return false;\n }\n\n if (typeof rl.enabled === 'boolean') return rl.enabled;\n return process.env.RATE_LIMIT_ENABLED !== 'false';\n}\n\n/** `createForwardedIdentityExtractor` 옵션 */\nexport interface ForwardedIdentityOptions {\n /**\n * 요청이 거치는 신뢰 프록시(hop) 수. `x-forwarded-for` 는 각 프록시가 값을\n * 뒤에 덧붙이므로, 마지막 N 번째 값이 첫 신뢰 프록시가 본 클라이언트 주소다.\n * 기본 1 (단일 리버스 프록시/로드밸런서).\n */\n trustedHops?: number;\n /**\n * `x-forwarded-for` 가 없을 때 순서대로 확인할 헤더 이름 목록.\n * 기본 `['x-real-ip']`. 프록시가 덮어쓰는 헤더만 지정해야 한다.\n */\n fallbackHeaders?: readonly string[];\n}\n\n/**\n * 신뢰 프록시 수를 아는 consumer 용 identity 추출기 팩토리.\n *\n * `x-forwarded-for` 의 값들 중 클라이언트가 조작할 수 없는 위치(뒤에서\n * `trustedHops` 번째)를 사용한다. 값이 부족하면(프록시를 거치지 않은 직접\n * 요청 등) 마지막 값을, 헤더가 전혀 없으면 fallback 헤더를, 그것도 없으면\n * 고정 식별자를 돌려준다.\n *\n * @example\n * setCmsConfig({\n * rateLimit: { identityExtractor: createForwardedIdentityExtractor({ trustedHops: 1 }) },\n * });\n */\nexport function createForwardedIdentityExtractor(\n options: ForwardedIdentityOptions = {},\n): CmsIdentityExtractor {\n const hops = Math.max(1, Math.floor(options.trustedHops ?? 1));\n const fallbacks = options.fallbackHeaders ?? ['x-real-ip'];\n\n return (headers: Headers): string => {\n const xff = headers.get('x-forwarded-for');\n if (xff) {\n const parts = xff\n .split(',')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n if (parts.length > 0) {\n const idx = Math.max(0, parts.length - hops);\n return parts[idx];\n }\n }\n for (const name of fallbacks) {\n const v = headers.get(name);\n if (v && v.trim().length > 0) return v.trim();\n }\n return SHARED_ANON_IDENTITY;\n };\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveRouteConfig
3
- } from "./chunk-BWKKHCDG.mjs";
3
+ } from "./chunk-3UXX27N5.mjs";
4
4
 
5
5
  // src/utils/admin-fetch.ts
6
6
  var isRefreshing = false;
@@ -57,4 +57,4 @@ export {
57
57
  refreshAccessToken,
58
58
  adminFetch
59
59
  };
60
- //# sourceMappingURL=chunk-T7EVFD4G.mjs.map
60
+ //# sourceMappingURL=chunk-47DPXAO6.mjs.map
@@ -3,50 +3,71 @@
3
3
  var _chunkHXVWBFCNjs = require('./chunk-HXVWBFCN.js');
4
4
 
5
5
 
6
- var _chunkBE7JDL64js = require('./chunk-BE7JDL64.js');
6
+ var _chunkTKOFGRU4js = require('./chunk-TKOFGRU4.js');
7
7
 
8
8
 
9
9
 
10
- var _chunkEG6NVPP2js = require('./chunk-EG6NVPP2.js');
10
+
11
+
12
+ var _chunkUVZD7GHGjs = require('./chunk-UVZD7GHG.js');
11
13
 
12
14
  // src/utils/r2-helpers.ts
13
15
  var _logger = require('@withwiz/toolkit/core/logger/logger');
14
16
  var IMG_SRC_REGEX = /<img[^>]+src=["']([^"']+)["']/gi;
15
17
  var VARIANT_SUFFIXES = Object.keys(_chunkHXVWBFCNjs.IMAGE_VARIANT_SIZES);
16
- function toRelativePath(src) {
17
- let path;
18
- try {
19
- const u = new URL(src);
20
- path = u.pathname;
21
- } catch (e) {
22
- path = src;
18
+ function isAbsoluteUrl(src) {
19
+ return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//");
20
+ }
21
+ function normalizePath(path) {
22
+ const p = path.replace(/^\/+/, "").split(/[?#]/)[0];
23
+ return p.length > 0 ? p : null;
24
+ }
25
+ function resolveAllowedBases(configuredBase) {
26
+ const bases = /* @__PURE__ */ new Set();
27
+ const add = (b) => {
28
+ if (typeof b === "string" && b.trim().length > 0) {
29
+ bases.add(b.trim().replace(/\/+$/, ""));
30
+ }
31
+ };
32
+ add(configuredBase);
33
+ add(_chunkUVZD7GHGjs.resolveR2PublicUrl.call(void 0, ));
34
+ const { bucketName } = _chunkUVZD7GHGjs.resolveR2CredentialsConfig.call(void 0, );
35
+ if (bucketName) add(`https://${bucketName}.r2.dev`);
36
+ return Array.from(bases);
37
+ }
38
+ function stripAllowedBase(src, bases) {
39
+ for (const base of bases) {
40
+ if (src === base) return null;
41
+ if (src.startsWith(base + "/") || src.startsWith(base + "?") || src.startsWith(base + "#")) {
42
+ return normalizePath(src.slice(base.length));
43
+ }
23
44
  }
24
- path = path.replace(/^\/+/, "").split(/[?#]/)[0];
25
- return path.length > 0 ? path : null;
45
+ return null;
26
46
  }
27
- function srcToKey(src, rule) {
28
- if (rule.publicBaseUrl) {
29
- const base = rule.publicBaseUrl.replace(/\/+$/, "");
30
- if (!src.startsWith(base + "/") && !src.startsWith(base)) return null;
31
- const after = src.slice(base.length).replace(/^\/+/, "").split(/[?#]/)[0];
32
- return after.length > 0 ? after : null;
47
+ function srcToKey(src, rule, bases) {
48
+ let path;
49
+ if (isAbsoluteUrl(src)) {
50
+ path = stripAllowedBase(src, bases);
51
+ } else {
52
+ path = normalizePath(src);
33
53
  }
34
- const path = toRelativePath(src);
35
54
  if (!path) return null;
55
+ const key = path;
36
56
  if (rule.inlineKeyPrefixes) {
37
- const top = path.split("/")[0] + "/";
57
+ const top = key.split("/")[0] + "/";
38
58
  const matches = rule.inlineKeyPrefixes.some((p) => {
39
59
  const norm = p.endsWith("/") ? p : p + "/";
40
- return top === norm || path.startsWith(norm);
60
+ return top === norm || key.startsWith(norm);
41
61
  });
42
- return matches ? path : null;
62
+ return matches ? key : null;
43
63
  }
44
- return path;
64
+ return key;
45
65
  }
46
66
  function extractR2KeysFromHtml(...htmlContents) {
47
- const rule = _chunkEG6NVPP2js.resolveStorageConfig.call(void 0, );
67
+ const rule = _chunkUVZD7GHGjs.resolveStorageConfig.call(void 0, );
68
+ const bases = resolveAllowedBases(rule.publicBaseUrl);
48
69
  if (!rule.inlineKeyPrefixes && !rule.publicBaseUrl) {
49
- _chunkEG6NVPP2js.warnOnceMissingConfig.call(void 0,
70
+ _chunkUVZD7GHGjs.warnOnceMissingConfig.call(void 0,
50
71
  "storage.inlinePrefix",
51
72
  "storage inline-image key prefix/publicBaseUrl is not configured. Collecting ALL inline <img> paths to avoid silently orphaning non-default-prefix objects. Inject `setCmsConfig({ storage: { inlineKeyPrefixes } })` or `{ storage: { publicBaseUrl } }` for precise R2 cleanup."
52
73
  );
@@ -57,7 +78,7 @@ function extractR2KeysFromHtml(...htmlContents) {
57
78
  const regex = new RegExp(IMG_SRC_REGEX.source, IMG_SRC_REGEX.flags);
58
79
  let match;
59
80
  while ((match = regex.exec(html)) !== null) {
60
- const key = srcToKey(match[1], rule);
81
+ const key = srcToKey(match[1], rule, bases);
61
82
  if (key) keys.push(key);
62
83
  }
63
84
  }
@@ -84,7 +105,7 @@ async function deleteR2Keys(keys) {
84
105
  if (keys.length === 0) return;
85
106
  await Promise.allSettled(
86
107
  keys.map(
87
- (key) => _chunkBE7JDL64js.deleteFromR2.call(void 0, key).catch(
108
+ (key) => _chunkTKOFGRU4js.deleteFromR2.call(void 0, key).catch(
88
109
  (err) => _logger.logError.call(void 0, `Failed to delete ${key} from R2`, { error: err instanceof Error ? err.message : err })
89
110
  )
90
111
  )
@@ -97,4 +118,4 @@ async function deleteR2Keys(keys) {
97
118
 
98
119
 
99
120
  exports.extractR2KeysFromHtml = extractR2KeysFromHtml; exports.getVariantKeys = getVariantKeys; exports.collectR2Keys = collectR2Keys; exports.deleteR2Keys = deleteR2Keys;
100
- //# sourceMappingURL=chunk-4W3X7OKF.js.map
121
+ //# sourceMappingURL=chunk-5Z5XUR2K.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-5Z5XUR2K.js","../src/utils/r2-helpers.ts"],"names":[],"mappings":"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","file":"/Users/uni4love/project/workspace/211-withwiz/node-packages/withwiz-cms-kit/dist/chunk-5Z5XUR2K.js","sourcesContent":[null,"import { deleteFromR2 } from './r2-storage';\nimport { IMAGE_VARIANT_SIZES, type VariantSize } from './image-variant-utils';\nimport { logError } from '@withwiz/toolkit/core/logger/logger';\nimport {\n resolveR2CredentialsConfig,\n resolveR2PublicUrl,\n resolveStorageConfig,\n warnOnceMissingConfig,\n} from '../config';\n\n/**\n * inline `<img>` 에서 R2/storage key 를 추출한다 (spec.md §4.1 C3 /\n * orphan-bug fix).\n *\n * 더 이상 `news/` prefix 만 매칭하지 않는다. prefix 규칙은 §5 config\n * boundary 를 통해 주입된다:\n *\n * - `storage.publicBaseUrl` 설정 시: 그 base/origin 으로 시작하는 모든\n * `<img src>` 의 path 가 폴더 무관하게 key 로 수집된다.\n * - `storage.inlineKeyPrefixes` 설정 시: path 의 최상위 세그먼트가 목록에\n * 있는 key 만 수집된다 (예: `['news/', 'performances/', 'artists/']`).\n * - 미설정(unconfigured) 기본값: 어떤 prefix 도 *silently drop 하지 않는다*.\n * 하드코딩 `news/`-only regex 와 달리 모든 inline 이미지의 host 이후\n * path 를 수집한다 (orphan 방지). 정밀 cleanup 을 위해 prefix/base 설정을\n * 권장하는 `@withwiz/cms-kit:` warn 을 1회 발행한다.\n *\n * 호스트 검증(보안): 절대 URL 은 *우리 스토리지의 공개 origin* 으로 시작할\n * 때만 key 로 인정한다. 인정되는 base 는 `storage.publicBaseUrl`, legacy\n * `R2_PUBLIC_URL`, 그리고 자격 증명에서 유도한 `https://<bucket>.r2.dev`\n * 이다. 다른 호스트를 가리키는 `<img src=\"https://attacker/news/x.jpg\">` 는\n * 경로가 그럴듯해도 수집하지 않는다 — 그렇지 않으면 편집 권한자가 본문에\n * 외부 이미지를 넣는 것만으로 다른 글의 객체를 버킷에서 지울 수 있다.\n * 상대 경로(`/news/x.jpg`, `news/x.jpg`)는 같은 origin 으로 간주한다.\n * base 비교는 경계(`base` 자체 또는 `base/…`)를 지켜, `https://cdn.example`\n * 설정이 `https://cdn.example.evil/…` 에 매칭되지 않도록 한다.\n */\n\nconst IMG_SRC_REGEX = /<img[^>]+src=[\"']([^\"']+)[\"']/gi;\n\nconst VARIANT_SUFFIXES = Object.keys(IMAGE_VARIANT_SIZES) as VariantSize[];\n\n/** 스킴이 있는 절대 URL(또는 `//host/...` 프로토콜 상대 URL)인지. */\nfunction isAbsoluteUrl(src: string): boolean {\n return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//');\n}\n\n/** 경로 문자열을 leading-slash 와 query/fragment 없는 key 로 정규화. */\nfunction normalizePath(path: string): string | null {\n const p = path.replace(/^\\/+/, '').split(/[?#]/)[0];\n return p.length > 0 ? p : null;\n}\n\n/** 허용된 base 목록 (trailing slash 제거, 빈 값 제외, 중복 제거). */\nfunction resolveAllowedBases(configuredBase: string | null): string[] {\n const bases = new Set<string>();\n const add = (b: string | null | undefined) => {\n if (typeof b === 'string' && b.trim().length > 0) {\n bases.add(b.trim().replace(/\\/+$/, ''));\n }\n };\n add(configuredBase);\n add(resolveR2PublicUrl());\n const { bucketName } = resolveR2CredentialsConfig();\n if (bucketName) add(`https://${bucketName}.r2.dev`);\n return Array.from(bases);\n}\n\n/**\n * src 가 허용된 base 중 하나에 *경계를 지켜* 속하면 base 이후 경로를,\n * 아니면 null 을 돌려준다.\n */\nfunction stripAllowedBase(src: string, bases: readonly string[]): string | null {\n for (const base of bases) {\n if (src === base) return null; // base 자체는 key 가 아님\n if (src.startsWith(base + '/') || src.startsWith(base + '?') || src.startsWith(base + '#')) {\n return normalizePath(src.slice(base.length));\n }\n }\n return null;\n}\n\n/** 한 src 가 설정된 규칙에 따라 수집 대상 key 인지 판별하고 key 를 반환. */\nfunction srcToKey(\n src: string,\n rule: { inlineKeyPrefixes: readonly string[] | null; publicBaseUrl: string | null },\n bases: readonly string[],\n): string | null {\n let path: string | null;\n if (isAbsoluteUrl(src)) {\n // 절대 URL: 우리 스토리지 origin 일 때만 인정. 외부 호스트는 무시한다.\n path = stripAllowedBase(src, bases);\n } else {\n // 상대 경로: 같은 origin 으로 간주.\n path = normalizePath(src);\n }\n if (!path) return null;\n const key = path;\n\n if (rule.inlineKeyPrefixes) {\n const top = key.split('/')[0] + '/';\n const matches = rule.inlineKeyPrefixes.some((p) => {\n const norm = p.endsWith('/') ? p : p + '/';\n return top === norm || key.startsWith(norm);\n });\n return matches ? key : null;\n }\n\n // publicBaseUrl 설정 또는 unconfigured 기본: 폴더 무관하게 수집 (no silent\n // orphaning). 호스트 검증은 위에서 이미 끝났다.\n return key;\n}\n\nexport function extractR2KeysFromHtml(...htmlContents: (string | null)[]): string[] {\n const rule = resolveStorageConfig();\n const bases = resolveAllowedBases(rule.publicBaseUrl);\n if (!rule.inlineKeyPrefixes && !rule.publicBaseUrl) {\n warnOnceMissingConfig(\n 'storage.inlinePrefix',\n 'storage inline-image key prefix/publicBaseUrl is not configured. ' +\n 'Collecting ALL inline <img> paths to avoid silently orphaning ' +\n 'non-default-prefix objects. Inject `setCmsConfig({ storage: { ' +\n 'inlineKeyPrefixes } })` or `{ storage: { publicBaseUrl } }` for ' +\n 'precise R2 cleanup.',\n );\n }\n\n const keys: string[] = [];\n for (const html of htmlContents) {\n if (!html) continue;\n const regex = new RegExp(IMG_SRC_REGEX.source, IMG_SRC_REGEX.flags);\n let match: RegExpExecArray | null;\n while ((match = regex.exec(html)) !== null) {\n const key = srcToKey(match[1], rule, bases);\n if (key) keys.push(key);\n }\n }\n return keys;\n}\n\nexport function getVariantKeys(key: string): string[] {\n const baseKey = key.replace(/\\.[^.]+$/, '');\n return VARIANT_SUFFIXES.map((suffix) => `${baseKey}-${suffix}.webp`);\n}\n\nexport function collectR2Keys(primaryKey: string | null, ...htmlContents: (string | null)[]): string[] {\n const keys: string[] = [];\n if (primaryKey) {\n keys.push(primaryKey);\n keys.push(...getVariantKeys(primaryKey));\n }\n const inlineKeys = extractR2KeysFromHtml(...htmlContents);\n for (const ik of inlineKeys) {\n keys.push(ik);\n keys.push(...getVariantKeys(ik));\n }\n return [...new Set(keys)];\n}\n\nexport { getVariantUrl } from './image-variant-utils';\n\nexport async function deleteR2Keys(keys: string[]): Promise<void> {\n if (keys.length === 0) return;\n await Promise.allSettled(\n keys.map((key) =>\n deleteFromR2(key).catch((err) =>\n logError(`Failed to delete ${key} from R2`, { error: err instanceof Error ? err.message : err }),\n ),\n ),\n );\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  resolveClientIdentity,
3
3
  resolveRateLimitEnabled
4
- } from "./chunk-BWKKHCDG.mjs";
4
+ } from "./chunk-3UXX27N5.mjs";
5
5
 
6
6
  // src/infrastructure/middleware/wrappers.ts
7
7
  import {
@@ -11,11 +11,19 @@ import {
11
11
  withCustomApi as _withCustomApi
12
12
  } from "@withwiz/toolkit/next/middleware/wrappers";
13
13
  import { setRateLimitAdapter } from "@withwiz/toolkit/next/middleware/rate-limit";
14
+ var PRUNE_THRESHOLD = 1e4;
14
15
  function createInMemoryLimiter(limit, windowMs) {
15
16
  const store = /* @__PURE__ */ new Map();
17
+ const prune = (now) => {
18
+ if (store.size < PRUNE_THRESHOLD) return;
19
+ for (const [k, v] of store) {
20
+ if (now > v.resetAt) store.delete(k);
21
+ }
22
+ };
16
23
  return {
17
24
  check: async (identifier) => {
18
25
  const now = Date.now();
26
+ prune(now);
19
27
  const entry = store.get(identifier);
20
28
  if (!entry || now > entry.resetAt) {
21
29
  store.set(identifier, { count: 1, resetAt: now + windowMs });
@@ -50,4 +58,4 @@ export {
50
58
  withAuthApi,
51
59
  withCustomApi
52
60
  };
53
- //# sourceMappingURL=chunk-LDFE33ML.mjs.map
61
+ //# sourceMappingURL=chunk-7F5YFG2W.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/infrastructure/middleware/wrappers.ts"],"sourcesContent":["import type { NextRequest } from 'next/server';\nimport {\n withPublicApi as _withPublicApi,\n withAdminApi as _withAdminApi,\n withAuthApi as _withAuthApi,\n withCustomApi as _withCustomApi,\n} from '@withwiz/toolkit/next/middleware/wrappers';\nimport { setRateLimitAdapter } from '@withwiz/toolkit/next/middleware/rate-limit';\nimport { resolveClientIdentity, resolveRateLimitEnabled } from '../../config';\n\n/** 만료 항목 정리를 시도하기 전까지 허용하는 store 크기. */\nconst PRUNE_THRESHOLD = 10_000;\n\nfunction createInMemoryLimiter(limit: number, windowMs: number) {\n const store = new Map<string, { count: number; resetAt: number }>();\n // 식별자가 다양(실제 IP 기반)해지면 만료된 항목이 무한히 쌓이므로,\n // 일정 크기를 넘길 때 만료 항목을 정리한다 (메모리 고갈 방지).\n const prune = (now: number) => {\n if (store.size < PRUNE_THRESHOLD) return;\n for (const [k, v] of store) {\n if (now > v.resetAt) store.delete(k);\n }\n };\n return {\n check: async (identifier: string) => {\n const now = Date.now();\n prune(now);\n const entry = store.get(identifier);\n if (!entry || now > entry.resetAt) {\n store.set(identifier, { count: 1, resetAt: now + windowMs });\n return { success: true, remaining: limit - 1, resetIn: windowMs };\n }\n entry.count++;\n const remaining = Math.max(0, limit - entry.count);\n const resetIn = entry.resetAt - now;\n return { success: entry.count <= limit, remaining, resetIn };\n },\n config: { limit },\n };\n}\n\n/**\n * rate-limit client-identity 추출 (spec.md §4.6 / Sprint 1 S4).\n *\n * 더 이상 spoofable 한 `x-forwarded-for` 의 첫 값을 무조건 신뢰하지 않고\n * `127.0.0.1` 매직 fallback 도 쓰지 않는다. 식별자 추출은 §5 config\n * boundary 의 `resolveClientIdentity` 로 위임되며, consumer 가 자신의 proxy\n * topology / trusted hop 수를 아는 `rateLimit.identityExtractor` 를 주입하면\n * 그것이 사용된다. 미설정 시 안전 기본값(헤더만 바꿔 회전 불가)을 쓰되,\n * 그 값은 전역 단일 버킷이므로 `resolveRateLimitEnabled` 가 rate-limit 을\n * 비활성화하고 1회 경고한다 (한 클라이언트가 전체를 잠그는 self-DoS 방지).\n *\n * NOTE: import-time `setRateLimitAdapter(...)` 무조건 호출 자체의 제거는\n * §4.3 / Sprint 2 범위다. Sprint 1 은 §4.6 보안 결함(identity 추출)만 고치고\n * 그것을 overridable 하게 만든다 (in-memory limiter 기본값은 유지).\n */\nconst extractClientIdentity = (headers: Headers): string =>\n resolveClientIdentity(headers);\n\nsetRateLimitAdapter({\n rateLimiters: {\n api: createInMemoryLimiter(120, 60_000),\n auth: createInMemoryLimiter(10, 60_000),\n admin: createInMemoryLimiter(200, 60_000),\n },\n extractClientIp: extractClientIdentity,\n isEnabled: async () => resolveRateLimitEnabled(),\n});\n\ntype NextRouteHandler = (request: NextRequest, context?: any) => Promise<Response>;\n\nexport const withPublicApi = _withPublicApi as unknown as (handler: any) => NextRouteHandler;\nexport const withAdminApi = _withAdminApi as unknown as (handler: any) => NextRouteHandler;\nexport const withAuthApi = _withAuthApi as unknown as (handler: any) => NextRouteHandler;\nexport const withCustomApi = _withCustomApi as unknown as (handler: any, configureChain: any) => NextRouteHandler;\n\nexport type { IApiContext, IUser, TApiHandler } from '@withwiz/toolkit/next/middleware/types';\n"],"mappings":";;;;;;AACA;AAAA,EACE,iBAAiB;AAAA,EACjB,gBAAgB;AAAA,EAChB,eAAe;AAAA,EACf,iBAAiB;AAAA,OACZ;AACP,SAAS,2BAA2B;AAIpC,IAAM,kBAAkB;AAExB,SAAS,sBAAsB,OAAe,UAAkB;AAC9D,QAAM,QAAQ,oBAAI,IAAgD;AAGlE,QAAM,QAAQ,CAAC,QAAgB;AAC7B,QAAI,MAAM,OAAO,gBAAiB;AAClC,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO;AAC1B,UAAI,MAAM,EAAE,QAAS,OAAM,OAAO,CAAC;AAAA,IACrC;AAAA,EACF;AACA,SAAO;AAAA,IACL,OAAO,OAAO,eAAuB;AACnC,YAAM,MAAM,KAAK,IAAI;AACrB,YAAM,GAAG;AACT,YAAM,QAAQ,MAAM,IAAI,UAAU;AAClC,UAAI,CAAC,SAAS,MAAM,MAAM,SAAS;AACjC,cAAM,IAAI,YAAY,EAAE,OAAO,GAAG,SAAS,MAAM,SAAS,CAAC;AAC3D,eAAO,EAAE,SAAS,MAAM,WAAW,QAAQ,GAAG,SAAS,SAAS;AAAA,MAClE;AACA,YAAM;AACN,YAAM,YAAY,KAAK,IAAI,GAAG,QAAQ,MAAM,KAAK;AACjD,YAAM,UAAU,MAAM,UAAU;AAChC,aAAO,EAAE,SAAS,MAAM,SAAS,OAAO,WAAW,QAAQ;AAAA,IAC7D;AAAA,IACA,QAAQ,EAAE,MAAM;AAAA,EAClB;AACF;AAiBA,IAAM,wBAAwB,CAAC,YAC7B,sBAAsB,OAAO;AAE/B,oBAAoB;AAAA,EAClB,cAAc;AAAA,IACZ,KAAK,sBAAsB,KAAK,GAAM;AAAA,IACtC,MAAM,sBAAsB,IAAI,GAAM;AAAA,IACtC,OAAO,sBAAsB,KAAK,GAAM;AAAA,EAC1C;AAAA,EACA,iBAAiB;AAAA,EACjB,WAAW,YAAY,wBAAwB;AACjD,CAAC;AAIM,IAAM,gBAAgB;AACtB,IAAM,eAAe;AACrB,IAAM,cAAc;AACpB,IAAM,gBAAgB;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveTrustedIframeOrigins
3
- } from "./chunk-BWKKHCDG.mjs";
3
+ } from "./chunk-3UXX27N5.mjs";
4
4
  import {
5
5
  __require
6
6
  } from "./chunk-BJTO5JO5.mjs";
@@ -78,28 +78,33 @@ function dompurifySanitize(html, purify, trustedOrigins, config) {
78
78
  }
79
79
  options.ALLOWED_ATTR = Array.from(flat);
80
80
  }
81
- const purifyWithHook = purify;
82
- if (typeof purifyWithHook.addHook === "function") {
83
- purifyWithHook.addHook("uponSanitizeElement", (node) => {
84
- if (node.nodeName && node.nodeName.toLowerCase() === "iframe") {
85
- const src = node.getAttribute?.("src") ?? "";
86
- const trusted = trustedOrigins.some(
87
- (origin) => src.startsWith(origin)
88
- );
89
- if (!trusted) {
90
- node.remove?.();
91
- }
92
- }
93
- });
94
- }
81
+ ensureIframeHook(purify);
82
+ activeTrustedOrigins = trustedOrigins;
95
83
  try {
96
84
  return purify.sanitize(html, options);
97
85
  } finally {
98
- if (typeof purifyWithHook.removeHook === "function") {
99
- purifyWithHook.removeHook("uponSanitizeElement");
100
- }
86
+ activeTrustedOrigins = null;
101
87
  }
102
88
  }
89
+ var activeTrustedOrigins = null;
90
+ var hookedInstances = /* @__PURE__ */ new WeakSet();
91
+ function ensureIframeHook(purify) {
92
+ const p = purify;
93
+ if (typeof p.addHook !== "function") return;
94
+ if (hookedInstances.has(p)) return;
95
+ hookedInstances.add(p);
96
+ p.addHook("uponSanitizeElement", (node) => {
97
+ const origins = activeTrustedOrigins;
98
+ if (!origins) return;
99
+ if (node.nodeName && node.nodeName.toLowerCase() === "iframe") {
100
+ const src = node.getAttribute?.("src") ?? "";
101
+ const trusted = origins.some((origin) => src.startsWith(origin));
102
+ if (!trusted) {
103
+ node.remove?.();
104
+ }
105
+ }
106
+ });
107
+ }
103
108
  function createSanitizer(config = {}) {
104
109
  return function sanitize(html) {
105
110
  if (!html) return html;
@@ -120,4 +125,4 @@ export {
120
125
  createSanitizer,
121
126
  sanitizeHtmlContent
122
127
  };
123
- //# sourceMappingURL=chunk-CGVFB23E.mjs.map
128
+ //# sourceMappingURL=chunk-A557FA2K.mjs.map