You need to sign in or sign up before continuing.
Commit aaeabdae authored by ThinhNC's avatar ThinhNC

feat(be): implement admin control center, api key auth, webhooks, and ssrf protection

parent 80763c02
...@@ -118,3 +118,30 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/ ...@@ -118,3 +118,30 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/
* `POST /ai-assistant/chat` [Bearer] - Hỏi đáp tài chính cá nhân với trợ lý AI dựa trên dữ liệu thu chi thực tế của người dùng. * `POST /ai-assistant/chat` [Bearer] - Hỏi đáp tài chính cá nhân với trợ lý AI dựa trên dữ liệu thu chi thực tế của người dùng.
* `POST /ai-assistant/classify` [Bearer] - Phân tích văn bản giao dịch tự do để đề xuất danh mục thích hợp. * `POST /ai-assistant/classify` [Bearer] - Phân tích văn bản giao dịch tự do để đề xuất danh mục thích hợp.
* `POST /ai-assistant/ocr` [Bearer] - Phân tích ảnh hóa đơn tải lên để tự động bóc tách số tiền, danh mục, ngày tháng. * `POST /ai-assistant/ocr` [Bearer] - Phân tích ảnh hóa đơn tải lên để tự động bóc tách số tiền, danh mục, ngày tháng.
### 3.10 Cấu hình Công khai Hệ thống (Public Config)
* `GET /system/public-config` [Public] - Cấu hình công khai của app (tên, phiên bản, trạng thái và thông điệp bảo trì hệ thống, cờ tính năng).
### 3.11 Quản trị Cấu hình Hệ thống & Chế độ Bảo trì (System Settings & Maintenance)
* `GET /admin/settings` [Bearer: SYSTEM_CONFIG_READ] - Lấy danh sách toàn bộ tham số cấu hình hệ thống (lọc theo `category`).
* `GET /admin/settings/:key` [Bearer: SYSTEM_CONFIG_READ] - Xem chi tiết một tham số cấu hình.
* `PUT /admin/settings/:key` [Bearer: SYSTEM_CONFIG_UPDATE] - Cập nhật giá trị tham số cấu hình và ghi nhật ký kiểm toán.
* `PUT /admin/settings/maintenance` [Bearer: MAINTENANCE_MODE_UPDATE] - Bật/tắt chế độ bảo trì hệ thống (trả về 503 cho người dùng thường, admin bypass).
### 3.12 Quản trị Thông báo Đa kênh (Notification Management)
* `GET /admin/notifications/overview` [Bearer: NOTIFICATION_ADMIN_READ] - Báo cáo tổng quan số lượng gửi, tỷ lệ thành công, phân bổ theo kênh (IN_APP, EMAIL, PUSH, ZALO).
* `GET /admin/notifications/deliveries` [Bearer: NOTIFICATION_ADMIN_READ] - Lịch sử phân phối thông báo kèm bộ lọc kênh, trạng thái và phân trang.
* `POST /admin/notifications/deliveries/:id/retry` [Bearer: NOTIFICATION_RETRY] - Thử lại việc gửi thông báo bị thất bại.
* `GET /admin/notifications/templates` [Bearer: NOTIFICATION_TEMPLATE_READ] - Danh sách mẫu thông báo song ngữ (vi/en).
* `PUT /admin/notifications/templates/:id` [Bearer: NOTIFICATION_TEMPLATE_UPDATE] - Cập nhật nội dung mẫu thông báo.
* `GET /admin/notifications/channels` [Bearer: NOTIFICATION_CONFIG_READ] - Lấy trạng thái kích hoạt của từng kênh gửi tin.
* `PUT /admin/notifications/channels` [Bearer: NOTIFICATION_CONFIG_UPDATE] - Bật/tắt các kênh phát tán thông báo.
### 3.13 Quản trị Trí tuệ Nhân tạo (AI Administration)
* `GET /admin/ai/status` [Bearer: AI_ADMIN_READ] - Trạng thái hoạt động của các tính năng AI và model đang kết nối.
* `PUT /admin/ai/features/:key/toggle` [Bearer: AI_CONFIG_UPDATE] - Bật hoặc tắt khẩn cấp một tính năng AI.
* `GET /admin/ai/usage` [Bearer: AI_USAGE_READ] - Thống kê tiêu thụ Token (Prompt, Completion, Total), độ trễ phản hồi và số lần chạm trần rate limit theo chu kỳ (today, week, month).
* `GET /admin/ai/logs` [Bearer: AI_LOG_READ] - Nhật ký chi tiết từng lượt gọi AI LLM (User ID, Model, Tokens, Latency, Error).
* `GET /admin/ai/rate-limit` [Bearer: AI_CONFIG_READ] - Lấy cấu hình Sliding Window Rate Limit cho AI.
* `PUT /admin/ai/rate-limit` [Bearer: AI_CONFIG_UPDATE] - Cập nhật số yêu cầu tối đa và thời lượng cửa sổ trượt (Sliding Window) cho AI.
...@@ -23,6 +23,9 @@ importers: ...@@ -23,6 +23,9 @@ importers:
bcryptjs: bcryptjs:
specifier: ^2.4.3 specifier: ^2.4.3
version: 2.4.3 version: 2.4.3
bullmq:
specifier: ^6.2.0
version: 6.2.0(ioredis@6.0.0)(pg@8.23.0)
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
...@@ -935,6 +938,36 @@ packages: ...@@ -935,6 +938,36 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
cpu: [x64]
os: [win32]
'@napi-rs/wasm-runtime@1.2.2': '@napi-rs/wasm-runtime@1.2.2':
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
...@@ -1513,6 +1546,24 @@ packages: ...@@ -1513,6 +1546,24 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bullmq@6.2.0:
resolution: {integrity: sha512-9BVvqp1nJ6jzG2Ap37sZ+ucseAgHU+fxecLkHCcTJf8SMvLtRPSy1q5J7n7kutw+Hfwsa+araPPTULJSX3ID8g==}
engines: {node: '>=14.17.0'}
peerDependencies:
bullmq-otel: '>=2.0.0'
ioredis: '>=5.0.0'
pg: '>=8.0.0'
redis: '>=5.0.0'
peerDependenciesMeta:
bullmq-otel:
optional: true
ioredis:
optional: true
pg:
optional: true
redis:
optional: true
busboy@1.6.0: busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
...@@ -1642,6 +1693,10 @@ packages: ...@@ -1642,6 +1693,10 @@ packages:
create-require@1.1.1: create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
cron-parser@5.10.0:
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
...@@ -2360,6 +2415,10 @@ packages: ...@@ -2360,6 +2415,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
make-dir@4.0.0: make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'} engines: {node: '>=10'}
...@@ -2447,6 +2506,13 @@ packages: ...@@ -2447,6 +2506,13 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msgpackr-extract@3.0.4:
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
hasBin: true
msgpackr@2.0.5:
resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==}
multer@2.2.0: multer@2.2.0:
resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==}
engines: {node: '>= 10.16.0'} engines: {node: '>= 10.16.0'}
...@@ -2466,6 +2532,13 @@ packages: ...@@ -2466,6 +2532,13 @@ packages:
neo-async@2.6.2: neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-gyp-build-optional-packages@5.2.2:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
node-int64@0.4.0: node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
...@@ -4070,6 +4143,24 @@ snapshots: ...@@ -4070,6 +4143,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4':
optional: true
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4':
optional: true
'@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
...@@ -4701,6 +4792,17 @@ snapshots: ...@@ -4701,6 +4792,17 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
bullmq@6.2.0(ioredis@6.0.0)(pg@8.23.0):
dependencies:
cron-parser: 5.10.0
msgpackr: 2.0.5
node-abort-controller: 3.1.1
semver: 7.8.5
tslib: 2.8.1
optionalDependencies:
ioredis: 6.0.0
pg: 8.23.0
busboy@1.6.0: busboy@1.6.0:
dependencies: dependencies:
streamsearch: 1.1.0 streamsearch: 1.1.0
...@@ -4813,6 +4915,10 @@ snapshots: ...@@ -4813,6 +4915,10 @@ snapshots:
create-require@1.1.1: {} create-require@1.1.1: {}
cron-parser@5.10.0:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
...@@ -5756,6 +5862,8 @@ snapshots: ...@@ -5756,6 +5862,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
luxon@3.7.2: {}
make-dir@4.0.0: make-dir@4.0.0:
dependencies: dependencies:
semver: 7.8.5 semver: 7.8.5
...@@ -5832,6 +5940,22 @@ snapshots: ...@@ -5832,6 +5940,22 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
msgpackr-extract@3.0.4:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
optional: true
msgpackr@2.0.5:
optionalDependencies:
msgpackr-extract: 3.0.4
multer@2.2.0: multer@2.2.0:
dependencies: dependencies:
append-field: 1.0.0 append-field: 1.0.0
...@@ -5847,6 +5971,13 @@ snapshots: ...@@ -5847,6 +5971,13 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
node-abort-controller@3.1.1: {}
node-gyp-build-optional-packages@5.2.2:
dependencies:
detect-libc: 2.1.2
optional: true
node-int64@0.4.0: {} node-int64@0.4.0: {}
node-releases@2.0.53: {} node-releases@2.0.53: {}
......
...@@ -136,6 +136,9 @@ model User { ...@@ -136,6 +136,9 @@ model User {
notificationSetting NotificationSetting? notificationSetting NotificationSetting?
reminders Reminder[] reminders Reminder[]
recurringTransactionSchedules RecurringTransactionSchedule[] recurringTransactionSchedules RecurringTransactionSchedule[]
apiKeys ApiKey[]
webhookEndpoints WebhookEndpoint[]
asyncJobs AsyncJob[]
@@index([roleId]) @@index([roleId])
@@map("users") @@map("users")
...@@ -283,6 +286,8 @@ model Transaction { ...@@ -283,6 +286,8 @@ model Transaction {
@@index([userId, type]) @@index([userId, type])
@@index([walletId, date]) @@index([walletId, date])
@@index([categoryId, date]) @@index([categoryId, date])
@@index([userId, type, createdAt])
@@index([userId, createdAt])
@@map("transactions") @@map("transactions")
} }
...@@ -333,6 +338,7 @@ model Budget { ...@@ -333,6 +338,7 @@ model Budget {
@@index([userId, startDate, endDate]) @@index([userId, startDate, endDate])
@@index([userId, currency, startDate, endDate]) @@index([userId, currency, startDate, endDate])
@@index([userId, type, period]) @@index([userId, type, period])
@@index([isArchived, startDate, endDate])
@@map("budgets") @@map("budgets")
} }
...@@ -358,6 +364,7 @@ model SavingGoal { ...@@ -358,6 +364,7 @@ model SavingGoal {
@@index([userId, isArchived]) @@index([userId, isArchived])
@@index([userId, status]) @@index([userId, status])
@@index([userId, targetDate]) @@index([userId, targetDate])
@@index([isArchived, status])
@@map("saving_goals") @@map("saving_goals")
} }
...@@ -588,3 +595,189 @@ model RecurringTransactionOccurrence { ...@@ -588,3 +595,189 @@ model RecurringTransactionOccurrence {
@@index([status, updatedAt]) @@index([status, updatedAt])
@@map("recurring_transaction_occurrences") @@map("recurring_transaction_occurrences")
} }
enum SettingType {
STRING
NUMBER
BOOLEAN
JSON
}
enum SettingCategory {
GENERAL
SECURITY
NOTIFICATION
AI
SYSTEM
}
model SystemSetting {
key String @id @db.VarChar(100)
value String @db.Text
type SettingType @default(STRING)
category SettingCategory @default(GENERAL)
description String @db.VarChar(255)
isEditable Boolean @default(true) @map("is_editable")
isPublic Boolean @default(false) @map("is_public")
updatedBy String? @map("updated_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([category])
@@map("system_settings")
}
model NotificationTemplate {
id String @id @default(uuid()) @db.Uuid
type NotificationType
channel NotificationChannel @default(IN_APP)
language String @default("vi") @db.VarChar(10)
titleTemplate String @map("title_template") @db.VarChar(200)
bodyTemplate String @map("body_template") @db.Text
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([type, channel, language])
@@index([type, channel, isActive])
@@map("notification_templates")
}
enum AiRequestStatus {
SUCCESS
FAILED
RATE_LIMITED
}
model AiRequestLog {
id String @id @default(uuid()) @db.Uuid
userId String? @map("user_id") @db.Uuid
feature String @db.VarChar(50)
provider String @db.VarChar(50)
model String @db.VarChar(100)
status AiRequestStatus @default(SUCCESS)
promptTokens Int? @map("prompt_tokens")
completionTokens Int? @map("completion_tokens")
totalTokens Int? @map("total_tokens")
latencyMs Int @map("latency_ms")
errorMessage String? @map("error_message") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@index([userId, createdAt])
@@index([feature, createdAt])
@@index([status, createdAt])
@@index([createdAt])
@@map("ai_request_logs")
}
enum ApiKeyStatus {
ACTIVE
REVOKED
EXPIRED
}
model ApiKey {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
keyPrefix String @map("key_prefix") @db.VarChar(16)
keyHash String @unique @map("key_hash") @db.VarChar(128)
permissions String[] @default([])
ipWhitelist String[] @default([]) @map("ip_whitelist")
status ApiKeyStatus @default(ACTIVE)
lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, deletedAt])
@@index([keyHash])
@@map("api_keys")
}
enum WebhookStatus {
ACTIVE
DISABLED
}
enum WebhookDeliveryStatus {
PENDING
SUCCESS
FAILED
}
model WebhookEndpoint {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
url String @db.VarChar(500)
description String? @db.VarChar(255)
secretEncrypted String @map("secret_encrypted") @db.Text
events String[] @default(["job.completed", "job.failed"])
status WebhookStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[]
@@index([userId, status, deletedAt])
@@map("webhook_endpoints")
}
model WebhookDelivery {
id String @id @default(uuid()) @db.Uuid
webhookEndpointId String @map("webhook_endpoint_id") @db.Uuid
eventId String @map("event_id") @db.VarChar(100)
event String @db.VarChar(100)
payload Json
status WebhookDeliveryStatus @default(PENDING)
statusCode Int? @map("status_code")
responseBody String? @map("response_body") @db.Text
attemptCount Int @default(0) @map("attempt_count")
maxAttempts Int @default(5) @map("max_attempts")
nextRetryAt DateTime? @map("next_retry_at") @db.Timestamptz(3)
lastAttemptAt DateTime? @map("last_attempt_at") @db.Timestamptz(3)
errorMessage String? @map("error_message") @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
endpoint WebhookEndpoint @relation(fields: [webhookEndpointId], references: [id], onDelete: Cascade)
@@index([webhookEndpointId, createdAt])
@@index([status, nextRetryAt])
@@map("webhook_deliveries")
}
enum AsyncJobStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
model AsyncJob {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
type String @db.VarChar(100)
status AsyncJobStatus @default(PENDING)
input Json?
result Json?
error String? @db.Text
attempt Int @default(0)
maxAttempts Int @default(3) @map("max_attempts")
startedAt DateTime? @map("started_at") @db.Timestamptz(3)
completedAt DateTime? @map("completed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, createdAt])
@@index([status, createdAt])
@@map("async_jobs")
}
This diff is collapsed.
...@@ -8,13 +8,14 @@ import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middlew ...@@ -8,13 +8,14 @@ import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middlew
import routes from './routes'; import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config'; import { swaggerSpec, swaggerOptions } from './config/swagger.config';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware'; import { rateLimitMiddleware } from './middlewares/rate-limit.middleware';
import { maintenanceModeMiddleware } from './middlewares/maintenance-mode.middleware';
import { envConfig } from './config/env.config'; import { envConfig } from './config/env.config';
const app = express(); const app = express();
app.set('trust proxy', envConfig.trustProxy); app.set('trust proxy', envConfig.trustProxy);
app.use(helmet({ contentSecurityPolicy: false, }),); app.use(helmet({ contentSecurityPolicy: false }));
const corsOptions: cors.CorsOptions = { const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => { origin: (origin, callback) => {
...@@ -49,10 +50,11 @@ app.use(cookieParser()); ...@@ -49,10 +50,11 @@ app.use(cookieParser());
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions)); app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
app.use('/api/v1', rateLimitMiddleware, routes); app.use('/api/v1', rateLimitMiddleware, maintenanceModeMiddleware, routes);
app.use(notFoundMiddleware); app.use(notFoundMiddleware);
app.use(errorMiddleware); app.use(errorMiddleware);
export default app; export default app;
...@@ -85,6 +85,11 @@ export const PERMISSIONS = { ...@@ -85,6 +85,11 @@ export const PERMISSIONS = {
NOTIFICATION_READ: 'NOTIFICATION_READ', NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE', NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
NOTIFICATION_DELETE: 'NOTIFICATION_DELETE', NOTIFICATION_DELETE: 'NOTIFICATION_DELETE',
NOTIFICATION_ADMIN_READ: 'NOTIFICATION_ADMIN_READ',
NOTIFICATION_RETRY: 'NOTIFICATION_RETRY',
NOTIFICATION_TEMPLATE_READ: 'NOTIFICATION_TEMPLATE_READ',
NOTIFICATION_TEMPLATE_UPDATE: 'NOTIFICATION_TEMPLATE_UPDATE',
NOTIFICATION_CONFIG_UPDATE: 'NOTIFICATION_CONFIG_UPDATE',
// REMINDER // REMINDER
REMINDER_READ: 'REMINDER_READ', REMINDER_READ: 'REMINDER_READ',
...@@ -92,11 +97,36 @@ export const PERMISSIONS = { ...@@ -92,11 +97,36 @@ export const PERMISSIONS = {
REMINDER_UPDATE: 'REMINDER_UPDATE', REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE', REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT // AI ASSISTANT & AI ADMIN
AI_ASSISTANT_USE: 'AI_ASSISTANT_USE', AI_ASSISTANT_USE: 'AI_ASSISTANT_USE',
AI_ADMIN_READ: 'AI_ADMIN_READ',
AI_CONFIG_UPDATE: 'AI_CONFIG_UPDATE',
AI_USAGE_READ: 'AI_USAGE_READ',
// SYSTEM CONFIGURATION & MAINTENANCE
SYSTEM_CONFIG_READ: 'SYSTEM_CONFIG_READ',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
MAINTENANCE_MODE_UPDATE: 'MAINTENANCE_MODE_UPDATE',
// UPLOAD // UPLOAD
UPLOAD_FILE: 'UPLOAD_FILE', UPLOAD_FILE: 'UPLOAD_FILE',
// API KEY INTEGRATION
API_KEY_READ: 'API_KEY_READ',
API_KEY_CREATE: 'API_KEY_CREATE',
API_KEY_DELETE: 'API_KEY_DELETE',
// WEBHOOK INTEGRATION
WEBHOOK_READ: 'WEBHOOK_READ',
WEBHOOK_CREATE: 'WEBHOOK_CREATE',
WEBHOOK_UPDATE: 'WEBHOOK_UPDATE',
WEBHOOK_DELETE: 'WEBHOOK_DELETE',
WEBHOOK_TEST: 'WEBHOOK_TEST',
// ASYNC JOB
JOB_READ: 'JOB_READ',
JOB_CREATE: 'JOB_CREATE',
} as const; } as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
...@@ -37,6 +37,7 @@ export const ERROR_CODE = { ...@@ -37,6 +37,7 @@ export const ERROR_CODE = {
AI_PROVIDER_UNAVAILABLE: 'AI_PROVIDER_UNAVAILABLE', AI_PROVIDER_UNAVAILABLE: 'AI_PROVIDER_UNAVAILABLE',
AI_RESPONSE_INVALID: 'AI_RESPONSE_INVALID', AI_RESPONSE_INVALID: 'AI_RESPONSE_INVALID',
AI_RATE_LIMIT_EXCEEDED: 'AI_RATE_LIMIT_EXCEEDED', AI_RATE_LIMIT_EXCEEDED: 'AI_RATE_LIMIT_EXCEEDED',
RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND', CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED', CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED', PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
...@@ -46,6 +47,15 @@ export const ERROR_CODE = { ...@@ -46,6 +47,15 @@ export const ERROR_CODE = {
LAST_ADMIN_PROTECTED: 'LAST_ADMIN_PROTECTED', LAST_ADMIN_PROTECTED: 'LAST_ADMIN_PROTECTED',
ROLE_NOT_FOUND: 'ROLE_NOT_FOUND', ROLE_NOT_FOUND: 'ROLE_NOT_FOUND',
PERMISSION_NOT_FOUND: 'PERMISSION_NOT_FOUND', PERMISSION_NOT_FOUND: 'PERMISSION_NOT_FOUND',
MAINTENANCE_MODE_ACTIVE: 'MAINTENANCE_MODE_ACTIVE',
SSRF_REDIRECT_LOOP: 'SSRF_REDIRECT_LOOP',
SSRF_DNS_RESOLVE_FAILED: 'SSRF_DNS_RESOLVE_FAILED',
API_KEY_INVALID: 'API_KEY_INVALID',
API_KEY_EXPIRED: 'API_KEY_EXPIRED',
WEBHOOK_NOT_FOUND: 'WEBHOOK_NOT_FOUND',
WEBHOOK_DELIVERY_FAILED: 'WEBHOOK_DELIVERY_FAILED',
JOB_NOT_FOUND: 'JOB_NOT_FOUND',
} as const; } as const;
export type ErrorCode = keyof typeof ERROR_CODE; export type ErrorCode = keyof typeof ERROR_CODE;
import {
encryptSecret,
decryptSecret,
signWebhookPayload,
verifyWebhookSignature,
generateApiKey,
hashApiKey,
generateWebhookSecret,
} from './crypto.helper';
describe('crypto.helper', () => {
describe('AES-256-GCM Secret Encryption / Decryption', () => {
it('should encrypt and decrypt secret correctly', () => {
const original = 'whsec_9876543210abcdef1234567890';
const encrypted = encryptSecret(original);
expect(encrypted).not.toEqual(original);
expect(encrypted.split(':')).toHaveLength(3); // iv:authTag:cipher
const decrypted = decryptSecret(encrypted);
expect(decrypted).toEqual(original);
});
it('should return empty string when encrypting or decrypting empty string', () => {
expect(encryptSecret('')).toBe('');
expect(decryptSecret('')).toBe('');
});
it('should throw error on invalid ciphertext format', () => {
expect(() => decryptSecret('invalid_format')).toThrow(
'Invalid encrypted ciphertext format',
);
});
it('should produce different ciphertext for each encryption of the same plaintext (random IV)', () => {
const text = 'my_secret_key';
const enc1 = encryptSecret(text);
const enc2 = encryptSecret(text);
expect(enc1).not.toEqual(enc2);
expect(decryptSecret(enc1)).toEqual(text);
expect(decryptSecret(enc2)).toEqual(text);
});
});
describe('Webhook HMAC-SHA256 Signing & Verification', () => {
const secret = 'whsec_test_secret_key_123456';
const payload = JSON.stringify({ event: 'job.completed', jobId: '123' });
it('should sign and verify valid webhook payload', () => {
const now = Math.floor(Date.now() / 1000);
const { signature } = signWebhookPayload(payload, secret, now);
expect(signature).toContain(`t=${now},v1=`);
const isValid = verifyWebhookSignature(payload, signature, secret, 300);
expect(isValid).toBe(true);
});
it('should reject when payload is tampered', () => {
const now = Math.floor(Date.now() / 1000);
const { signature } = signWebhookPayload(payload, secret, now);
const tamperedPayload = JSON.stringify({ event: 'job.completed', jobId: '999' });
const isValid = verifyWebhookSignature(tamperedPayload, signature, secret, 300);
expect(isValid).toBe(false);
});
it('should reject when signature is tampered', () => {
const now = Math.floor(Date.now() / 1000);
const fakeSignature = `t=${now},v1=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef`;
const isValid = verifyWebhookSignature(payload, fakeSignature, secret, 300);
expect(isValid).toBe(false);
});
it('should reject when timestamp exceeds tolerance (Replay Attack)', () => {
const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago
const { signature } = signWebhookPayload(payload, secret, oldTimestamp);
// Tolerance is 300 seconds (5 minutes)
const isValid = verifyWebhookSignature(payload, signature, secret, 300);
expect(isValid).toBe(false);
});
it('should return false for missing or malformed signature header', () => {
expect(verifyWebhookSignature(payload, '', secret)).toBe(false);
expect(verifyWebhookSignature(payload, 'invalid-header', secret)).toBe(false);
expect(verifyWebhookSignature(payload, 't=123', secret)).toBe(false);
});
});
describe('API Key Generation & Hashing', () => {
it('should generate valid API key structure', () => {
const { rawKey, keyPrefix, keyHash } = generateApiKey();
expect(rawKey.startsWith('fw_live_')).toBe(true);
expect(keyPrefix.startsWith('fw_live_')).toBe(true);
expect(keyPrefix.length).toBe(16); // 'fw_live_' (8) + 8 chars = 16
expect(keyHash).toEqual(hashApiKey(rawKey));
expect(keyHash.length).toBe(64); // SHA-256 hex is 64 characters
});
it('should generate unique keys each time', () => {
const k1 = generateApiKey();
const k2 = generateApiKey();
expect(k1.rawKey).not.toEqual(k2.rawKey);
expect(k1.keyHash).not.toEqual(k2.keyHash);
});
it('should generate random webhook secrets', () => {
const s1 = generateWebhookSecret();
const s2 = generateWebhookSecret();
expect(s1.startsWith('whsec_')).toBe(true);
expect(s1).not.toEqual(s2);
});
});
});
import crypto from 'crypto';
import { envConfig } from '../../config/env.config';
const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
/**
* Lấy encryption key 32-byte từ APP_SECRET hoặc JWT accessSecret.
*/
function getEncryptionKey(): Buffer {
const secret = process.env.APP_SECRET || envConfig.jwt.accessSecret || 'finwise_default_secure_app_secret_key_32_bytes';
return crypto.createHash('sha256').update(secret).digest();
}
/**
* Mã hóa dữ liệu nhạy cảm (như webhook secret) bằng AES-256-GCM.
* Chuỗi trả về định dạng: `iv:authTag:encryptedData` (hex).
*/
export function encryptSecret(plainText: string): string {
if (!plainText) return '';
const key = getEncryptionKey();
const iv = crypto.randomBytes(12); // 96-bit IV recommended for GCM
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
/**
* Giải mã dữ liệu đã được mã hóa bằng AES-256-GCM.
*/
export function decryptSecret(cipherText: string): string {
if (!cipherText) return '';
const parts = cipherText.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted ciphertext format (expected iv:authTag:data)');
}
const [ivHex, authTagHex, encryptedHex] = parts;
const key = getEncryptionKey();
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Tạo chữ ký HMAC-SHA256 cho webhook payload kèm timestamp để chống Replay Attack.
* Signature format: `t=1756000000,v1=abcdef...`
*/
export function signWebhookPayload(payload: string, secret: string, timestamp: number = Math.floor(Date.now() / 1000)): { signature: string; timestamp: number } {
const signedPayload = `${timestamp}.${payload}`;
const hmac = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
return {
signature: `t=${timestamp},v1=${hmac}`,
timestamp,
};
}
/**
* Xác minh chữ ký HMAC-SHA256 của Webhook.
*/
export function verifyWebhookSignature(
payload: string,
signatureHeader: string,
secret: string,
toleranceSeconds: number = 300, // 5 minutes tolerance
): boolean {
if (!signatureHeader || !secret) return false;
const elements = signatureHeader.split(',');
let timestampStr: string | undefined;
let signatureV1: string | undefined;
for (const element of elements) {
const [key, value] = element.trim().split('=');
if (key === 't') timestampStr = value;
if (key === 'v1') signatureV1 = value;
}
if (!timestampStr || !signatureV1) return false;
const timestamp = parseInt(timestampStr, 10);
if (isNaN(timestamp)) return false;
// Check timestamp drift (replay attack prevention)
const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - timestamp) > toleranceSeconds) {
return false;
}
const expectedSignedPayload = `${timestamp}.${payload}`;
const expectedHmac = crypto.createHmac('sha256', secret).update(expectedSignedPayload).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signatureV1, 'hex'), Buffer.from(expectedHmac, 'hex'));
} catch {
return false;
}
}
/**
* Băm API Key bằng SHA-256 để lưu trữ an toàn trong DB.
*/
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
/**
* Sinh cặp API Key mới:
* - rawKey: `fw_live_<32 bytes hex>` (chỉ trả về cho người dùng 1 lần duy nhất)
* - keyPrefix: `fw_live_` + 8 ký tự đầu (để hiển thị nhận diện)
* - keyHash: SHA-256 hash lưu vào DB
*/
export function generateApiKey(prefix = 'fw_live_'): { rawKey: string; keyPrefix: string; keyHash: string } {
const randomBytes = crypto.randomBytes(24).toString('hex');
const rawKey = `${prefix}${randomBytes}`;
const keyPrefix = rawKey.substring(0, prefix.length + 8);
const keyHash = hashApiKey(rawKey);
return { rawKey, keyPrefix, keyHash };
}
/**
* Sinh Webhook Secret ngẫu nhiên (dạng `whsec_<random>`).
*/
export function generateWebhookSecret(): string {
return `whsec_${crypto.randomBytes(24).toString('hex')}`;
}
This diff is collapsed.
This diff is collapsed.
import crypto from 'crypto';
import { cacheService } from './cache.service'; import { cacheService } from './cache.service';
import { LoggerService } from './logger.service'; import { LoggerService } from './logger.service';
......
...@@ -169,18 +169,32 @@ export class MailService { ...@@ -169,18 +169,32 @@ export class MailService {
? `<p style="text-align: center; margin-top: 24px;"><a href="${escapeHtml(actionUrl)}" style="background-color: #2563eb; color: white; padding: 10px 18px; text-decoration: none; border-radius: 5px;">Open FinWise</a></p>` ? `<p style="text-align: center; margin-top: 24px;"><a href="${escapeHtml(actionUrl)}" style="background-color: #2563eb; color: white; padding: 10px 18px; text-decoration: none; border-radius: 5px;">Open FinWise</a></p>`
: ''; : '';
await this.transporter.sendMail({ if (!mailConfig.auth.user || !mailConfig.auth.pass) {
from: mailConfig.from, console.warn('-------- NOTIFICATION EMAIL (DEV MODE) --------');
to: email, console.warn(`To: ${email}`);
subject: `[FinWise] ${notification.title.replace(/[\r\n]+/g, ' ')}`, console.warn(`Subject: ${notification.title}`);
html: ` console.warn(`Message: ${notification.message}`);
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;"> console.warn('----------------------------------------------');
<p>Hello ${escapeHtml(fullName || 'there')},</p> return;
<h2>${escapeHtml(notification.title)}</h2> }
<p>${escapeHtml(notification.message)}</p>
${actionHtml} try {
</div> await this.transporter.sendMail({
`, from: mailConfig.from,
}); to: email,
subject: `[FinWise] ${notification.title.replace(/[\r\n]+/g, ' ')}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<p>Hello ${escapeHtml(fullName || 'there')},</p>
<h2>${escapeHtml(notification.title)}</h2>
<p>${escapeHtml(notification.message)}</p>
${actionHtml}
</div>
`,
});
} catch (error) {
console.error('Failed to send notification email:', error);
throw error;
}
} }
} }
...@@ -3,10 +3,11 @@ declare global { ...@@ -3,10 +3,11 @@ declare global {
interface Request { interface Request {
user: { user: {
id: string; id: string;
email: string; email: string | null;
roleId?: string; roleId?: string;
role: string; role: string;
permissions?: string[]; permissions?: string[];
apiKeyId?: string;
}; };
} }
} }
......
...@@ -76,6 +76,36 @@ export const envConfig = { ...@@ -76,6 +76,36 @@ export const envConfig = {
? value ? value
: 300000; : 300000;
})(), })(),
concurrency: (() => {
const value = parseInt(process.env.NOTIFICATION_WORKER_CONCURRENCY || '5', 10);
return Number.isFinite(value) && value >= 1 && value <= 50 ? value : 5;
})(),
deliveryTimeoutMs: (() => {
const value = parseInt(process.env.NOTIFICATION_DELIVERY_TIMEOUT_MS || '10000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 60000 ? value : 10000;
})(),
maxDeliveryAttempts: (() => {
const value = parseInt(process.env.NOTIFICATION_MAX_DELIVERY_ATTEMPTS || '5', 10);
return Number.isFinite(value) && value >= 1 && value <= 20 ? value : 5;
})(),
backoffBaseDelayMs: (() => {
const value = parseInt(process.env.NOTIFICATION_BACKOFF_BASE_DELAY_MS || '60000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 600000 ? value : 60000;
})(),
},
webhooks: {
concurrency: (() => {
const value = parseInt(process.env.WEBHOOK_WORKER_CONCURRENCY || '5', 10);
return Number.isFinite(value) && value >= 1 && value <= 50 ? value : 5;
})(),
timeoutMs: (() => {
const value = parseInt(process.env.WEBHOOK_TIMEOUT_MS || '10000', 10);
return Number.isFinite(value) && value >= 1000 && value <= 60000 ? value : 10000;
})(),
maxAttempts: (() => {
const value = parseInt(process.env.WEBHOOK_MAX_ATTEMPTS || '5', 10);
return Number.isFinite(value) && value >= 1 && value <= 20 ? value : 5;
})(),
}, },
recurringTransactions: { recurringTransactions: {
batchLimit: (() => { batchLimit: (() => {
...@@ -129,4 +159,14 @@ export const envConfig = { ...@@ -129,4 +159,14 @@ export const envConfig = {
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000', 10), maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000', 10),
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes default windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes default
}, },
apiKeyRateLimit: {
maxRequests: (() => {
const val = parseInt(process.env.API_KEY_RATE_LIMIT_MAX_REQUESTS || '120', 10);
return Number.isFinite(val) && val >= 1 && val <= 10000 ? val : 120;
})(),
windowMs: (() => {
const val = parseInt(process.env.API_KEY_RATE_LIMIT_WINDOW_MS || '60000', 10);
return Number.isFinite(val) && val >= 1000 && val <= 3600000 ? val : 60000;
})(),
},
}; };
This diff is collapsed.
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { AiRequestStatus } from '@prisma/client';
import { ERROR_CODE } from '../common/errors/error-code'; import { ERROR_CODE } from '../common/errors/error-code';
import { envConfig } from '../config/env.config'; import { envConfig } from '../config/env.config';
import { cacheService } from '../common/services/cache.service';
import { systemSettingService } from '../modules/system-settings/system-setting.service';
import { adminAiRepository } from '../modules/ai-assistant/admin-ai.repository';
interface RateLimitRecord { interface RateLimitRecord {
count: number; count: number;
...@@ -10,14 +14,62 @@ interface RateLimitRecord { ...@@ -10,14 +14,62 @@ interface RateLimitRecord {
const requestCounts = new Map<string, RateLimitRecord>(); const requestCounts = new Map<string, RateLimitRecord>();
let lastCleanupAt = 0; let lastCleanupAt = 0;
export function aiRateLimitMiddleware( export async function aiRateLimitMiddleware(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
): void { ): Promise<void> {
const key = req.user?.id || req.ip || req.socket.remoteAddress || 'unknown'; const key = req.user?.id || req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now(); const now = Date.now();
if (now - lastCleanupAt >= envConfig.ai.rateLimit.windowMs) {
const [maxRequests, windowMs] = await Promise.all([
systemSettingService.getNumber('ai.rate_limit.max_requests', envConfig.ai.rateLimit.maxRequests),
systemSettingService.getNumber('ai.rate_limit.window_ms', envConfig.ai.rateLimit.windowMs),
]);
const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) {
try {
const redisKey = `finwise:ai-rate-limit:${key}`;
const currentCount = await redisClient.incr(redisKey);
if (currentCount === 1) {
await redisClient.pexpire(redisKey, windowMs);
}
if (currentCount > maxRequests) {
const ttlMs = await redisClient.pttl(redisKey);
const retryAfterSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
await adminAiRepository.createLog({
userId: req.user?.id,
feature: 'RATE_LIMIT',
provider: 'system',
model: 'rate-limiter',
status: AiRequestStatus.RATE_LIMITED,
latencyMs: 0,
errorMessage: `AI request rate limit exceeded (${currentCount}/${maxRequests} in ${Math.round(windowMs / 1000)}s)`,
}).catch(() => {});
res.status(429).json({
success: false,
message: 'AI request limit exceeded, please try again later',
code: ERROR_CODE.AI_RATE_LIMIT_EXCEEDED,
});
return;
}
next();
return;
} catch (error) {
console.error('Redis AI rate limiting failed. Falling back to memory rate limiting.', error);
}
}
if (now - lastCleanupAt >= windowMs) {
requestCounts.forEach((value, recordKey) => { requestCounts.forEach((value, recordKey) => {
if (now >= value.resetAt) { if (now >= value.resetAt) {
requestCounts.delete(recordKey); requestCounts.delete(recordKey);
...@@ -30,23 +82,36 @@ export function aiRateLimitMiddleware( ...@@ -30,23 +82,36 @@ export function aiRateLimitMiddleware(
if (!record || now >= record.resetAt) { if (!record || now >= record.resetAt) {
requestCounts.set(key, { requestCounts.set(key, {
count: 1, count: 1,
resetAt: now + envConfig.ai.rateLimit.windowMs, resetAt: now + windowMs,
}); });
next(); next();
return; return;
} }
record.count += 1; record.count += 1;
if (record.count <= envConfig.ai.rateLimit.maxRequests) { if (record.count <= maxRequests) {
next(); next();
return; return;
} }
const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000)); const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString()); res.setHeader('Retry-After', retryAfterSeconds.toString());
// Log rate limit event
await adminAiRepository.createLog({
userId: req.user?.id,
feature: 'RATE_LIMIT',
provider: 'system',
model: 'rate-limiter',
status: AiRequestStatus.RATE_LIMITED,
latencyMs: 0,
errorMessage: `AI request rate limit exceeded (${record.count}/${maxRequests} in ${Math.round(windowMs / 1000)}s)`,
}).catch(() => {});
res.status(429).json({ res.status(429).json({
success: false, success: false,
message: 'AI request limit exceeded, please try again later', message: 'AI request limit exceeded, please try again later',
code: ERROR_CODE.AI_RATE_LIMIT_EXCEEDED, code: ERROR_CODE.AI_RATE_LIMIT_EXCEEDED,
}); });
} }
import { Request, Response, NextFunction } from 'express';
import { apiKeyRateLimitMiddleware } from './api-key-rate-limit.middleware';
describe('apiKeyRateLimitMiddleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: jest.Mock;
let headers: Record<string, string>;
beforeEach(() => {
headers = {};
req = {
user: {
id: 'user-123',
email: 'user@example.com',
role: 'USER' as any,
permissions: ['TRANSACTION_CREATE'],
apiKeyId: 'key-test-rate-limit',
},
};
res = {
setHeader: jest.fn((name: string, value: string) => {
headers[name] = value;
return res as Response;
}),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
next = jest.fn();
jest.clearAllMocks();
});
it('should skip rate limiting if request is not authenticated via API Key', async () => {
req.user = {
id: 'user-123',
email: 'user@example.com',
role: 'USER' as any,
permissions: ['TRANSACTION_CREATE'],
};
await apiKeyRateLimitMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.setHeader).not.toHaveBeenCalled();
});
it('should attach X-RateLimit headers and call next on valid request', async () => {
await apiKeyRateLimitMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', expect.any(String));
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', expect.any(String));
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(String));
});
it('should return 429 when max requests are exceeded within window', async () => {
const customKeyId = `key-excess-${Date.now()}`;
req.user!.apiKeyId = customKeyId;
// Simulate sending 125 requests (limit is 120)
for (let i = 0; i < 120; i++) {
await apiKeyRateLimitMiddleware(req as Request, res as Response, next);
}
expect(next).toHaveBeenCalledTimes(120);
// 121st request should be blocked
await apiKeyRateLimitMiddleware(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
code: 'RATE_LIMIT_EXCEEDED',
}),
);
});
});
import { Request, Response, NextFunction } from 'express';
import { envConfig } from '../config/env.config';
import { cacheService } from '../common/services/cache.service';
import { ERROR_CODE } from '../common/errors/error-code';
interface RateLimitRecord {
count: number;
resetAt: number;
}
const keyCounts = new Map<string, RateLimitRecord>();
let lastCleanupAt = 0;
export async function apiKeyRateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const apiKeyId = req.user?.apiKeyId;
// Only apply to requests authenticated via API Key
if (!apiKeyId) {
next();
return;
}
const now = Date.now();
const maxRequests = envConfig.apiKeyRateLimit.maxRequests;
const windowMs = envConfig.apiKeyRateLimit.windowMs;
const isUsingRedis = cacheService.isUsingRedis();
const redisClient = cacheService.getRedisClient();
if (isUsingRedis && redisClient) {
try {
const redisKey = `finwise:api-key-rate-limit:${apiKeyId}`;
const currentCount = await redisClient.incr(redisKey);
if (currentCount === 1) {
await redisClient.pexpire(redisKey, windowMs);
}
const ttlMs = await redisClient.pttl(redisKey);
const remaining = Math.max(0, maxRequests - currentCount);
const resetTimeSeconds = Math.ceil((now + ttlMs) / 1000);
res.setHeader('X-RateLimit-Limit', maxRequests.toString());
res.setHeader('X-RateLimit-Remaining', remaining.toString());
res.setHeader('X-RateLimit-Reset', resetTimeSeconds.toString());
if (currentCount > maxRequests) {
const retryAfterSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
message: 'API Key rate limit exceeded. Please throttle your requests.',
code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
});
return;
}
next();
return;
} catch (error) {
console.error('Redis API key rate limiting failed. Falling back to memory.', error);
}
}
// Memory-safe sliding window rate limit fallback
if (now - lastCleanupAt >= windowMs) {
keyCounts.forEach((record, key) => {
if (now >= record.resetAt) {
keyCounts.delete(key);
}
});
lastCleanupAt = now;
}
let record = keyCounts.get(apiKeyId);
if (!record || now >= record.resetAt) {
record = {
count: 1,
resetAt: now + windowMs,
};
keyCounts.set(apiKeyId, record);
res.setHeader('X-RateLimit-Limit', maxRequests.toString());
res.setHeader('X-RateLimit-Remaining', (maxRequests - 1).toString());
res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000).toString());
next();
return;
}
record.count += 1;
const remaining = Math.max(0, maxRequests - record.count);
res.setHeader('X-RateLimit-Limit', maxRequests.toString());
res.setHeader('X-RateLimit-Remaining', remaining.toString());
res.setHeader('X-RateLimit-Reset', Math.ceil(record.resetAt / 1000).toString());
if (record.count > maxRequests) {
const retryAfterSeconds = Math.max(1, Math.ceil((record.resetAt - now) / 1000));
res.setHeader('Retry-After', retryAfterSeconds.toString());
res.status(429).json({
success: false,
message: 'API Key rate limit exceeded. Please throttle your requests.',
code: ERROR_CODE.RATE_LIMIT_EXCEEDED,
});
return;
}
next();
}
import { Request, Response, NextFunction } from 'express';
import { apiKeyMiddleware } from './api-key.middleware';
import { prisma } from '../database/prisma.client';
import { generateApiKey } from '../common/helpers/crypto.helper';
import { rbacService } from '../modules/rbac/rbac.service';
import { AppError } from '../common/errors/app-error';
import { ApiKeyStatus } from '@prisma/client';
jest.mock('../database/prisma.client', () => ({
prisma: {
apiKey: {
findUnique: jest.fn(),
update: jest.fn().mockResolvedValue({}),
},
},
}));
jest.mock('../modules/rbac/rbac.service', () => ({
rbacService: {
getUserPermissions: jest.fn(),
},
}));
describe('apiKeyMiddleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: jest.Mock;
beforeEach(() => {
req = {
headers: {},
};
res = {};
next = jest.fn();
jest.clearAllMocks();
});
it('should throw UNAUTHORIZED when no API key header is provided', async () => {
await apiKeyMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledTimes(1);
const err = next.mock.calls[0][0];
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(401);
expect(err.code).toBe('UNAUTHORIZED');
});
it('should authenticate valid API Key from X-API-Key header', async () => {
const { rawKey, keyHash, keyPrefix } = generateApiKey();
req.headers = { 'x-api-key': rawKey };
const mockApiKey = {
id: 'key-123',
name: 'Integration Key',
keyPrefix,
keyHash,
permissions: ['TRANSACTION_CREATE'],
status: ApiKeyStatus.ACTIVE,
expiresAt: null,
deletedAt: null,
user: {
id: 'user-456',
email: 'user@example.com',
isActive: true,
deletedAt: null,
role: { name: 'USER' },
},
};
(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue(mockApiKey);
(rbacService.getUserPermissions as jest.Mock).mockResolvedValue([
'TRANSACTION_CREATE',
'TRANSACTION_READ',
'WALLET_READ',
]);
await apiKeyMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(req.user).toEqual({
id: 'user-456',
email: 'user@example.com',
role: 'USER',
permissions: ['TRANSACTION_CREATE'], // Intersected with apiKey.permissions
apiKeyId: 'key-123',
});
});
it('should reject when API key is expired', async () => {
const { rawKey, keyHash } = generateApiKey();
req.headers = { 'x-api-key': rawKey };
const mockApiKey = {
id: 'key-123',
keyHash,
status: ApiKeyStatus.ACTIVE,
expiresAt: new Date(Date.now() - 10000), // Expired in past
deletedAt: null,
user: { id: 'user-456', isActive: true, deletedAt: null, role: { name: 'USER' } },
};
(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue(mockApiKey);
await apiKeyMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledTimes(1);
const err = next.mock.calls[0][0];
expect(err).toBeInstanceOf(AppError);
expect(err.code).toBe('API_KEY_EXPIRED');
});
it('should allow request when client IP is in ipWhitelist', async () => {
const { rawKey, keyHash, keyPrefix } = generateApiKey();
req.headers = { 'x-api-key': rawKey, 'x-forwarded-for': '203.113.130.1' };
const mockApiKey = {
id: 'key-123',
name: 'IP Restricted Key',
keyPrefix,
keyHash,
permissions: ['TRANSACTION_READ'],
ipWhitelist: ['203.113.130.1', '198.51.100.2'],
status: ApiKeyStatus.ACTIVE,
expiresAt: null,
deletedAt: null,
user: {
id: 'user-456',
email: 'user@example.com',
isActive: true,
deletedAt: null,
role: { name: 'USER' },
},
};
(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue(mockApiKey);
(rbacService.getUserPermissions as jest.Mock).mockResolvedValue(['TRANSACTION_READ']);
await apiKeyMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(req.user?.apiKeyId).toBe('key-123');
});
it('should reject with FORBIDDEN when client IP is not in ipWhitelist', async () => {
const { rawKey, keyHash, keyPrefix } = generateApiKey();
req.headers = { 'x-api-key': rawKey, 'x-forwarded-for': '1.2.3.4' };
const mockApiKey = {
id: 'key-123',
name: 'IP Restricted Key',
keyPrefix,
keyHash,
permissions: ['TRANSACTION_READ'],
ipWhitelist: ['203.113.130.1'],
status: ApiKeyStatus.ACTIVE,
expiresAt: null,
deletedAt: null,
user: {
id: 'user-456',
email: 'user@example.com',
isActive: true,
deletedAt: null,
role: { name: 'USER' },
},
};
(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue(mockApiKey);
await apiKeyMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledTimes(1);
const err = next.mock.calls[0][0];
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(403);
expect(err.code).toBe('FORBIDDEN');
});
});
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../database/prisma.client';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
import { hashApiKey } from '../common/helpers/crypto.helper';
import { rbacService } from '../modules/rbac/rbac.service';
import { ApiKeyStatus } from '@prisma/client';
/**
* Middleware xác thực API Key từ hệ thống bên ngoài:
* - Hỗ trợ Header: `X-API-Key: fw_live_...` hoặc `Authorization: Bearer fw_live_...` / `Authorization: ApiKey fw_live_...`
* - Băm key bằng SHA-256 để tìm trong database
* - Kiểm tra trạng thái isActive, expiration, deletedAt
* - Nạp user sở hữu và gán vào `req.user`
*/
export async function apiKeyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
let rawKey: string | undefined;
const xApiKey = req.headers['x-api-key'];
if (typeof xApiKey === 'string' && xApiKey.trim()) {
rawKey = xApiKey.trim();
}
if (!rawKey) {
const authHeader = req.headers.authorization;
if (authHeader) {
if (authHeader.startsWith('Bearer fw_') || authHeader.startsWith('ApiKey fw_')) {
rawKey = authHeader.split(' ')[1]?.trim();
}
}
}
if (!rawKey) {
next(new AppError('API Key is missing', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
try {
const keyHash = hashApiKey(rawKey);
const apiKey = await prisma.apiKey.findUnique({
where: { keyHash },
include: {
user: {
include: {
role: true,
},
},
},
});
if (!apiKey || apiKey.status !== ApiKeyStatus.ACTIVE || apiKey.deletedAt !== null) {
next(new AppError('Invalid or revoked API Key', 401, ERROR_CODE.API_KEY_INVALID));
return;
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
next(new AppError('API Key has expired', 401, ERROR_CODE.API_KEY_EXPIRED));
return;
}
if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0) {
const isWildcard = apiKey.ipWhitelist.some((ip) => ip.trim() === '*');
if (!isWildcard) {
const forwarded = req.headers['x-forwarded-for'];
const rawIp = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : (req.ip || req.socket.remoteAddress || '');
const cleanClientIp = rawIp.replace(/^::ffff:/, '');
const isAllowed = apiKey.ipWhitelist.some((allowedIp) => {
const cleanAllowedIp = allowedIp.trim().replace(/^::ffff:/, '');
return cleanClientIp === cleanAllowedIp || rawIp === allowedIp.trim();
});
if (!isAllowed) {
next(new AppError(`Client IP (${rawIp}) is not in API Key whitelist`, 403, ERROR_CODE.FORBIDDEN));
return;
}
}
}
const user = apiKey.user;
if (!user || !user.isActive || user.deletedAt !== null) {
next(new AppError('API Key owner account is inactive', 403, ERROR_CODE.USER_INACTIVE));
return;
}
// Load user permissions from RBAC service cache
let permissions = await rbacService.getUserPermissions(user.id);
// If API key has scoped permissions restricted to a subset, intersect them
if (apiKey.permissions && apiKey.permissions.length > 0) {
permissions = permissions.filter((p) => apiKey.permissions.includes(p));
}
req.user = {
id: user.id,
email: user.email,
role: user.role.name as any,
permissions,
apiKeyId: apiKey.id,
};
// Update lastUsedAt asynchronously without blocking request
(async () => {
try {
await prisma.apiKey.update({
where: { id: apiKey.id },
data: { lastUsedAt: new Date() },
});
} catch (err) {
console.error('Failed to update API key lastUsedAt:', err);
}
})();
next();
} catch (error) {
next(error);
}
}
/**
* Middleware cho phép xác thực linh hoạt: qua JWT Token (User UI) HOẶC qua API Key (External Integration).
*/
export async function authOrApiKeyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const hasApiKey = Boolean(req.headers['x-api-key'] || req.headers.authorization?.startsWith('Bearer fw_') || req.headers.authorization?.startsWith('ApiKey '));
if (hasApiKey) {
return apiKeyMiddleware(req, res, next);
}
// Fallback to standard JWT auth middleware
const { authMiddleware } = await import('./auth.middleware');
return authMiddleware(req, res, next);
}
import jwt from 'jsonwebtoken';
import { NextFunction, Request, Response } from 'express';
import { ERROR_CODE } from '../common/errors/error-code';
import { PERMISSIONS } from '../common/constants/permission.constant';
import { envConfig } from '../config/env.config';
import { rbacService } from '../modules/rbac/rbac.service';
import { systemSettingService } from '../modules/system-settings/system-setting.service';
const WHITELIST_PATHS = [
'/health',
'/api/v1/health',
'/api/v1/system/public-config',
'/api/v1/auth/login',
'/api/v1/auth/me',
'/api/v1/auth/logout',
'/api/v1/auth/refresh',
'/api/docs',
];
const ADMIN_PATH_PREFIXES = [
'/api/v1/admin',
'/api/v1/roles',
'/api/v1/permissions',
'/api/v1/audit-logs',
];
export async function maintenanceModeMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const rawPath = req.path;
const fullPath = (req.originalUrl || req.url || '').split('?')[0];
// 1. Check if the path is explicitly whitelisted
const isWhitelisted = WHITELIST_PATHS.some(
(p) =>
rawPath === p ||
fullPath === p ||
rawPath === p.replace(/^\/api\/v1/, '') ||
fullPath === `/api/v1${p}`,
);
if (isWhitelisted) {
next();
return;
}
// 2. Check if the path is an admin path
const isAdmin = ADMIN_PATH_PREFIXES.some(
(prefix) =>
rawPath.startsWith(prefix) ||
fullPath.startsWith(prefix) ||
rawPath.startsWith(prefix.replace(/^\/api\/v1/, '')),
);
if (isAdmin) {
next();
return;
}
try {
const isMaintenance = await systemSettingService.getBoolean(
'system.maintenance.enabled',
false,
);
if (!isMaintenance) {
next();
return;
}
// 3. If user is authenticated, check if they have admin/maintenance bypass permissions
let user = req.user;
if (!user) {
const authHeader = req.headers.authorization;
const cookieToken = req.cookies?.accessToken;
let token: string | undefined;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (cookieToken) {
token = cookieToken;
}
if (token) {
try {
const payload = jwt.verify(token, envConfig.jwt.accessSecret) as {
id: string;
email?: string;
roleId?: string;
role?: string;
};
if (payload?.id) {
const permissions = await rbacService.getUserPermissions(payload.id);
user = {
id: payload.id,
email: payload.email || '',
roleId: payload.roleId || '',
role: payload.role || '',
permissions,
};
req.user = user;
}
} catch {
// Invalid or expired token; proceed to maintenance check
}
}
}
if (user) {
let permissions = user.permissions;
if (!permissions) {
permissions = await rbacService.getUserPermissions(user.id);
user.permissions = permissions;
}
const hasAdminBypass = [
PERMISSIONS.MAINTENANCE_MODE_UPDATE,
PERMISSIONS.SYSTEM_CONFIG_UPDATE,
PERMISSIONS.SYSTEM_CONFIG_READ,
].some((p) => permissions.includes(p));
if (hasAdminBypass) {
next();
return;
}
}
// 4. Reject normal user request with 503 Maintenance Mode Active
const [message, startAt, endAt] = await Promise.all([
systemSettingService.getString(
'system.maintenance.message',
'Hệ thống FinWise đang bảo trì để nâng cấp định kỳ. Vui lòng quay lại sau ít phút.',
),
systemSettingService.getString('system.maintenance.start_at', ''),
systemSettingService.getString('system.maintenance.end_at', ''),
]);
res.status(503).json({
success: false,
code: ERROR_CODE.MAINTENANCE_MODE_ACTIVE,
message,
maintenance: {
enabled: true,
message,
startAt: startAt || null,
endAt: endAt || null,
},
});
} catch (error) {
// If checking maintenance mode fails (e.g., db temporarily unstable), don't brick the request; pass to error handler
next(error);
}
}
import { NextFunction, Request, Response } from 'express';
import { adminAiService } from './admin-ai.service';
import { AiRequestLogQueryDto } from './admin-ai.dto';
export class AdminAiController {
getFeatureStatuses = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const statuses = await adminAiService.getFeatureStatuses();
res.status(200).json({
success: true,
data: statuses,
});
} catch (error) {
next(error);
}
};
toggleFeature = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { feature } = req.params;
const { enabled } = req.body;
const actorId = req.user?.id;
const ipAddress = req.ip || req.socket.remoteAddress;
const userAgent = req.headers['user-agent'];
const result = await adminAiService.toggleFeature(
feature,
enabled,
actorId,
{ ipAddress, userAgent },
);
res.status(200).json({
success: true,
data: result,
message: `${enabled ? 'Đã kích hoạt' : 'Đã tạm dừng'} tính năng [${result.name}] thành công`,
});
} catch (error) {
next(error);
}
};
getUsageSummary = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { period } = req.query as { period?: 'today' | 'week' | 'month' };
const summary = await adminAiService.getUsageSummary(period);
res.status(200).json({
success: true,
data: summary,
});
} catch (error) {
next(error);
}
};
findLogs = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const query = req.query as unknown as AiRequestLogQueryDto;
const result = await adminAiService.findLogs(query);
res.status(200).json({
success: true,
data: result.data,
meta: result.meta,
});
} catch (error) {
next(error);
}
};
getRateLimitConfig = async (_req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const config = await adminAiService.getRateLimitConfig();
res.status(200).json({
success: true,
data: config,
});
} catch (error) {
next(error);
}
};
updateRateLimitConfig = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const dto = req.body;
const actorId = req.user?.id;
const ipAddress = req.ip || req.socket.remoteAddress;
const userAgent = req.headers['user-agent'];
const config = await adminAiService.updateRateLimitConfig(
dto,
actorId,
{ ipAddress, userAgent },
);
res.status(200).json({
success: true,
data: config,
message: 'Cập nhật giới hạn AI Rate Limit thành công',
});
} catch (error) {
next(error);
}
};
}
export const adminAiController = new AdminAiController();
import { AiRequestStatus } from '@prisma/client';
export type AiFeatureKey = 'assistant' | 'forecasting' | 'anomalies' | 'query';
export interface AiFeatureStatusDto {
key: AiFeatureKey;
name: string;
enabled: boolean;
status: 'ENABLED' | 'DISABLED' | 'DEGRADED';
settingKey: string;
provider: string;
model: string;
description: string;
}
export interface AiUsageSummaryDto {
period: 'today' | 'week' | 'month';
totalRequests: number;
successRequests: number;
failedRequests: number;
rateLimitEvents: number;
successRate: number; // percentage e.g. 98.2
avgLatencyMs: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
isUsageAvailable: boolean;
byFeature: Array<{
feature: string;
total: number;
success: number;
failed: number;
avgLatencyMs: number;
totalTokens: number;
}>;
}
export interface AiRequestLogQueryDto {
feature?: string;
status?: AiRequestStatus;
userId?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}
export interface AiRequestLogItemDto {
id: string;
userId: string | null;
feature: string;
provider: string;
model: string;
status: AiRequestStatus;
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
latencyMs: number;
errorMessage: string | null;
createdAt: Date;
}
export interface ToggleAiFeatureDto {
enabled: boolean;
}
export interface UpdateAiRateLimitDto {
maxRequests: number;
windowMs: number;
}
export interface AiRateLimitConfigDto {
maxRequests: number;
windowMs: number;
}
import { AiRequestStatus, Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
AiRequestLogItemDto,
AiRequestLogQueryDto,
AiUsageSummaryDto,
} from './admin-ai.dto';
export class AdminAiRepository {
async createLog(data: {
userId?: string | null;
feature: string;
provider: string;
model: string;
status: AiRequestStatus;
promptTokens?: number | null;
completionTokens?: number | null;
totalTokens?: number | null;
latencyMs: number;
errorMessage?: string | null;
}) {
return prisma.aiRequestLog.create({
data: {
userId: data.userId ?? null,
feature: data.feature,
provider: data.provider,
model: data.model,
status: data.status,
promptTokens: data.promptTokens ?? null,
completionTokens: data.completionTokens ?? null,
totalTokens: data.totalTokens ?? null,
latencyMs: Math.max(0, Math.round(data.latencyMs)),
errorMessage: data.errorMessage ? data.errorMessage.slice(0, 500) : null,
},
});
}
async getUsageSummary(
from: Date,
to: Date,
period: 'today' | 'week' | 'month',
): Promise<AiUsageSummaryDto> {
const where: Prisma.AiRequestLogWhereInput = {
createdAt: {
gte: from,
lte: to,
},
};
const [
aggregate,
statusGrouped,
featureGrouped,
] = await Promise.all([
prisma.aiRequestLog.aggregate({
where,
_count: { _all: true },
_avg: { latencyMs: true },
_sum: {
promptTokens: true,
completionTokens: true,
totalTokens: true,
},
}),
prisma.aiRequestLog.groupBy({
by: ['status'],
where,
_count: { _all: true },
}),
prisma.aiRequestLog.groupBy({
by: ['feature', 'status'],
where,
_count: { _all: true },
_avg: { latencyMs: true },
_sum: { totalTokens: true },
}),
]);
const totalRequests = aggregate._count._all;
let successRequests = 0;
let failedRequests = 0;
let rateLimitEvents = 0;
for (const group of statusGrouped) {
if (group.status === AiRequestStatus.SUCCESS) {
successRequests += group._count._all;
} else if (group.status === AiRequestStatus.FAILED) {
failedRequests += group._count._all;
} else if (group.status === AiRequestStatus.RATE_LIMITED) {
rateLimitEvents += group._count._all;
}
}
const evaluatedRequests = successRequests + failedRequests;
const successRate =
evaluatedRequests > 0
? Math.round((successRequests / evaluatedRequests) * 1000) / 10
: 100;
const featureMap = new Map<
string,
{ total: number; success: number; failed: number; totalLatency: number; latencyCount: number; totalTokens: number }
>();
for (const g of featureGrouped) {
const current = featureMap.get(g.feature) ?? {
total: 0,
success: 0,
failed: 0,
totalLatency: 0,
latencyCount: 0,
totalTokens: 0,
};
const count = g._count._all;
current.total += count;
if (g.status === AiRequestStatus.SUCCESS) current.success += count;
if (g.status === AiRequestStatus.FAILED) current.failed += count;
if (g._avg.latencyMs) {
current.totalLatency += g._avg.latencyMs * count;
current.latencyCount += count;
}
if (g._sum.totalTokens) {
current.totalTokens += g._sum.totalTokens;
}
featureMap.set(g.feature, current);
}
const byFeature = Array.from(featureMap.entries()).map(([feature, stats]) => ({
feature,
total: stats.total,
success: stats.success,
failed: stats.failed,
avgLatencyMs:
stats.latencyCount > 0
? Math.round(stats.totalLatency / stats.latencyCount)
: 0,
totalTokens: stats.totalTokens,
}));
return {
period,
totalRequests,
successRequests,
failedRequests,
rateLimitEvents,
successRate,
avgLatencyMs: Math.round(aggregate._avg.latencyMs ?? 0),
promptTokens: aggregate._sum.promptTokens ?? 0,
completionTokens: aggregate._sum.completionTokens ?? 0,
totalTokens: aggregate._sum.totalTokens ?? 0,
isUsageAvailable: totalRequests > 0,
byFeature,
};
}
async findLogs(query: AiRequestLogQueryDto) {
const {
feature,
status,
userId,
dateFrom,
dateTo,
page = 1,
limit = 20,
} = query;
const where: Prisma.AiRequestLogWhereInput = {
...(feature ? { feature } : {}),
...(status ? { status } : {}),
...(userId ? { userId } : {}),
...(dateFrom || dateTo
? {
createdAt: {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
...(dateTo ? { lte: new Date(dateTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [logs, total] = await prisma.$transaction([
prisma.aiRequestLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.aiRequestLog.count({ where }),
]);
const formattedData: AiRequestLogItemDto[] = logs.map((log) => ({
id: log.id,
userId: log.userId,
feature: log.feature,
provider: log.provider,
model: log.model,
status: log.status,
promptTokens: log.promptTokens,
completionTokens: log.completionTokens,
totalTokens: log.totalTokens,
latencyMs: log.latencyMs,
errorMessage: log.errorMessage,
createdAt: log.createdAt,
}));
return {
data: formattedData,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}
export const adminAiRepository = new AdminAiRepository();
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants/permission.constant';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { adminAiController } from './admin-ai.controller';
import {
aiRequestLogQuerySchema,
aiUsageSummaryQuerySchema,
toggleAiFeatureSchema,
updateAiRateLimitSchema,
} from './admin-ai.validation';
export const adminAiRouter = Router();
adminAiRouter.use(authMiddleware);
adminAiRouter.get(
'/status',
requirePermission(PERMISSIONS.AI_ADMIN_READ),
adminAiController.getFeatureStatuses,
);
adminAiRouter.post(
'/features/:feature/toggle',
requirePermission(PERMISSIONS.AI_CONFIG_UPDATE),
validate(toggleAiFeatureSchema),
adminAiController.toggleFeature,
);
adminAiRouter.get(
'/usage',
requirePermission(PERMISSIONS.AI_USAGE_READ),
validate(aiUsageSummaryQuerySchema, 'query'),
adminAiController.getUsageSummary,
);
adminAiRouter.get(
'/logs',
requirePermission(PERMISSIONS.AI_USAGE_READ),
validate(aiRequestLogQuerySchema, 'query'),
adminAiController.findLogs,
);
adminAiRouter.get(
'/rate-limit',
requirePermission(PERMISSIONS.AI_ADMIN_READ),
adminAiController.getRateLimitConfig,
);
adminAiRouter.put(
'/rate-limit',
requirePermission(PERMISSIONS.AI_CONFIG_UPDATE),
validate(updateAiRateLimitSchema),
adminAiController.updateRateLimitConfig,
);
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config';
import { rbacRepository } from '../rbac/rbac.repository';
import { systemSettingService } from '../system-settings/system-setting.service';
import {
AiFeatureKey,
AiFeatureStatusDto,
AiRateLimitConfigDto,
AiRequestLogQueryDto,
UpdateAiRateLimitDto,
} from './admin-ai.dto';
import {
adminAiRepository,
AdminAiRepository,
} from './admin-ai.repository';
export class AdminAiService {
constructor(
private readonly repository: AdminAiRepository = adminAiRepository,
) {}
async getFeatureStatuses(): Promise<AiFeatureStatusDto[]> {
const isGeminiConfigured = envConfig.ai.geminiApiKeys.length > 0;
const [
assistantEnabled,
forecastingEnabled,
anomaliesEnabled,
queryEnabled,
] = await Promise.all([
systemSettingService.getBoolean('ai.assistant.enabled', true),
systemSettingService.getBoolean('ai.forecasting.enabled', true),
systemSettingService.getBoolean('ai.anomalies.enabled', true),
systemSettingService.getBoolean('ai.query.enabled', true),
]);
const features: Array<{
key: AiFeatureKey;
name: string;
enabled: boolean;
settingKey: string;
description: string;
requiresLlm: boolean;
}> = [
{
key: 'assistant',
name: 'Trợ lý tài chính AI Assistant',
enabled: assistantEnabled,
settingKey: 'ai.assistant.enabled',
description: 'Trò chuyện, phân loại giao dịch, trích xuất hóa đơn và phân tích tài chính',
requiresLlm: true,
},
{
key: 'forecasting',
name: 'Dự báo dòng tiền & cạn kiệt ngân sách',
enabled: forecastingEnabled,
settingKey: 'ai.forecasting.enabled',
description: 'Mô hình dự báo Holt-Winters và tính toán tốc độ đốt tiền xác định',
requiresLlm: false,
},
{
key: 'anomalies',
name: 'Phát hiện chi tiêu bất thường',
enabled: anomaliesEnabled,
settingKey: 'ai.anomalies.enabled',
description: 'Thuật toán Modified Z-score (MAD) và phát hiện bùng nổ giao dịch',
requiresLlm: false,
},
{
key: 'query',
name: 'Truy vấn ngôn ngữ tự nhiên DSL',
enabled: queryEnabled,
settingKey: 'ai.query.enabled',
description: 'Trình biên dịch truy vấn ngôn ngữ tự nhiên thành AST và tổng hợp dữ liệu',
requiresLlm: false,
},
];
return features.map((f) => {
let status: 'ENABLED' | 'DISABLED' | 'DEGRADED' = 'DISABLED';
if (f.enabled) {
if (f.requiresLlm && !isGeminiConfigured) {
status = 'DEGRADED';
} else {
status = 'ENABLED';
}
}
return {
key: f.key,
name: f.name,
enabled: f.enabled,
status,
settingKey: f.settingKey,
provider: envConfig.ai.provider,
model: envConfig.ai.geminiModel,
description: f.description,
};
});
}
async toggleFeature(
featureKey: string,
enabled: boolean,
actorId?: string,
meta?: { ipAddress?: string; userAgent?: string },
): Promise<AiFeatureStatusDto> {
const validFeatures: Record<string, { settingKey: string; name: string }> = {
assistant: { settingKey: 'ai.assistant.enabled', name: 'Trợ lý AI Assistant' },
forecasting: { settingKey: 'ai.forecasting.enabled', name: 'Dự báo dòng tiền' },
anomalies: { settingKey: 'ai.anomalies.enabled', name: 'Phát hiện chi tiêu bất thường' },
query: { settingKey: 'ai.query.enabled', name: 'Truy vấn DSL ngôn ngữ tự nhiên' },
};
const target = validFeatures[featureKey.toLowerCase()];
if (!target) {
throw new AppError(`Tính năng AI [${featureKey}] không tồn tại`, 404, ERROR_CODE.NOT_FOUND);
}
const previousEnabled = await systemSettingService.getBoolean(target.settingKey, true);
await systemSettingService.updateSetting(
target.settingKey,
{ value: enabled },
actorId,
meta,
);
await rbacRepository.createAuditLog({
actorId,
action: 'AI_CONFIG_UPDATE',
targetType: 'AI_FEATURE',
targetId: featureKey,
previousState: { enabled: previousEnabled },
newState: { enabled },
ipAddress: meta?.ipAddress,
userAgent: meta?.userAgent,
});
const statuses = await this.getFeatureStatuses();
return statuses.find((s) => s.key === featureKey.toLowerCase())!;
}
async getUsageSummary(period: 'today' | 'week' | 'month' = 'today') {
const now = new Date();
let fromDate: Date;
if (period === 'today') {
// Vietnam business day start (UTC+7)
const vietnamDate = new Date(now.getTime() + 7 * 60 * 60 * 1000);
vietnamDate.setUTCHours(0, 0, 0, 0);
fromDate = new Date(vietnamDate.getTime() - 7 * 60 * 60 * 1000);
} else if (period === 'week') {
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else {
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
return this.repository.getUsageSummary(fromDate, now, period);
}
async findLogs(query: AiRequestLogQueryDto) {
return this.repository.findLogs(query);
}
async getRateLimitConfig(): Promise<AiRateLimitConfigDto> {
const [maxRequests, windowMs] = await Promise.all([
systemSettingService.getNumber('ai.rate_limit.max_requests', 20),
systemSettingService.getNumber('ai.rate_limit.window_ms', 900000),
]);
return {
maxRequests,
windowMs,
};
}
async updateRateLimitConfig(
dto: UpdateAiRateLimitDto,
actorId?: string,
meta?: { ipAddress?: string; userAgent?: string },
): Promise<AiRateLimitConfigDto> {
const previousConfig = await this.getRateLimitConfig();
await Promise.all([
systemSettingService.updateSetting(
'ai.rate_limit.max_requests',
{ value: dto.maxRequests },
actorId,
meta,
),
systemSettingService.updateSetting(
'ai.rate_limit.window_ms',
{ value: dto.windowMs },
actorId,
meta,
),
]);
const newConfig = {
maxRequests: dto.maxRequests,
windowMs: dto.windowMs,
};
await rbacRepository.createAuditLog({
actorId,
action: 'AI_CONFIG_UPDATE',
targetType: 'AI_RATE_LIMIT',
targetId: 'global',
previousState: previousConfig,
newState: newConfig,
ipAddress: meta?.ipAddress,
userAgent: meta?.userAgent,
});
return newConfig;
}
}
export const adminAiService = new AdminAiService();
import { AiRequestStatus } from '@prisma/client';
import { z } from 'zod';
export const toggleAiFeatureSchema = z.object({
enabled: z.boolean(),
});
export const updateAiRateLimitSchema = z.object({
maxRequests: z.number().int().min(1).max(1000),
windowMs: z.number().int().min(1000).max(86400000),
});
export const aiUsageSummaryQuerySchema = z.object({
period: z.enum(['today', 'week', 'month']).default('today'),
});
export const aiRequestLogQuerySchema = z.object({
feature: z.string().trim().max(50).optional(),
status: z.nativeEnum(AiRequestStatus).optional(),
userId: z.string().uuid().optional(),
dateFrom: z.string().datetime({ offset: true }).optional().or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()),
dateTo: z.string().datetime({ offset: true }).optional().or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
This diff is collapsed.
This diff is collapsed.
import { ApiKeyStatus } from '@prisma/client';
export interface CreateApiKeyDto {
name: string;
permissions?: string[];
ipWhitelist?: string[];
expiresAt?: string | null;
}
export interface ApiKeyResponseDto {
id: string;
name: string;
keyPrefix: string;
permissions: string[];
ipWhitelist: string[];
status: ApiKeyStatus;
lastUsedAt: Date | null;
expiresAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateApiKeyResponseDto {
apiKey: ApiKeyResponseDto;
rawKey: string; // Trả về raw secret key chỉ 1 lần duy nhất khi tạo
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { z } from 'zod';
export const createJobSchema = z.object({
type: z.string().min(1, 'Job type is required').max(100),
input: z.record(z.any()).optional(),
});
export const jobParamSchema = z.object({
id: z.string().uuid('Invalid Job ID'),
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment