@fuzionx/framework 0.1.75 → 0.1.76

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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.75",
12
+ "@fuzionx/player": "^0.1.76",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -12,6 +12,7 @@ import { promises as fs } from 'node:fs';
12
12
  import cluster from 'node:cluster';
13
13
  import Config from './Config.js';
14
14
  import Context, { createContext, createContextFromReq } from './Context.js';
15
+ import { ServiceError } from './AppError.js';
15
16
  import Router from '../http/Router.js';
16
17
  import ModelRegistry from '../database/ModelRegistry.js';
17
18
  import ErrorHandler from '../http/ErrorHandler.js';
@@ -709,6 +710,14 @@ export default class Application {
709
710
 
710
711
  // 미들웨어 체인 사전 구성 (부트 시 1회)
711
712
  const middlewareFns = [...this._globalMiddleware];
713
+
714
+ // route.validate 옵션 → Validation 미들웨어를 라우트별 미들웨어 *앞*에 삽입
715
+ // (글로벌 미들웨어 이후, 라우트 미들웨어 이전 — 입력 정규화는 인증/권한 검사보다 늦게,
716
+ // 핸들러 진입 직전에 — 이 순서를 따르도록 라우트 미들웨어 앞에 둠)
717
+ if (route.validate) {
718
+ middlewareFns.push(this._createValidateMiddleware(route.validate));
719
+ }
720
+
712
721
  if (route.middleware?.length) {
713
722
  for (const mw of route.middleware) {
714
723
  if (typeof mw === 'function') {
@@ -783,8 +792,12 @@ export default class Application {
783
792
  _handleRawRequest(rawReq) {
784
793
  const dispatch = this._rawDispatch[rawReq.handlerId];
785
794
  if (!dispatch) {
786
- // handlerId 미등록404
787
- return { status: 404, body: '{"error":"Not Found"}', headers: { 'Content-Type': 'application/json' } };
795
+ // 라우트 미매칭ErrorHandler 합류 (Accept 협상)
796
+ return this._buildErrorResponse(
797
+ rawReq,
798
+ this._getDefaultAppName(),
799
+ new ServiceError('Not Found', 404),
800
+ );
788
801
  }
789
802
 
790
803
  // ── 도메인 검증 (모든 요청에 대해 수행) ──
@@ -794,11 +807,11 @@ export default class Application {
794
807
  // 미등록 도메인 → 403 차단
795
808
  if (!resolvedAppName) {
796
809
  const reqHost = rawReq.headers?.host || 'unknown';
797
- return {
798
- status: 403,
799
- body: JSON.stringify({ error: 'Forbidden', message: `도메인 '${reqHost}'은(는) 등록되지 않았습니다.` }),
800
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
801
- };
810
+ return this._buildErrorResponse(
811
+ rawReq,
812
+ this._getDefaultAppName(),
813
+ new ServiceError(`도메인 '${reqHost}'은(는) 등록되지 않았습니다.`, 403),
814
+ );
802
815
  }
803
816
 
804
817
  // ── 앱 결정 (단일 앱: O(0), 멀티앱: Host 기반) ──
@@ -810,11 +823,11 @@ export default class Application {
810
823
  appName = resolvedAppName;
811
824
  entry = dispatch.appMap.get(appName);
812
825
  if (!entry) {
813
- return {
814
- status: 403,
815
- body: JSON.stringify({ error: 'Forbidden', message: `앱 '${appName}'을(를) 찾을 수 없습니다.` }),
816
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
817
- };
826
+ return this._buildErrorResponse(
827
+ rawReq,
828
+ appName,
829
+ new ServiceError(`앱 '${appName}'을(를) 찾을 없습니다.`, 403),
830
+ );
818
831
  }
819
832
  }
820
833
 
@@ -826,6 +839,8 @@ export default class Application {
826
839
  ctx.appName = appName;
827
840
 
828
841
  // ── Sync-first 실행 ──
842
+ // 핸들러/미들웨어 throw → _handleContextError 가 ErrorHandler 로깅까지 책임지므로
843
+ // 여기서는 별도 console 출력 없이 ctx 응답만 만든다 (중복 로그 방지).
829
844
  let chainResult;
830
845
  try {
831
846
  if (middlewareFns.length === 0) {
@@ -835,7 +850,6 @@ export default class Application {
835
850
  chainResult = this._executeSyncChain(middlewareFns, ctx, resolvedHandler);
836
851
  }
837
852
  } catch (err) {
838
- console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
839
853
  this._handleContextError(err, ctx);
840
854
  return ctx._toFusionResponse();
841
855
  }
@@ -851,12 +865,11 @@ export default class Application {
851
865
  this._sendContextResponse(rawReq.requestId, ctx);
852
866
  })
853
867
  .catch((err) => {
854
- console.error(`[FX] Unhandled controller error: ${ctx.method} ${ctx.path}`, err);
855
868
  try {
856
869
  this._handleContextError(err, ctx);
857
870
  } catch (handlerErr) {
858
- // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장
859
- console.error(`[FX] Error handler failed:`, handlerErr);
871
+ // 에러 핸들링 자체 실패 → 최소한의 500 응답 보장 (logger도 실패 가능성 → console 유지)
872
+ this.logger?.error?.('[FX] ErrorHandler 자체 실패:', handlerErr);
860
873
  try {
861
874
  ctx._statusCode = 500;
862
875
  ctx._body = JSON.stringify({ error: { message: err.message, status: 500 } });
@@ -937,6 +950,11 @@ export default class Application {
937
950
  _registerWsHandlers(coreApp) {
938
951
  if (!coreApp.ws) return;
939
952
 
953
+ // WS connect/message/disconnect 추적 로그는 채팅 트래픽 등에서 폭주하므로
954
+ // bridge.logging.ws_trace 옵션이 명시적으로 true일 때만 출력.
955
+ const wsTrace = !!this.config.get('bridge.logging.ws_trace', false);
956
+ const logger = this.logger;
957
+
940
958
  for (const [, appEntry] of this._appRegistry) {
941
959
  if (!appEntry.wsHandlers || appEntry.wsHandlers.size === 0) continue;
942
960
 
@@ -948,7 +966,7 @@ export default class Application {
948
966
  const inst = new HandlerClass(this);
949
967
 
950
968
  wsNs.on('connect', (socket) => {
951
- this.logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
969
+ if (wsTrace) logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
952
970
  inst.onConnect(socket);
953
971
  });
954
972
 
@@ -958,13 +976,13 @@ export default class Application {
958
976
  catch { parsed = { type: 'message', data: rawMessage }; }
959
977
  const eventType = parsed.type || 'message';
960
978
  const eventData = parsed.data || parsed;
961
- this.logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
979
+ if (wsTrace) logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
962
980
  const entry = eventMap.get(eventType);
963
981
  if (entry) {
964
982
  const result = entry.handler.call(inst, socket, eventData);
965
983
  if (result && typeof result.then === 'function') {
966
984
  result.then(r => { if (r) socket.send(JSON.stringify(r)); })
967
- .catch(e => console.error(`[WS] error: ${eventType}`, e));
985
+ .catch(e => logger.error(`[WS] error: ${eventType}`, e));
968
986
  } else if (result) {
969
987
  socket.send(JSON.stringify(result));
970
988
  }
@@ -974,14 +992,20 @@ export default class Application {
974
992
  });
975
993
 
976
994
  wsNs.on('disconnect', (socket) => {
977
- this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
995
+ if (wsTrace) logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
978
996
  inst.onDisconnect(socket);
979
997
  });
980
998
 
981
999
  // 원격 메타데이터 변경 이벤트 (Hub METADATA_UPDATE → onRemoteMetadataUpdate)
982
- wsNs.on('metadata_update', (socket, key, value) => {
983
- inst.onRemoteMetadataUpdate(socket, key, value);
984
- });
1000
+ // core가 이 이벤트를 지원하지 않으면 ( 버전) 조용히 건너뛴다 — 부팅 실패 방지.
1001
+ // Hub 멀티서버 메타데이터 동기화 기능을 쓰지 않으면 영향 없음.
1002
+ try {
1003
+ wsNs.on('metadata_update', (socket, key, value) => {
1004
+ inst.onRemoteMetadataUpdate(socket, key, value);
1005
+ });
1006
+ } catch (e) {
1007
+ logger.debug(`[WS] '${namespace}' metadata_update 미지원 — 건너뜀 (${e.message})`);
1008
+ }
985
1009
 
986
1010
  this.logger.info(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
987
1011
  }
@@ -1265,6 +1289,85 @@ export default class Application {
1265
1289
  }
1266
1290
  }
1267
1291
 
1292
+ /**
1293
+ * route.validate 옵션 → 미들웨어로 변환.
1294
+ *
1295
+ * 미들웨어 체인에서 실행되며, ValidationError 발생 시 _handleContextError 경로를 탐.
1296
+ * Validation 인스턴스(Joi 기반)는 lazy 생성 — 첫 호출 시에만 joi import.
1297
+ * Joi가 사용자 프로젝트에 설치되지 않은 경우 검증을 건너뛰고 경고를 1회 출력한다.
1298
+ *
1299
+ * @param {object} validateOpts - { body?, query?, params? } Joi 스키마 또는 간단문법 객체
1300
+ * @returns {Function} async 미들웨어 (ctx, next) => Promise<void>
1301
+ * @private
1302
+ */
1303
+ _createValidateMiddleware(validateOpts) {
1304
+ return async (ctx, next) => {
1305
+ const runner = await this._getValidationRunner();
1306
+ if (!runner) {
1307
+ // Joi 없음 → 검증 건너뜀 (경고는 _getValidationRunner에서 1회만 출력)
1308
+ return next();
1309
+ }
1310
+ const result = runner.run(ctx, validateOpts);
1311
+ // 검증된 데이터(stripUnknown 포함)를 ctx에 반영
1312
+ if (result.body !== undefined) ctx.body = result.body;
1313
+ if (result.query !== undefined) ctx.query = result.query;
1314
+ if (result.params !== undefined) ctx.params = result.params;
1315
+ await next();
1316
+ };
1317
+ }
1318
+
1319
+ /**
1320
+ * Validation 러너 lazy 가져오기 — Joi import + 인스턴스 캐싱.
1321
+ *
1322
+ * @returns {Promise<import('../http/Validation.js').default|null>}
1323
+ * @private
1324
+ */
1325
+ async _getValidationRunner() {
1326
+ if (this._validationRunner !== undefined) return this._validationRunner;
1327
+ try {
1328
+ const joiMod = await import('joi');
1329
+ const Joi = joiMod.default || joiMod;
1330
+ const Validation = (await import('../http/Validation.js')).default;
1331
+ this._validationRunner = new Validation(Joi);
1332
+ } catch (e) {
1333
+ // joi 미설치 → 검증 건너뜀. 경고는 1회.
1334
+ this.logger.warn(
1335
+ `[Validation] joi가 설치되지 않아 route.validate 옵션이 무시됩니다 — ${e.message}`,
1336
+ );
1337
+ this._validationRunner = null;
1338
+ }
1339
+ return this._validationRunner;
1340
+ }
1341
+
1342
+ /**
1343
+ * 디스패치 이전(라우트 미매칭/도메인 거부 등) 발생한 에러용 응답 생성기.
1344
+ *
1345
+ * 미들웨어 체인 없이 즉시 ErrorHandler로 흘려서 Accept 협상을 통과시킴.
1346
+ * 동기 경로이므로 Fusion 응답을 그대로 반환한다.
1347
+ *
1348
+ * @param {object} rawReq - Bridge 원시 요청
1349
+ * @param {string} appName - 응답에 적용할 앱 이름(테마/뷰 lookup용)
1350
+ * @param {Error} err - ServiceError 또는 일반 Error
1351
+ * @returns {object} Fusion 응답 객체
1352
+ * @private
1353
+ */
1354
+ _buildErrorResponse(rawReq, appName, err) {
1355
+ // async-safe 새 인스턴스 (sync 공유 인스턴스 오염 방지 — 이 경로는 cold path)
1356
+ const ctx = createContext(rawReq, this, false);
1357
+ ctx.appName = appName || this._getDefaultAppName();
1358
+ try {
1359
+ this._handleContextError(err, ctx);
1360
+ } catch (handlerErr) {
1361
+ // 에러 핸들링 자체 실패 → 최소 500 응답 보장
1362
+ this.logger.error('[FX] ErrorHandler 자체 실패:', handlerErr);
1363
+ ctx._statusCode = 500;
1364
+ ctx._body = JSON.stringify({ error: { message: err?.message || 'Internal Error', status: 500 } });
1365
+ ctx._headers['Content-Type'] = 'application/json; charset=utf-8';
1366
+ ctx._sent = true;
1367
+ }
1368
+ return ctx._toFusionResponse();
1369
+ }
1370
+
1268
1371
  /**
1269
1372
  * Context 에러 핸들링 — ctx에 에러 응답 설정
1270
1373
  *
@@ -1335,7 +1438,7 @@ export default class Application {
1335
1438
  response.body || '', contentType, extraHeaders,
1336
1439
  );
1337
1440
  } catch (e) {
1338
- console.error('[fuzionx] sendAsyncResponse failed:', e.message);
1441
+ this.logger.error('[fuzionx] sendAsyncResponse 실패:', e.message);
1339
1442
  }
1340
1443
  }
1341
1444
 
package/lib/core/Base.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ServiceError } from './AppError.js';
2
+
1
3
  /**
2
4
  * Base — 공통 기본 클래스
3
5
  *
@@ -82,4 +84,15 @@ export default class Base {
82
84
 
83
85
  /** @returns {import('../cache/CacheManager.js').default|null} 캐시 매니저 */
84
86
  get cache() { return this.app._cacheManager || null; }
87
+
88
+ /**
89
+ * 비즈니스 로직 에러 생성 헬퍼
90
+ * @param {string} message - 에러 메시지
91
+ * @param {number} [status=400] - HTTP 상태 코드
92
+ * @param {object|null} [data=null] - 추가 데이터
93
+ * @returns {import('./AppError.js').ServiceError}
94
+ */
95
+ error(message, status = 400, data = null) {
96
+ return new ServiceError(message, status, data);
97
+ }
85
98
  }
@@ -486,7 +486,12 @@ function _createSession(rawReq, app) {
486
486
  data[key] = value;
487
487
  if (sessionId && bridge?.sessionSet) {
488
488
  try { bridge.sessionSet(sessionId, { ...data }); }
489
- catch (e) { console.error('[Session] sessionSet failed:', e?.message || e); }
489
+ catch (e) {
490
+ // app.logger가 있으면 거기로, 없으면 stderr 보장 차원에서 console.error 폴백
491
+ const msg = e?.message || e;
492
+ if (app?.logger?.warn) app.logger.warn('[Session] sessionSet 실패:', msg);
493
+ else console.error('[Session] sessionSet failed:', msg);
494
+ }
490
495
  }
491
496
  },
492
497
 
@@ -52,28 +52,55 @@ export default class ErrorHandler {
52
52
  return this._htmlResponse(err, ctx, status);
53
53
  }
54
54
 
55
+ /**
56
+ * ValidationError(또는 422) HTML 컨텍스트 처리:
57
+ * - ctx.session.flash('errors', fields) + flash('old', body)
58
+ * - Referer로 리다이렉트(없으면 '/')
59
+ * 세션이 없거나 flash 호출 자체가 실패하면 false 반환 → 일반 HTML 페이지 폴백.
60
+ *
61
+ * @param {ValidationError} err
62
+ * @param {object} ctx
63
+ * @returns {boolean} 처리 성공 여부
64
+ * @private
65
+ */
66
+ _tryFlashRedirect(err, ctx) {
67
+ const flash = ctx.session?.flash;
68
+ if (typeof flash !== 'function') return false;
69
+ try {
70
+ flash.call(ctx.session, 'errors', err.fields || {});
71
+ if (ctx.body && typeof ctx.body === 'object') {
72
+ flash.call(ctx.session, 'old', ctx.body);
73
+ }
74
+ const back = (typeof ctx.get === 'function' && ctx.get('referer')) || '/';
75
+ ctx.redirect(back);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
55
82
  /** @private */
56
83
  _jsonResponse(err, ctx, status) {
84
+ const t = typeof ctx.t === 'function' ? ctx.t.bind(ctx) : (msg) => msg;
85
+ const translatedMessage = t(err.message) || err.message;
86
+
57
87
  const body = {
58
- error: {
59
- message: err.message,
60
- status,
61
- },
88
+ error: translatedMessage,
62
89
  };
63
90
 
64
91
  // ValidationError → 필드 에러 포함
65
92
  if (err instanceof ValidationError) {
66
- body.error.fields = err.fields;
93
+ body.fields = err.fields;
67
94
  }
68
95
 
69
96
  // dev 모드에서 스택 노출
70
97
  if (this.isDev && status >= 500) {
71
- body.error.stack = err.stack;
98
+ body.stack = err.stack;
72
99
  }
73
100
 
74
101
  // 추가 데이터
75
102
  if (err.data && !(err instanceof ValidationError)) {
76
- body.error.data = err.data;
103
+ body.data = err.data;
77
104
  }
78
105
 
79
106
  ctx.status(status).json(body);
@@ -87,9 +114,18 @@ export default class ErrorHandler {
87
114
  * 2. views/{theme}/errors/default.html
88
115
  * 3. 프레임워크 내장 에러 페이지
89
116
  *
117
+ * View.render는 템플릿이 없으면 throw하므로 try/catch만으로 폴백 가능.
118
+ * (이전 휴리스틱 `<!-- template:` 검사는 Tera 출력에 존재하지 않아 무용 — 제거)
119
+ *
90
120
  * @private
91
121
  */
92
122
  _htmlResponse(err, ctx, status) {
123
+ // ── ValidationError + 세션 사용 가능 → 플래시 + 이전 페이지로 리다이렉트 (폼 흐름) ──
124
+ // docs/framework/08-error-handling.md 명세: HTML 컨텍스트의 422는 redirect back
125
+ if (err instanceof ValidationError && this._tryFlashRedirect(err, ctx)) {
126
+ return;
127
+ }
128
+
93
129
  const errorData = {
94
130
  error: {
95
131
  code: status,
@@ -104,23 +140,22 @@ export default class ErrorHandler {
104
140
  const appEntry = ctx.app?._appRegistry?.get(ctx.appName);
105
141
  const view = this._view || appEntry?.view;
106
142
  if (view) {
107
- const theme = ctx.theme || ctx.app?.config?.get('themes.default', 'default') || 'default';
108
143
  // 1. views/{theme}/errors/{code}.html
109
144
  try {
110
145
  const html = view.render(`errors/${status}`, errorData);
111
- if (html && !html.startsWith('<!-- template:')) {
146
+ if (html) {
112
147
  ctx.status(status).html(html);
113
148
  return;
114
149
  }
115
- } catch {}
150
+ } catch { /* 템플릿 없음 → 다음 폴백 */ }
116
151
  // 2. views/{theme}/errors/default.html
117
152
  try {
118
153
  const html = view.render('errors/default', errorData);
119
- if (html && !html.startsWith('<!-- template:')) {
154
+ if (html) {
120
155
  ctx.status(status).html(html);
121
156
  return;
122
157
  }
123
- } catch {}
158
+ } catch { /* 템플릿 없음 → 내장 에러 페이지로 폴백 */ }
124
159
  }
125
160
 
126
161
  // 3. 프레임워크 내장 에러 페이지 (모던 디자인 — XSS 방지)
@@ -7,9 +7,20 @@
7
7
  */
8
8
  import { ValidationError } from '../core/AppError.js';
9
9
 
10
+ /** type 룰: base schema 를 결정 (modifier 와 분리해 순서 무관 처리) */
11
+ const TYPE_RULES = new Set(['string', 'number', 'integer', 'boolean', 'date', 'array']);
12
+
10
13
  /**
11
14
  * 간단 규칙 문자열을 Joi 스키마로 변환
12
- * @param {object} rules - { name: 'required|min:2', email: 'required|email' }
15
+ *
16
+ * 규칙은 두 그룹으로 분리되어 처리된다:
17
+ * 1) type 룰 (string/number/integer/boolean/date/array) — base schema 결정
18
+ * 2) modifier 룰 (required/optional/nullable/min/max/email/url/in/positive/alpha)
19
+ *
20
+ * type 룰을 먼저 적용하고 그 위에 modifier 를 쌓는 방식이라
21
+ * 'required|string' / 'string|required' 어느 순서로 써도 동일하게 동작한다.
22
+ *
23
+ * @param {object} rules - { name: 'required|min:2', email: 'string|email|nullable' }
13
24
  * @param {object} Joi - Joi 모듈 참조
14
25
  * @returns {import('joi').ObjectSchema}
15
26
  */
@@ -18,41 +29,62 @@ export function parseRules(rules, Joi) {
18
29
 
19
30
  for (const [field, rule] of Object.entries(rules)) {
20
31
  const parts = typeof rule === 'string' ? rule.split('|') : [];
21
- let schema = Joi.any();
22
32
 
33
+ // 1단계: type 룰과 modifier 룰 분리 (type 은 마지막 선언 우선)
34
+ let typeName = null;
35
+ const modifiers = [];
23
36
  for (const part of parts) {
37
+ const [name] = part.split(':');
38
+ if (TYPE_RULES.has(name)) typeName = name;
39
+ else modifiers.push(part);
40
+ }
41
+
42
+ // 2단계: base schema 결정
43
+ let schema;
44
+ switch (typeName) {
45
+ case 'string': schema = Joi.string(); break;
46
+ case 'number': schema = Joi.number(); break;
47
+ case 'integer': schema = Joi.number().integer(); break;
48
+ case 'boolean': schema = Joi.boolean(); break;
49
+ case 'date': schema = Joi.date(); break;
50
+ case 'array': schema = Joi.array(); break;
51
+ default: schema = Joi.any();
52
+ }
53
+
54
+ // 3단계: modifier 적용 (type 추론 가능한 룰은 base 가 any 이면 자동 승격)
55
+ for (const part of modifiers) {
24
56
  const [name, ...args] = part.split(':');
25
57
 
26
58
  switch (name) {
27
- case 'string':
28
- schema = Joi.string(); break;
29
- case 'number':
30
- schema = Joi.number(); break;
31
- case 'boolean':
32
- schema = Joi.boolean(); break;
33
59
  case 'required':
34
60
  schema = schema.required(); break;
35
61
  case 'optional':
36
62
  schema = schema.optional(); break;
63
+ case 'nullable':
64
+ // null 허용 — 클라이언트가 미입력 필드를 null 로 보내는 패턴 지원
65
+ schema = schema.allow(null); break;
66
+ case 'empty':
67
+ // 빈 문자열 허용 — Joi.string() 은 기본적으로 '' 를 거부하므로
68
+ // 폼이 미입력 필드를 빈 문자열로 보내는 경우 명시적 허용 필요
69
+ schema = schema.allow(''); break;
37
70
  case 'min':
38
71
  schema = schema.min(Number(args[0])); break;
39
72
  case 'max':
40
73
  schema = schema.max(Number(args[0])); break;
41
74
  case 'email':
42
- // 기존 schema Joi.any() string()으로 교체, 이미 string()이면 유지
43
- schema = (schema.type === 'any' ? Joi.string() : schema).email(); break;
75
+ if (schema.type === 'any') schema = Joi.string();
76
+ schema = schema.email(); break;
44
77
  case 'url':
45
- schema = (schema.type === 'any' ? Joi.string() : schema).uri(); break;
46
- case 'in':
47
- schema = schema.valid(...args[0].split(',')); break;
48
- case 'integer':
49
- schema = schema.integer ? schema.integer() : Joi.number().integer(); break;
78
+ if (schema.type === 'any') schema = Joi.string();
79
+ schema = schema.uri(); break;
50
80
  case 'positive':
51
- schema = schema.positive ? schema.positive() : Joi.number().positive(); break;
52
- case 'date':
53
- schema = schema.type === 'any' ? Joi.date() : schema; break;
81
+ if (schema.type === 'any') schema = Joi.number();
82
+ schema = schema.positive(); break;
54
83
  case 'alpha':
55
- schema = (schema.type === 'any' ? Joi.string() : schema).alphanum(); break;
84
+ if (schema.type === 'any') schema = Joi.string();
85
+ schema = schema.alphanum(); break;
86
+ case 'in':
87
+ schema = schema.valid(...args[0].split(',')); break;
56
88
  default:
57
89
  // 알 수 없는 규칙은 무시
58
90
  break;
@@ -86,13 +118,13 @@ export default class Validation {
86
118
  const result = {};
87
119
 
88
120
  if (validateOpts.body) {
89
- result.body = this._validate(ctx.body, validateOpts.body);
121
+ result.body = this._validate(ctx, ctx.body, validateOpts.body);
90
122
  }
91
123
  if (validateOpts.query) {
92
- result.query = this._validate(ctx.query, validateOpts.query);
124
+ result.query = this._validate(ctx, ctx.query, validateOpts.query);
93
125
  }
94
126
  if (validateOpts.params) {
95
- result.params = this._validate(ctx.params, validateOpts.params);
127
+ result.params = this._validate(ctx, ctx.params, validateOpts.params);
96
128
  }
97
129
 
98
130
  return result;
@@ -101,7 +133,7 @@ export default class Validation {
101
133
  /**
102
134
  * @private
103
135
  */
104
- _validate(data, schema) {
136
+ _validate(ctx, data, schema) {
105
137
  // 간단문법 객체 → Joi 변환
106
138
  if (schema && typeof schema === 'object' && !schema.validate) {
107
139
  schema = parseRules(schema, this._Joi);
@@ -114,8 +146,19 @@ export default class Validation {
114
146
 
115
147
  if (error) {
116
148
  const fields = {};
149
+ const translate = typeof ctx.t === 'function' ? ctx.t : () => '';
150
+
117
151
  for (const detail of error.details) {
118
- fields[detail.path.join('.')] = detail.message;
152
+ // 1. 필드명 다국어 번역 시도 (예: username -> 아이디)
153
+ // fields.username 키가 없으면 원래 영문 필드명 사용
154
+ const fieldName = translate(`fields.${detail.context.key}`) || detail.context.key;
155
+
156
+ // 2. Joi 에러 타입별 다국어 매핑 (예: any.required -> {field}은(는) 필수입니다)
157
+ // detail.context 에는 limit, val 등의 추가 제약 정보가 포함되어 있음
158
+ const i18nMsg = translate(`validation.${detail.type}`, { ...detail.context, field: fieldName });
159
+
160
+ // 3. 번역된 메세지가 없으면 Joi의 기본 영문 메세지 fallback
161
+ fields[detail.path.join('.')] = i18nMsg || detail.message;
119
162
  }
120
163
  throw new ValidationError('Validation failed', fields);
121
164
  }
@@ -3,13 +3,17 @@
3
3
  *
4
4
  * Authorization: Bearer <token> → 검증 → ctx.user
5
5
  *
6
+ * 인증 실패 시 ServiceError(401) throw → ErrorHandler가 Accept 협상으로 응답.
7
+ *
6
8
  * @see docs/framework/14-authentication.md
9
+ * @see docs/framework/08-error-handling.md
7
10
  *
8
11
  * @param {object} [opts]
9
12
  * @param {string} [opts.secret] - JWT 시크릿
10
13
  * @param {string} [opts.model='User']
11
14
  */
12
15
  import { createHmac, timingSafeEqual } from 'node:crypto';
16
+ import { ServiceError } from '../core/AppError.js';
13
17
 
14
18
  export function apiAuth(opts = {}) {
15
19
  const modelName = opts.model || 'User';
@@ -18,29 +22,27 @@ export function apiAuth(opts = {}) {
18
22
  const authHeader = ctx.get('authorization') || '';
19
23
 
20
24
  if (!authHeader.startsWith('Bearer ')) {
21
- ctx.status(401).json({ error: { message: 'Token required', status: 401 } });
22
- return;
25
+ throw new ServiceError('Token required', 401);
23
26
  }
24
27
 
25
28
  const token = authHeader.slice(7);
29
+ const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
26
30
 
31
+ let payload;
27
32
  try {
28
- const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
29
- const payload = decodeJwtPayload(token, secret);
33
+ payload = decodeJwtPayload(token, secret);
34
+ } catch {
35
+ throw new ServiceError('Invalid token', 401);
36
+ }
30
37
 
31
- if (!payload || !payload.sub) {
32
- ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
33
- return;
34
- }
38
+ if (!payload || !payload.sub) {
39
+ throw new ServiceError('Invalid token', 401);
40
+ }
35
41
 
36
- if (ctx.app?.db?.[modelName]) {
37
- ctx.user = await ctx.app.db[modelName].find(payload.sub);
38
- } else {
39
- ctx.user = { id: payload.sub, ...payload };
40
- }
41
- } catch {
42
- ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
43
- return;
42
+ if (ctx.app?.db?.[modelName]) {
43
+ ctx.user = await ctx.app.db[modelName].find(payload.sub);
44
+ } else {
45
+ ctx.user = { id: payload.sub, ...payload };
44
46
  }
45
47
 
46
48
  await next();
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * ctx.session.userId → db.User.find() → ctx.user
5
5
  *
6
+ * 미인증 처리:
7
+ * - redirectTo 지정 시: 해당 URL로 302 리다이렉트 (HTML 라우트용)
8
+ * - 미지정 시: ServiceError(401) throw → ErrorHandler가 Accept 협상으로 응답 형식 결정
9
+ *
6
10
  * @see docs/framework/14-authentication.md
11
+ * @see docs/framework/08-error-handling.md
7
12
  *
8
13
  * @param {object} [opts]
9
14
  * @param {string} [opts.sessionKey='userId']
10
15
  * @param {string} [opts.model='User']
11
- * @param {string} [opts.redirectTo='/login']
16
+ * @param {string} [opts.redirectTo] - 미지정 시 401 throw, 지정 시 해당 URL로 redirect
12
17
  */
18
+ import { ServiceError } from '../core/AppError.js';
19
+
13
20
  export function auth(opts = {}) {
14
21
  const sessionKey = opts.sessionKey || 'userId';
15
22
  const modelName = opts.model || 'User';
@@ -21,10 +28,9 @@ export function auth(opts = {}) {
21
28
  if (!userId) {
22
29
  if (redirectTo) {
23
30
  ctx.redirect(redirectTo);
24
- } else {
25
- ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
31
+ return;
26
32
  }
27
- return;
33
+ throw new ServiceError('Unauthorized', 401);
28
34
  }
29
35
 
30
36
  if (ctx.app?.db?.[modelName]) {
@@ -2,13 +2,17 @@
2
2
  * csrf — CSRF 보호 미들웨어
3
3
  *
4
4
  * GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증.
5
+ * 토큰 불일치 시 ServiceError(403) throw → ErrorHandler가 Accept 협상으로 응답.
5
6
  *
6
7
  * @see docs/framework/12-middleware.md
8
+ * @see docs/framework/08-error-handling.md
7
9
  *
8
10
  * @param {object} [opts]
9
11
  * @param {string} [opts.headerName='x-csrf-token']
10
12
  * @param {string} [opts.sessionKey='_csrfToken']
11
13
  */
14
+ import { ServiceError } from '../core/AppError.js';
15
+
12
16
  export function csrf(opts = {}) {
13
17
  const headerName = opts.headerName || 'x-csrf-token';
14
18
  const sessionKey = opts.sessionKey || '_csrfToken';
@@ -23,8 +27,7 @@ export function csrf(opts = {}) {
23
27
  const expected = ctx._rawReq?.session?.[sessionKey];
24
28
 
25
29
  if (!token || !expected || token !== expected) {
26
- ctx.status(403).json({ error: { message: 'CSRF token mismatch', status: 403 } });
27
- return;
30
+ throw new ServiceError('CSRF token mismatch', 403);
28
31
  }
29
32
 
30
33
  await next();
@@ -4,6 +4,10 @@
4
4
  * `auth()` 미들웨어 이후에 사용.
5
5
  * ctx.user.role_code가 허용된 역할 목록에 포함되는지 검사.
6
6
  *
7
+ * 거부 시 ServiceError throw → ErrorHandler가 Accept 협상으로 응답.
8
+ * - 미인증: 401
9
+ * - 권한 부족: 403
10
+ *
7
11
  * @example
8
12
  * // 관리자 백오피스: developer, admin만 허용
9
13
  * r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin'])] }, (r) => { ... });
@@ -12,6 +16,7 @@
12
16
  * r.group('/api', { middleware: [auth(), roleGuard(['developer', 'admin', 'reseller'])] }, (r) => { ... });
13
17
  *
14
18
  * @see docs/framework/14-authentication.md
19
+ * @see docs/framework/08-error-handling.md
15
20
  *
16
21
  * @param {string[]} allowedRoles - 접근 허용 역할 코드 배열
17
22
  * @param {object} [opts]
@@ -19,6 +24,8 @@
19
24
  * @param {string} [opts.message='auth.role_denied'] - 거부 시 에러 메시지 키
20
25
  * @returns {Function} 미들웨어 함수
21
26
  */
27
+ import { ServiceError } from '../core/AppError.js';
28
+
22
29
  export function roleGuard(allowedRoles, opts = {}) {
23
30
  const roleField = opts.roleField || 'role_code';
24
31
  const message = opts.message || 'auth.role_denied';
@@ -26,8 +33,7 @@ export function roleGuard(allowedRoles, opts = {}) {
26
33
  return async (ctx, next) => {
27
34
  /** 인증되지 않은 사용자 */
28
35
  if (!ctx.user) {
29
- ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
30
- return;
36
+ throw new ServiceError('Unauthorized', 401);
31
37
  }
32
38
 
33
39
  /** 역할 코드 추출 */
@@ -35,13 +41,8 @@ export function roleGuard(allowedRoles, opts = {}) {
35
41
 
36
42
  /** 허용된 역할인지 확인 */
37
43
  if (!userRole || !allowedRoles.includes(userRole)) {
38
- ctx.status(403).json({
39
- error: {
40
- message: ctx.t ? ctx.t(message) : message,
41
- status: 403,
42
- },
43
- });
44
- return;
44
+ const msg = (typeof ctx.t === 'function') ? ctx.t(message) : message;
45
+ throw new ServiceError(msg, 403);
45
46
  }
46
47
 
47
48
  await next();
@@ -8,7 +8,6 @@
8
8
  * @see docs/framework/class-design.mm.md (Service)
9
9
  */
10
10
  import Base from '../core/Base.js';
11
- import { ServiceError } from '../core/AppError.js';
12
11
 
13
12
  /** @type {Map<string, { value: *, expires: number }>} 전역 캐시 스토어 */
14
13
  const _cache = new Map();
@@ -141,6 +140,28 @@ export default class Service extends Base {
141
140
  return cached ? cached.expires > Date.now() : false;
142
141
  }
143
142
 
143
+ /**
144
+ * 접두어 기반 캐시 일괄 무효화
145
+ *
146
+ * prefix로 시작하는 모든 캐시 키를 삭제한다.
147
+ * 예: 'reseller:tree:' → 'reseller:tree:roots', 'reseller:tree:full', 'reseller:tree:children:5' 등 모두 삭제
148
+ *
149
+ * @param {string} prefix - 캐시 키 접두어
150
+ */
151
+ async invalidateCacheByPrefix(prefix) {
152
+ // 인메모리 Map — prefix로 시작하는 키 일괄 삭제
153
+ for (const key of _cache.keys()) {
154
+ if (key.startsWith(prefix)) {
155
+ _cache.delete(key);
156
+ }
157
+ }
158
+
159
+ // CacheManager — deleteByPrefix 지원 시 위임
160
+ if (this.cache && typeof this.cache.deleteByPrefix === 'function') {
161
+ await this.cache.deleteByPrefix(prefix);
162
+ }
163
+ }
164
+
144
165
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
145
166
  // 병렬 실행
146
167
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -257,19 +278,6 @@ export default class Service extends Base {
257
278
  }
258
279
  }
259
280
 
260
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
261
- // 에러
262
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
263
-
264
- /**
265
- * 에러 생성 헬퍼
266
- * @param {string} message
267
- * @param {number} [status=400]
268
- * @returns {ServiceError}
269
- */
270
- error(message, status = 400) {
271
- return new ServiceError(message, status);
272
- }
273
281
  }
274
282
 
275
283
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@aws-sdk/client-s3": "^3.1028.0",
38
- "@fuzionx/core": "^0.1.75",
38
+ "@fuzionx/core": "^0.1.76",
39
39
  "better-sqlite3": "^12.8.0",
40
40
  "knex": "^3.2.5",
41
41
  "mongoose": "^9.3.2",