@fuzionx/framework 0.1.74 → 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.
- package/cli/index.js +8 -2
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -1
- package/lib/core/Application.js +127 -24
- package/lib/core/Base.js +13 -0
- package/lib/core/Context.js +6 -1
- package/lib/http/ErrorHandler.js +47 -12
- package/lib/http/Validation.js +67 -24
- package/lib/middleware/apiAuth.js +18 -16
- package/lib/middleware/auth.js +10 -4
- package/lib/middleware/csrf.js +5 -2
- package/lib/middleware/roleGuard.js +10 -9
- package/lib/services/Service.js +22 -14
- package/package.json +2 -2
package/cli/index.js
CHANGED
|
@@ -103,9 +103,15 @@ function _knexColumnBuilder(t, col, def) {
|
|
|
103
103
|
c.notNullable();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// 기본값
|
|
106
|
+
// 기본값 — SQL 함수 표현식(now() 등)은 knex.raw()로 전달
|
|
107
107
|
if (def.default !== undefined) {
|
|
108
|
-
|
|
108
|
+
const val = def.default;
|
|
109
|
+
if (typeof val === 'string' && /^\w+\(.*\)$/.test(val)) {
|
|
110
|
+
// SQL 함수 표현식 (now(), uuid_generate_v4() 등)
|
|
111
|
+
c.defaultTo(t.client.raw(val));
|
|
112
|
+
} else {
|
|
113
|
+
c.defaultTo(val);
|
|
114
|
+
}
|
|
109
115
|
} else if ((def.type === 'integer' || def.type === 'bigInteger') && !def.nullable) {
|
|
110
116
|
c.defaultTo(0); // non-nullable integer만 0 기본값 (nullable FK는 NULL 유지)
|
|
111
117
|
} else if (def.type === 'boolean') {
|
package/lib/core/Application.js
CHANGED
|
@@ -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
|
-
//
|
|
787
|
-
return
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/core/Context.js
CHANGED
|
@@ -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) {
|
|
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
|
|
package/lib/http/ErrorHandler.js
CHANGED
|
@@ -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.
|
|
93
|
+
body.fields = err.fields;
|
|
67
94
|
}
|
|
68
95
|
|
|
69
96
|
// dev 모드에서 스택 노출
|
|
70
97
|
if (this.isDev && status >= 500) {
|
|
71
|
-
body.
|
|
98
|
+
body.stack = err.stack;
|
|
72
99
|
}
|
|
73
100
|
|
|
74
101
|
// 추가 데이터
|
|
75
102
|
if (err.data && !(err instanceof ValidationError)) {
|
|
76
|
-
body.
|
|
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
|
|
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
|
|
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 방지)
|
package/lib/http/Validation.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
43
|
-
schema =
|
|
75
|
+
if (schema.type === 'any') schema = Joi.string();
|
|
76
|
+
schema = schema.email(); break;
|
|
44
77
|
case 'url':
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
33
|
+
payload = decodeJwtPayload(token, secret);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new ServiceError('Invalid token', 401);
|
|
36
|
+
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
38
|
+
if (!payload || !payload.sub) {
|
|
39
|
+
throw new ServiceError('Invalid token', 401);
|
|
40
|
+
}
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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();
|
package/lib/middleware/auth.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
|
|
31
|
+
return;
|
|
26
32
|
}
|
|
27
|
-
|
|
33
|
+
throw new ServiceError('Unauthorized', 401);
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
if (ctx.app?.db?.[modelName]) {
|
package/lib/middleware/csrf.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
39
|
-
|
|
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();
|
package/lib/services/Service.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|