Resolve merge conflicts: prefer current branch (keep M2M and bot client changes)
Deploy / deploy (push) Failing after 18s

This commit is contained in:
Rayyan Syahbani Hermanto
2026-09-07 23:00:30 +07:00
179 changed files with 11440 additions and 1786 deletions
+89 -7
View File
@@ -1,10 +1,92 @@
APP_ENV=development #development / test / prod
PORT=3069
DATABASE_URL=postgresql://USERNAME:PASSWORD@HOST:5432/DATABASE?schema=public
# RayLab Core - contoh konfigurasi environment
# Salin file ini menjadi .env dan isi nilai-nilai sensitif sesuai environment Anda.
# Jangan commit .env yang berisi secrets ke VCS.
JWT_SECRET=GANTI_DENGAN_SECRET
JWT_REFRESH_SECRET=GANTI_DENGAN_REFRESH_SECRET
########################
# Database (Postgres)
########################
# URL koneksi Postgres untuk Prisma.
# Contoh: postgresql://user:password@localhost:5432/raylab
DATABASE_URL="postgresql://raylab:password@localhost:5432/raylab"
SWAGGER_ENABLED=true
# Catatan penting: migration menggunakan fungsi UUID di migration SQL.
# Pastikan database memiliki ekstensi yang sesuai:
# - Jika migration menggunakan gen_random_uuid(): pasang pgcrypto
# SQL: CREATE EXTENSION IF NOT EXISTS pgcrypto;
# - Jika ingin gunakan uuid_generate_v4(): pasang uuid-ossp
# SQL: CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";
LOG_LEVEL=debug
########################
# Internal JWT (RayLab)
########################
# Secret untuk menandatangani JWT internal (hingga JwtModule digunakan).
# Ganti dengan secret yang kuat di produksi.
RAYLAB_JWT_SECRET="replace-with-a-strong-secret"
########################
# Authentik / OIDC (Identity Provider)
########################
# Issuer base URL dari Authentik / OIDC provider (wajib untuk verifikasi token)
# Contoh: https://auth.example.com
AUTHENTIK_ISSUER="https://auth.example.com"
# Audience (aud) yang diharapkan pada token OIDC (opsional)
AUTHENTIK_AUDIENCE="raylab"
# JWKS URI jika ingin override. Jika kosong, akan dibentuk dari AUTHENTIK_ISSUER + "/.well-known/jwks.json"
AUTHENTIK_JWKS_URI=""
########################
# Redis (opsional, beberapa fitur)
########################
# Aktifkan/Nonaktifkan Redis (default true).
REDIS_ENABLED="true"
# URL Redis (contoh: redis://localhost:6379). Hanya dipakai bila REDIS_ENABLED=true
REDIS_URL="redis://localhost:6379"
########################
# Server / Aplikasi
########################
# Base URL aplikasi (dipakai pada integration tests / helper)
BASE_URL="http://localhost:3000"
# Port aplikasi
PORT="3000"
# Node environment
NODE_ENV="development"
# Log level (debug/info/warn/error)
LOG_LEVEL="debug"
########################
# Admin / Test accounts (opsional, dipakai tests/integration jika diperlukan)
########################
# Akun admin untuk keperluan pengujian/integrasi (hanya contoh)
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="changeme"
# Informasi akun uji (opsional)
TEST_USER_EMAIL="test-integration@example.com"
TEST_USER_USERNAME="test-integration"
TEST_USER_PASSWORD="StrongP@ssw0rd!"
# Expire time for test tokens (detik)
TEST_TOKEN_EXPIRES_IN="3600"
########################
# Optional / Integration hints
########################
# Jika Anda menggunakan penyedia lain atau menambahkan variabel tambahan,
# tambahkan di sini. Contoh:
# SSO_CALLBACK_URL="http://localhost:3000/auth/callback"
# MAILER_* variables, STORAGE_*, dsb.
########################
# Security reminders
########################
# - Jangan commit file .env dengan secrets.
# - Gunakan secret manager di production (Vault, AWS Secrets Manager, ..).
# - Pastikan DATABASE_URL menunjuk ke database yang benar dan aman.
# - Pastikan AUTHENTIK_ISSUER dan JWKS dapat diakses dari RayLab instance.
+24
View File
@@ -0,0 +1,24 @@
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: |
git config --global --add safe.directory /DATA/git/RayLab-Core
cd /DATA/git/RayLab-Core
git fetch origin
git reset --hard origin/main
cd /DATA/docker/raylab-core
docker compose up -d --build
+1
View File
@@ -2,3 +2,4 @@ node_modules
RayLab-Core.zip
RayLab-Core.rar
.env
dist/
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug NestJS",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"start:debug"
],
"console": "integratedTerminal",
"restart": true,
"skipFiles": [
"<node_internals>/**"
]
}
]
}
+25
View File
@@ -0,0 +1,25 @@
RayLab Core
├── Identity
│ ├── User
│ ├── Group
│ ├── Role
│ └── Permission
├── Authentication
├── Authorization
├── Audit
├── Scheduler
├── Media
├── Storage
├── Configuration
├── Workflow / Events
└── Registry
+208
View File
@@ -0,0 +1,208 @@
API Spec - Identity & Authorization API (Bahasa Indonesia)
Ringkasan
--------
Spesifikasi berikut disesuaikan dengan controller aktual pada aplikasi: modul Auth (OIDC + internal JWT), modul Identity (Users, Roles, Permissions) dan mekanisme otorisasi berbasis permission.
Base URL
--------
- https://api.example.com
Header Umum & Auth
------------------
- Content-Type: application/json
- Accept: application/json
- Otentikasi dapat diberikan dalam dua cara:
1) Cookie internal (default flow):
- raylab_jwt (internal JWT, httpOnly cookie)
- raylab_refresh (internal refresh token, httpOnly cookie)
2) Authorization header: Bearer <internal_jwt>
- Banyak endpoint melindungi akses dengan JWT dan permission checks. Respons sukses dari controller umumnya dibungkus sebagai:
{ "success": true, "data": ..., "meta": { ... } }
Auth (modul /auth)
-------------------
1) GET /auth/login
- Deskripsi: Mulai flow Authorization Code + PKCE. Redirect (302) ke Identity Provider.
- Query params:
- returnTo (optional) - URL tujuan setelah login
- Response: 302 Redirect
2) GET /auth/callback
- Deskripsi: Endpoint callback OIDC. Menukarkan code/state, membuat internal JWT & refresh token, dan menyetel cookie httpOnly.
- Response: 302 Redirect ke returnTo atau '/'
- Cookie yang disetel:
- raylab_jwt (internal JWT, maxAge sesuai expiresIn)
- raylab_refresh (refresh token)
3) POST /auth/logout
- Deskripsi: Invalidate internal refresh token (opsional) dan redirect ke logout identity provider.
- Body (optional): { "refreshToken": "..." }
- Response: 302 Redirect
4) POST /auth/refresh
- Deskripsi: Tukar refresh token menjadi internal JWT baru.
- Input: refresh token di cookie raylab_refresh atau di body { "refreshToken": "..." }
- Response 200: { "success": true, "data": { "accessToken": "...", "expiresIn": 3600, ... } }
5) GET /auth/me
- Deskripsi: Ambil data user saat ini dari internal JWT (cookie atau Authorization header).
- Response 200: { "success": true, "data": { /* user object */ } }
Catatan: tidak ada endpoint "/auth/register" atau POST /auth/login berbasis email/password pada controller saat ini — login terjadi via OIDC dan internal session cookies.
Users (modul /users)
--------------------
Semua endpoint Users dijalankan di bawah guards: JwtAuthGuard, CurrentUserGuard dan PermissionGuard. Respons mengikuti format { success, data, meta }.
1) GET /users
- Permission: PermissionType.USER_READ
- Deskripsi: List pengguna (paged)
- Query params umum: page, per_page, sort, q
- Response 200:
{ "success": true, "data": [ /* user list (toResponse) */ ], "meta": { "total": 123 } }
2) GET /users/me
- Deskripsi: Ambil profil user saat ini (token harus ada di cookie atau header)
- Response 200: { "success": true, "data": { /* current user */ }, "meta": {} }
3) GET /users/:id
- Permission: PermissionType.USER_READ
- Deskripsi: Ambil user berdasarkan id
- Response 200: { "success": true, "data": { /* user */ }, "meta": {} }
- 404 jika tidak ditemukan
4) PATCH /users/:id/enable
- Permission: PermissionType.USER_UPDATE
- Deskripsi: Enable user
- Response 200: { "success": true, "data": { /* updated user */ }, "meta": {} }
5) PATCH /users/:id/disable
- Permission: PermissionType.USER_UPDATE
- Deskripsi: Disable user
- Response 200
6) PATCH /users/:id
- Permission: PermissionType.USER_UPDATE
- Deskripsi: Update profil user (body berisi fields yang diperbolehkan)
- Body contoh: { "name": "Nama Baru", "email": "baru@example.com" }
- Response 200: { "success": true, "data": { /* updated user */ }, "meta": {} }
7) DELETE /users/:id
- Permission: PermissionType.USER_DELETE
- Deskripsi: Soft delete user
- Response 200: { "success": true, "data": null, "meta": {} }
8) POST /users/:id/restore
- Permission: PermissionType.USER_UPDATE
- Deskripsi: Restore user yang di-soft-delete
- Response 200: { "success": true, "data": { /* restored user */ }, "meta": {} }
Roles (modul /roles)
--------------------
Semua route dilindungi oleh JwtAuthGuard, CurrentUserGuard dan PermissionGuard.
1) GET /roles
- Permission: PermissionType.ROLE_READ
- Deskripsi: List roles
- Query params: page, per_page, q
- Response 200: { "success": true, "data": [ /* roles */ ], "meta": { "total": 10 } }
2) GET /roles/:id
- Permission: PermissionType.ROLE_READ
- Deskripsi: Ambil role
- Response 200
3) POST /roles
- Permission: PermissionType.ROLE_CREATE
- Deskripsi: Buat role baru
- Body contoh: { "name": "editor", "description": "..." }
- Response 200: { "success": true, "data": { /* created role */ }, "meta": {} }
4) PATCH /roles/:id
- Permission: PermissionType.ROLE_UPDATE
- Deskripsi: Update role
- Response 200
5) DELETE /roles/:id
- Permission: PermissionType.ROLE_DELETE
- Deskripsi: Hapus role
- Response 200: { "success": true, "data": null, "meta": {} }
6) POST /roles/:id/permissions
- Permission: PermissionType.ROLE_UPDATE
- Deskripsi: Assign permission ke role
- Body: { "permissionId": "..." }
- Response 200: { "success": true, "data": { /* updated role */ }, "meta": {} }
7) DELETE /roles/:id/permissions/:permissionId
- Permission: PermissionType.ROLE_UPDATE
- Deskripsi: Remove permission dari role
- Response 200
Permissions (modul /permissions)
-------------------------------
Semua route dilindungi oleh JwtAuthGuard, CurrentUserGuard dan PermissionGuard.
1) GET /permissions
- Permission: PermissionType.PERMISSION_READ
- Deskripsi: List permissions
- Response 200: { "success": true, "data": [ /* permissions */ ], "meta": { "total": 20 } }
2) GET /permissions/:id
- Permission: PermissionType.PERMISSION_READ
- Deskripsi: Ambil permission
- Response 200
3) POST /permissions
- Permission: PermissionType.PERMISSION_CREATE
- Deskripsi: Buat permission baru
- Body contoh: { "name": "user:create", "description": "..." }
- Response 200: { "success": true, "data": { /* created */ }, "meta": {} }
4) PATCH /permissions/:id
- Permission: PermissionType.PERMISSION_UPDATE
- Deskripsi: Update permission
- Response 200
5) DELETE /permissions/:id
- Permission: PermissionType.PERMISSION_DELETE
- Deskripsi: Delete permission
- Response 200: { "success": true, "data": null, "meta": {} }
Format Tanggal dan Numerik
--------------------------
- Tanggal/waktu: ISO 8601 (UTC), contoh: "2024-08-01T12:34:56Z"
- Desimal: titik sebagai pemisah desimal, misal 125000.50
Pagination
----------
- Gunakan page & per_page
- Meta object pada controller ini biasanya minimal: { total }
Response Error Umum
-------------------
- 400 Bad Request - payload tidak valid
{
"success": false,
"error": "invalid_request",
"message": "Deskripsi kesalahan",
"details": { "field": ["pesan validasi"] }
}
- 401 Unauthorized - token tidak ada/invalid/expired
- 403 Forbidden - tidak cukup izin
- 404 Not Found
- 429 Too Many Requests - rate limit
- 500 Internal Server Error
Keamanan dan Persyaratan
------------------------
- Semua permintaan harus lewat HTTPS (TLS 1.2+)
- Gunakan cookie httpOnly (raylab_jwt / raylab_refresh) untuk flow internal atau header Authorization: Bearer <token>
- Validasi input di server (length, tipe, range)
- Sanitasi data untuk mencegah injection
Catatan Akhir
------------
- Spesifikasi ini disesuaikan dengan controller yang ada: /auth, /users, /roles, /permissions dan mekanisme otorisasi berbasis PermissionType.
- Perhatikan bahwa detail field respons (mis. bentuk toResponse pada entitas) dapat berbeda antar resource. Untuk integrasi, gunakan endpoint /auth/me dan /users/me untuk memvalidasi data user yang tersedia.
BIN
View File
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
Note :
- pastikan berada di branch yang benar.
-
Requirement :
- Node.js 22 LTS
Step :
1. jalankan "NPM Install".
2. generate prisma client "npx prisma generate".
3. jalankan "cp env.example env" atau buat file .env baru.
4. sesuaikan isi dari .env
Run Server :
- npm run start:dev
Command Dev :
- npm test
- npm run lint
- npm run format
- npx prisma studio {untuk cek db}
Test API :
- menggunakan postman :
arahkan ke file /postman/RayLab.postman_collection.json
- menggunakan swagger :
- masuk ke http://localhost:port/apilist
- klik authorize dan masukkan jwt token
Controller Requirement :
- diatas deklarasi class controller pastikan ada ini :
- @ApiTags('object')
- @Controller('object')
- @UseGuards(JwtAuthGuard, PermissionGuard)
- diatas deklarasi method controller pastikan ada ini :
- tipe api : @Post(), @Get(':id'), @Patch(':id'), @Delete(':id')
- @Permissions(PermissionType.SOMETHING)
- @ApiOperation({ summary: 'penjelasan singkat api' })
Table :
- untuk table akan dibuat otomatis oleh schema.prisma, disana hanya perlu ditambahkan model atau diubah, jalankan commandnya, setelah itu otomatis tablenya dibuat
- command : npx prisma migrate dev --name {Nama Perubahan}
Use case:
- penentuan use case ditentukan siapa pemiliknya dahulu, dan harus konsisten.
+554
View File
@@ -0,0 +1,554 @@
# RayLab Bot Debt API
Base path: /api/bot/debt/
This document is the API contract for the Debt Management endpoints implemented in RayLab Core. The contents are generated from the actual code in the repository and reflect the real request/response shapes and behavior. Do not assume fields or endpoints that are not present in code.
Authentication
- All endpoints under /api/bot/debt/* require JWT authentication via Authorization: Bearer <token> (JwtAuthGuard).
- CurrentUserGuard maps the verified identity to internal User (via SyncIdentityHandler for external identities).
- All responses are wrapped as { success: true, data: ... } on success. Errors are returned via the global exception filter as { success: false, error: { code, message } }.
NOTE: See the "Authentication Gap" section below for how Telegram Bots should obtain JWTs.
---
CONTRACT FORMAT (per-endpoint)
Method Path
Authentication
Purpose
Request (example JSON)
Response success (example JSON)
Possible errors (HTTP code + example)
Notes
---
1) POST /api/bot/debt/users/sync
Authentication: Bearer JWT required
Purpose: Sync a Telegram identity to an internal RayLab user via SyncIdentityHandler and return internal user summary.
Request DTO: SyncUserDto
Example request JSON:
{
"telegramUserId": "123456789",
"displayName": "Rayyan"
}
Response success example:
{
"success": true,
"data": {
"id": "<internal-user-uuid>",
"username": "rayyan",
"name": "rayyan"
}
}
Possible errors:
- 400 Bad Request: { success: false, error: { code: 'BAD_REQUEST', message: 'telegramUserId required' } }
- 401 / 403 may be returned by JwtAuthGuard/CurrentUserGuard depending on authentication/identity validity.
Notes:
- This endpoint uses SyncIdentityHandler under the hood to find/create the internal User based on an external identity with sub `telegram:{telegramUserId}`.
- Calling this endpoint will create or update a User via the project identity patterns. Repeated calls for the same telegramUserId must return the same internal user (no duplicates).
- The endpoint is guarded by JwtAuthGuard + CurrentUserGuard; the caller must present a valid JWT. See "Authentication Gap".
---
2) POST /api/bot/debt/groups
Authentication: Bearer JWT required
Purpose: Create a new Debt Group. The authenticated user becomes the owner.
Request DTO: CreateGroupDto
Example request JSON:
{ "name": "Keluarga" }
Response success example:
{
"success": true,
"data": { "id": "<group-uuid>", "publicId": "RL-1A2B3C", "name": "Keluarga" }
}
Possible errors:
- 400 Bad Request: { success:false, error:{ code:'BAD_REQUEST', message:'name required' } }
- 401 Unauthorized / 403 Forbidden depending on JWT/identity
Notes:
- After create, owner is automatically added as a member.
- publicId is safe to share and used for joining.
---
3) GET /api/bot/debt/groups
Authentication: Bearer JWT required
Purpose: List Debt Groups the current user is a member of.
Request: none
Response success example:
{
"success": true,
"data": [
{ "id": "<group-uuid>", "publicId": "RL-1A2B3C", "name": "Keluarga", "ownerId": "<owner-uuid>" },
...
]
}
Possible errors:
- 401 Unauthorized
Notes:
- Membership is determined by DebtGroupMember entries.
---
4) GET /api/bot/debt/groups/:id
Authentication: Bearer JWT required
Purpose: Get group detail (people list and basic info). User must be a member.
Request: none
Response success example:
{
"success": true,
"data": {
"id": "<group-uuid>",
"publicId": "RL-1A2B3C",
"name": "Keluarga",
"people": [ { "id": "<person-uuid>", "name": "Rayyan" }, ... ]
}
}
Possible errors:
- 404 Not Found: { success:false, error:{ code:'NOT_FOUND', message:'Group not found' } }
- 403 Forbidden: { success:false, error:{ code:'FORBIDDEN', message:'Not a member' } }
Notes:
- Only members can view group detail.
---
5) POST /api/bot/debt/groups/:publicId/join
Authentication: Bearer JWT required
Purpose: Join a group by its publicId (anyone who has publicId can join as member without owner approval).
Request: none
Example request: POST /api/bot/debt/groups/RL-1A2B3C/join
Response success examples:
- On new membership created:
{
"success": true,
"data": { "id": "<membership-uuid>" }
}
- If already a member:
{
"success": true,
"data": { "ok": true }
}
Possible errors:
- 404 Not Found: { success:false, error:{ code:'NOT_FOUND', message:'Group not found' } }
Notes:
- Unique constraint prevents duplicate membership.
---
6) POST /api/bot/debt/groups/:id/leave
Authentication: Bearer JWT required
Purpose: Current user leaves the group.
Request: none
Response success example:
{ "success": true, "data": { "ok": true } }
Possible errors:
- 404 Not Found: Group not found
- 401/403: unauthorized
Notes:
- Implementation deletes DebtGroupMember entries for that (groupId, userId).
---
7) POST /api/bot/debt/groups/:id/people
Authentication: Bearer JWT required (Owner only)
Purpose: Owner adds a Person to the Group.
Request DTO: CreatePersonDto
Example request JSON:
{ "name": "Rayyan" }
Response success example:
{
"success": true,
"data": { "id": "<person-uuid>", "name": "Rayyan" }
}
Possible errors:
- 404 Not Found: Group not found
- 403 Forbidden: { success:false, error:{ code:'FORBIDDEN', message:'Only owner can add person' } }
- 400 Bad Request: { success:false, error:{ code:'BAD_REQUEST', message:'Person with same name already exists in group' } }
Notes:
- Person names are unique per group (DB @@unique([groupId,name])).
- Person != Telegram User (see Person section).
---
8) GET /api/bot/debt/groups/:id/people
Authentication: Bearer JWT required
Purpose: List People inside a group (member-only).
Request: none
Response success example:
{
"success": true,
"data": [ { "id": "<person-uuid>", "name": "Rayyan" }, ... ]
}
Possible errors:
- 403 Forbidden: Not a member
Notes:
- Only active (isDeleted=false) persons are returned.
---
9) DELETE /api/bot/debt/groups/:id/people/:personId
Authentication: Bearer JWT required (Owner only)
Purpose: Soft-delete a Person from the Group.
Request: none
Response success example:
{ "success": true, "data": null }
Possible errors:
- 404 Not Found: Group not found
- 403 Forbidden: Only owner can remove person
Notes:
- Implementation sets isDeleted=true (soft delete). Historical transactions remain and are not removed.
- After soft-delete this Person cannot be used for new transactions.
---
10) POST /api/bot/debt/groups/:id/transactions/debt
Authentication: Bearer JWT required (Member)
Purpose: Create a DEBT transaction (ledger entry).
Request DTO: CreateTransactionDto (example):
{
"from": "Rayyan",
"to": "Krisda",
"price": "17.000",
"description": "Makan siang",
"date": "2026/09/01", // optional, format yyyy/mm/dd
"requestId": "telegram-update-12345"
}
Response success example (created DEBT transaction model as returned by Prisma, wrapped):
{
"success": true,
"data": {
"id": "<tx-uuid>",
"groupId": "<group-uuid>",
"fromPersonId": "<person-uuid>",
"toPersonId": "<person-uuid>",
"amount": 17000,
"type": "DEBT",
"description": "Makan siang",
"transactionDate": "2026-09-01T00:00:00.000Z",
"createdAt": "2026-09-01T10:00:00.000Z",
"createdByUserId": "<user-uuid>",
"requestId": "telegram-update-12345"
}
}
Possible errors:
- 400 Bad Request:
- "from and to required"
- "from and to cannot be same"
- "Person not found in group"
- "requestId is required for idempotency"
- "Nominal tidak valid. Gunakan format seperti 17000 atau 17.000."
- "Nominal harus lebih besar dari 0"
- "Tanggal tidak valid. Gunakan format yyyy/mm/dd"
- 403 Forbidden: Not a member
- 404 Not Found: Group not found
Notes:
- requestId is required by service to ensure idempotency; DB also has unique(groupId, requestId) as last-resort protection.
- price is a string in request; service parses dot separators and stores BigInt.
- transactionDate stored as date-only (Asia/Jakarta by default if omitted).
---
11) POST /api/bot/debt/groups/:id/transactions/payment
Authentication: Bearer JWT required (Member)
Purpose: Create a PAYMENT ledger entry. If payment exceeds existing debt, a reverse DEBT transaction is created automatically for the remainder.
Request DTO: CreateTransactionDto (same shape as DEBT)
Example request JSON:
{
"from": "Rayyan",
"to": "Krisda",
"price": "60.000",
"description": "Bayar lebih",
"requestId": "telegram-update-67890"
}
Response success examples:
- If payment does not produce remainder (no overpayment):
{
"success": true,
"data": { /* PAYMENT transaction object like DEBT example */ }
}
- If payment > existing debt and remainder produced:
{
"success": true,
"data": {
"payment": { /* payment transaction object */ },
"remainder": { /* created DEBT transaction reversed, i.e. to->from */ }
}
}
Possible errors: same as DEBT endpoint (validation, membership, group not found)
Notes:
- Behavior: create PAYMENT ledger entry; compute existing net between the two persons (aggregating ledger), then if payment > existing positive debt, create a new DEBT transaction in opposite direction for remainder with description: "Sisa pembayaran hutang dari {creatorName}".
- The ledger is immutable: PAYMENT and resultant DEBT(reverse) are separate records.
- Avoid double-counting: the net is computed from ledger BEFORE the new PAYMENT; after PAYMENT + possible reverse DEBT, aggregate net will reflect correct result.
---
12) GET /api/bot/debt/groups/:id/transactions
Authentication: Bearer JWT required (Member)
Purpose: Retrieve raw ledger history for group.
Query params: page (optional), limit (optional) — current implementation ignores advanced paging; controller accepts page & limit but service returns all transactions for group.
Response success example:
{
"success": true,
"data": [
{ "id": "<tx-uuid>", "from": "Rayyan", "to": "Krisda", "amount": "17000", "type": "DEBT", "description": "Makan", "transactionDate": "2026-09-01", "createdAt": "2026-09-01T10:00:00Z" },
...
]
}
Possible errors:
- 403 Forbidden: Not a member
Notes:
- Results are ordered by transactionDate ASC, then createdAt ASC to ensure deterministic ordering.
- This endpoint is RAW ledger history (detail), not summary.
---
13) GET /api/bot/debt/groups/:id/summary
Authentication: Bearer JWT required (Member)
Purpose: Compute CURRENT NET DEBT for the group using global netting (read-only). The algorithm aggregates ledger and performs deterministic greedy settlement. It does not change the ledger.
Response success example:
{
"success": true,
"data": [
{ "from": "Rayyan", "to": "Krisda", "amount": "22000" },
...
]
}
If no debts remain:
{
"success": true,
"data": []
}
Possible errors:
- 403 Forbidden: Not a member
Notes:
- Summary output is a settlement list: who should pay whom and how much, computed by the server's netting algorithm.
- Different settlement variants may be valid; the server returns one deterministic settlement (greedy algorithm).
---
14) GET /api/bot/debt/groups/:id/detail
Authentication: Bearer JWT required (Member)
Purpose: Same as GET /transactions — return RAW LEDGER HISTORY ordered by transactionDate ASC, createdAt ASC.
Response: same as /transactions endpoint.
Notes: For Telegram commands, /TampilkanDetailHutang should call this endpoint.
---
ERROR MAPPING
RayLab Core uses a global exception filter which returns errors as:
{
"success": false,
"error": { "code": "<exception.code || 'INTERNAL_ERROR'>", "message": "<exception.message>" }
}
Common error cases (HTTP status and example response):
- 400 Bad Request
- Example: invalid amount
- {"success": false, "error": {"code":"BAD_REQUEST","message":"Nominal tidak valid. Gunakan format seperti 17000 atau 17.000."}}
- 403 Forbidden
- Example: not a member or not owner
- {"success": false, "error": {"code":"FORBIDDEN","message":"Not a member"}}
- 404 Not Found
- Example: Group not found
- {"success": false, "error": {"code":"NOT_FOUND","message":"Group not found"}}
- 500 Internal Error: unexpected exceptions
Exact messages returned are the exception.message strings thrown by the service (see service code for exact strings).
---
TELEGRAM BOT INTEGRATION (GUIDE)
Runtime flow (recommended):
Telegram User -> Telegram Bot -> RayLab Core (/api/bot/debt) with JWT auth
Key points:
- Each API call requires JWT authentication (JwtAuthGuard). The CurrentUserGuard maps the JWT-validated identity to internal User.
- The codebase does not provide an automatic JWT issuance endpoint for Telegram Bot on behalf of a Telegram user. Auth flow is based on existing RayLab authentication (Authentik / OIDC / internal JWT generation).
Recommended integration options for Telegram Bot developers (choose one depending on security & user experience):
1) Per-user interactive login (most secure):
- The Telegram Bot prompts the user to authenticate via the RayLab web login (OIDC) and obtain a RayLab internal access token. The bot instructs user to paste a short-lived token or do a one-time link flow.
- Bot uses that token to call /api/bot/debt with Authorization: Bearer <token>.
- Pros: actions executed under user identity; createdByUserId matches the real user.
- Cons: requires user interaction and web flow.
2) Service-account with explicit recorded actor (less ideal):
- Bot uses a service account JWT to call API. createdByUserId will be service-account id.
- To preserve traceability, Bot includes requestId and includes in request.body metadata about the Telegram user (but server currently does not accept/verify a claimed telegramUserId). This is less secure because createdByUserId won't match Telegram user.
3) Implement server-side mapping endpoint (recommended by platform team):
- Add a secure server endpoint that accepts a Telegram update signed or validated by bot token and exchanges it for a user-specific JWT using SyncIdentityHandler and internal authentication service.
- This requires changes outside the current feature and must follow security review.
Authentication Gap (explicit):
- The repository currently expects callers to present JWTs. There is no built-in endpoint to exchange a Telegram user_id for a JWT without user authentication.
- As a result, Telegram Bot developers must either obtain per-user JWTs via the normal login flow, or use a service account JWT (with tradeoffs), or request project owners to implement a secure bot-to-server auth bridge.
When to call /users/sync?
- /users/sync maps a Telegram external identity into internal User via SyncIdentityHandler. It should be called when you need the internal user to exist or to refresh data. If you follow per-user login (option 1), explicit /users/sync may not be needed. If you use a server-side bridging approach, call /users/sync as part of the bridging sequence to ensure the internal user exists.
- Do not call /users/sync unauthenticated. The endpoint is guarded and requires a JWT.
---
TELEGRAM COMMAND → API MAPPING (examples)
1) Create Group
Telegram: /BuatGroup Keluarga
Bot: POST /api/bot/debt/groups
Request body: { "name": "Keluarga" }
Response: returns group publicId (e.g. RL-1A2B3C) -> Bot shares this with group members to join.
2) Join Group
Telegram: /MasukGroup RL-1A2B3C
Bot: POST /api/bot/debt/groups/RL-1A2B3C/join
3) Add Person
Telegram: /TambahOrang Rayyan
Bot: POST /api/bot/debt/groups/:id/people
Body: { "name": "Rayyan" }
Note: only owner can add person (owner determined by JWT caller).
4) Add Debt
Telegram: /TambahHutang Rayyan - Krisda - 17.000 - Makan siang
Bot: POST /api/bot/debt/groups/:id/transactions/debt
Body:
{
"from": "Rayyan",
"to": "Krisda",
"price": "17.000",
"description": "Makan siang",
"requestId": "telegram-update-12345"
}
If date provided (yyyy/mm/dd) include "date" field.
5) Payment
Telegram: /BayarHutang Rayyan Krisda 10.000
Bot: POST /api/bot/debt/groups/:id/transactions/payment
Body:
{
"from": "Rayyan",
"to": "Krisda",
"price": "10.000",
"requestId": "telegram-update-67890"
}
6) Detail
Telegram: /TampilkanDetailHutang
Bot: GET /api/bot/debt/groups/:id/detail
Bot shows raw ledger lines as returned.
7) Summary
Telegram: /TampilkanKesimpulanHutang
Bot: GET /api/bot/debt/groups/:id/summary
Bot shows summarized settlement lines.
---
EXAMPLE END-TO-END FLOW (minimal)
1) Owner Alice authenticates via RayLab OIDC, obtains JWT (out of scope for bot automation). Bot now holds Alice's JWT for use.
2) Alice: /BuatGroup Keluarga
Bot: POST /api/bot/debt/groups with Alice's JWT -> receives { publicId: "RL-1A2B3C" }
Bot replies: "Group created. Public ID RL-1A2B3C"
3) Bob obtains publicId and calls /MasukGroup RL-1A2B3C (after Bob has a JWT)
Bot: POST /api/bot/debt/groups/RL-1A2B3C/join with Bob's JWT
4) Alice (owner) adds people:
/TambahOrang Rayyan -> POST /api/bot/debt/groups/:id/people { name: 'Rayyan' }
/TambahOrang Krisda -> POST /... { name: 'Krisda' }
5) Bob (member) adds debt:
/TambahHutang Rayyan - Krisda - 17000 - Makan siang -> POST transactions/debt with his JWT and requestId based on message id
6) Someone asks /TampilkanKesimpulanHutang -> GET summary -> Bot displays settlement
7) Someone asks /TampilkanDetailHutang -> GET detail -> Bot displays ledger lines
---
IDEMPOTENCY
- requestId in CreateTransactionDto is required by service. The DB has a unique constraint @@unique([groupId, requestId]).
- Behavior:
- If a request with the same (groupId, requestId) already exists, service returns the existing transaction and does not create a duplicate.
- In concurrent scenarios, DB unique constraint is the final line of defense; clients should retry on unique-violation errors by re-fetching the existing transaction or returning success.
Recommendation for bot developers:
- Use a stable requestId per Telegram update (e.g., `telegram:update:{update_id}` or `telegram:message:{chat_id}:{message_id}`) to allow safe retries.
---
SECURITY REVIEW (quick)
1) JWT validation
- JwtAuthGuard verifies external JWKS (Authentik) or internal secret. Use Authorization: Bearer <token>.
2) Identity spoofing
- /users/sync is guarded by JwtAuthGuard; unauthorized clients cannot arbitrarily create arbitrary internal users.
- However, there is no remote-check confirming that the JWT presented *belongs to* the telegramUserId included in /users/sync body. The server uses SyncIdentityHandler to map identity from verified claims when CurrentUserGuard is used; but the sync endpoint currently expects authenticated callers. If a bot uses a service account JWT, it can call /users/sync for any telegramUserId — the system will create internal users based on given sub `telegram:{id}`. This is a gap: a bot acting on behalf of a Telegram user must obtain a JWT THAT represents that user, or an auth bridge must be implemented.
3) Group membership and owner
- Owner checks compare req.currentUser.id to group.ownerId (server-side). This is enforced server-side.
4) Person name manipulation
- Person lookup in createTransaction is by name within groupId and isDeleted=false. Be careful: names are the identity used in the API; bots must ensure proper escaping and normalization when parsing from Telegram.
5) requestId manipulation
- requestId is honored by server and unique constraint used; ensure bots generate collision-resistant ids.
6) DTO whitelist
- Global ValidationPipe is enabled (whitelist:true, transform:true, forbidNonWhitelisted:true) so unexpected fields are rejected.
7) SQL/Prisma injection
- Prisma queries use parameterized API. However, person lookup is by name; ensure the bot sends sanitized strings. Prisma prevents SQL injection when using its query methods.
8) Deleted Person
- Soft-deleted Person cannot be used for new transactions; queries use isDeleted=false. Historical transactions remain readable.
Security finding / gap (explicit):
- There is no built-in server-side flow that allows the Telegram Bot to obtain a JWT on behalf of a Telegram user without user interaction. This means a bot cannot impersonate the real user securely unless that user performs the standard authentication flow (OIDC). If you plan to let Bot perform actions as real users without user login, you must implement a secure delegation mechanism (requires architectural review).
---
SWAGGER
- RayLab Core includes Swagger support (if SWAGGER_ENABLED=true in config, docs available). The debt controller classes use DTOs; if Swagger is enabled the endpoints will appear. The project already includes @nestjs/swagger — enabling will include these endpoints.
---
TESTS / BUILD / MIGRATION STATUS
- Unit tests (run locally during development): all unit tests in this repo passed in this environment.
- Test suites run: 10
- Tests: 29 passed
- Build: tsc compile succeeded in this environment.
- Prisma generate: succeeded (Prisma Client generated locally).
- Prisma migrate dev --name add_debt_models: NOT APPLIED in this environment due to unreachable DB (P1001: Can't reach database server at `postgres-raylab:5432`).
- Action required: run migration in environment with reachable DB; do not run migrate reset in production.
---
FILES CHANGED (by implementation)
- prisma/schema.prisma (added debt models + backrefs)
- src/modules/bot-debt/debt.module.ts
- src/modules/bot-debt/presentation/debt.controller.ts
- src/modules/bot-debt/presentation/dto/sync-user.dto.ts
- src/modules/bot-debt/presentation/dto/create-group.dto.ts
- src/modules/bot-debt/presentation/dto/create-person.dto.ts
- src/modules/bot-debt/presentation/dto/create-transaction.dto.ts
- src/modules/bot-debt/application/debt.service.ts
- src/app.module.ts
- tests/unit/debt.service.spec.ts
---
REMAINING ISSUES / ACTIONS FOR OPERATIONAL TEAM
1) Apply Prisma migration in environment where database is reachable:
- npx prisma generate
- npx prisma migrate dev --name add_debt_models
- Commit the generated migration directory if your process requires it.
2) Decide on authentication flow for Telegram Bot:
- Option A (recommended): Bot guides users to obtain per-user JWT via OIDC login; Bot uses users' JWT to call API.
- Option B: Implement a secure server-side exchange for Telegram identities to internal JWTs (requires new endpoint & security review).
3) Optionally make requestId DB column NOT NULL to enforce client discipline. (Current code requires requestId in service; DB column is nullable — left intentionally to maintain compatibility.)
4) Add integration tests (optional): minimal end-to-end test that runs migrate + spins up app and exercises endpoints.
---
SUMMARY STATEMENT
This document is the canonical API contract for the Debt Management endpoints implemented at /api/bot/debt/. It contains concrete request & response JSON, error mapping, identity & integration guidance for Telegram Bot implementers, and notes about security and idempotency.
Next steps for Bot developers:
- Decide how your bot will obtain JWTs (per-user or service-account). If per-user, implement a login flow; if service-account, accept createdByUserId will be the service account.
- Implement stable requestId generation per Telegram update to ensure idempotency.
- Use DTO field names exactly as documented above.
If you want, I can now:
- Add explicit Swagger decorators/examples to the controllers (so generated API docs include exact DTOs and examples), and
- Add a small integration test (if you can provide reachable test DB credentials or run the migration locally and provide migration status).
+8 -1
View File
@@ -1,6 +1,13 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
testMatch: ['**/tests/**/*.spec.ts', '**/?(*.)+(spec|test).[tj]s?(x)'],
testPathIgnorePatterns: ['/tests/api/', '/tests/integration/'],
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
};
BIN
View File
Binary file not shown.
+4601 -1573
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -11,6 +11,7 @@
"scripts": {
"start": "node dist/main.js",
"start:dev": "ts-node-dev --respawn --pretty --transpile-only src/main.ts",
"start:debug": "nest start --debug --watch",
"build": "tsc -p tsconfig.json",
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
@@ -24,6 +25,7 @@
"prisma:seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.379.0",
"@nestjs/common": "^10.4.20",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.20",
@@ -33,11 +35,18 @@
"@nestjs/platform-express": "^10.4.20",
"@nestjs/swagger": "^7.4.2",
"@prisma/client": "^5.22.0",
"axios": "^1.4.0",
"bcrypt": "^5.1.1",
"bullmq": "^1.73.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"ioredis": "^5.3.2",
"jose": "^6.2.6",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.4",
"openid-client": "^6.5.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pino": "^9.7.0",
@@ -47,16 +56,19 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@nestjs/testing": "^10.4.22",
"@playwright/test": "^1.62.1",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.10",
"@types/node": "^22.20.1",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3",
"@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"eslint": "^9.21.0",
"@eslint/js": "^9.21.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.5.3",
@@ -65,6 +77,9 @@
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.2"
"typescript": "^5.9.3"
},
"prisma": {
"seed": "node --loader ts-node/esm prisma/seed.ts"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// only run Playwright tests in the integration folder to avoid running Jest unit tests
testDir: './tests/integration',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["list"], ["./tests/reporter/custom-reporter.js"]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
});
+58
View File
@@ -0,0 +1,58 @@
{
"info": {
"_postman_id": "e1e605cc-a2c6-4410-a7aa-4692b594d42d",
"name": "RayLab",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "45313893"
},
"item": [
{
"name": "User",
"item": [
{
"name": "Create User",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "Authorization",
"value": "Bearer ",
"description": "Bearer <JWT_Token>",
"type": "text",
"disabled": true
}
],
"body": {
"mode": "raw",
"raw": "{\r\n \"username\": \"rayyan\",\r\n \"email\": \"rayyan@example.com\",\r\n \"password\": \"Password123!\",\r\n \"fullName\": \"Rayyan\"\r\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "http://localhost:3000/api/v1/users",
"protocol": "http",
"host": [
"localhost"
],
"port": "3000",
"path": [
"api",
"v1",
"users"
]
}
},
"response": []
}
]
}
]
}
@@ -0,0 +1,277 @@
-- CreateEnum
CREATE TYPE "UserRoleSource" AS ENUM ('AUTHENTIK', 'SYSTEM');
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"authentikId" TEXT,
"authentikUserId" TEXT,
"authentikSubject" TEXT,
"username" VARCHAR(255),
"email" VARCHAR(320),
"name" VARCHAR(255),
"picture" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"deletedAt" TIMESTAMP(3),
"lastSeenAt" TIMESTAMP(3),
"lastSyncedAt" TIMESTAMP(3),
"syncStatus" TEXT,
"storageQuota" BIGINT DEFAULT 10737418240,
"storageUsed" BIGINT DEFAULT 0,
"metadata" JSONB,
"lastGroupHash" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Role" (
"id" UUID NOT NULL,
"code" VARCHAR(100) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"displayName" VARCHAR(255),
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Permission" (
"id" UUID NOT NULL,
"code" VARCHAR(150) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"displayName" VARCHAR(255),
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RolePermission" (
"id" UUID NOT NULL,
"roleId" UUID NOT NULL,
"permissionId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPermission" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"permissionId" UUID NOT NULL,
"assignedBy" VARCHAR(100),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserPermission_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"roleId" UUID NOT NULL,
"source" "UserRoleSource" NOT NULL DEFAULT 'AUTHENTIK',
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthGroupRoleMapping" (
"id" UUID NOT NULL,
"authGroup" VARCHAR(255) NOT NULL,
"roleId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthGroupRoleMapping_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" UUID NOT NULL,
"userId" UUID,
"action" VARCHAR(200) NOT NULL,
"resource" VARCHAR(200),
"resourceId" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ScheduledJob" (
"id" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL,
"payload" JSONB,
"cron" TEXT,
"runAt" TIMESTAMP(3),
"status" VARCHAR(50) NOT NULL DEFAULT 'pending',
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastRunAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ScheduledJob_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MediaObject" (
"id" UUID NOT NULL,
"ownerUserId" UUID,
"storageKey" VARCHAR(1024) NOT NULL,
"filename" VARCHAR(1024),
"mimeType" VARCHAR(255),
"size" BIGINT,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaObject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Application" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"code" VARCHAR(100) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT,
"icon" TEXT,
"url" TEXT,
"applicationsClaim" VARCHAR(255) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT "Application_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikId_key" ON "User"("authentikId");
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikUserId_key" ON "User"("authentikUserId");
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikSubject_key" ON "User"("authentikSubject");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_authentikId_idx" ON "User"("authentikId");
-- CreateIndex
CREATE INDEX "User_username_idx" ON "User"("username");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Role_code_key" ON "Role"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_code_key" ON "Permission"("code");
-- CreateIndex
CREATE INDEX "RolePermission_roleId_idx" ON "RolePermission"("roleId");
-- CreateIndex
CREATE INDEX "RolePermission_permissionId_idx" ON "RolePermission"("permissionId");
-- CreateIndex
CREATE UNIQUE INDEX "RolePermission_roleId_permissionId_key" ON "RolePermission"("roleId", "permissionId");
-- CreateIndex
CREATE INDEX "UserPermission_userId_idx" ON "UserPermission"("userId");
-- CreateIndex
CREATE INDEX "UserPermission_permissionId_idx" ON "UserPermission"("permissionId");
-- CreateIndex
CREATE UNIQUE INDEX "UserPermission_userId_permissionId_key" ON "UserPermission"("userId", "permissionId");
-- CreateIndex
CREATE INDEX "UserRole_userId_idx" ON "UserRole"("userId");
-- CreateIndex
CREATE INDEX "UserRole_roleId_idx" ON "UserRole"("roleId");
-- CreateIndex
CREATE UNIQUE INDEX "UserRole_userId_roleId_key" ON "UserRole"("userId", "roleId");
-- CreateIndex
CREATE INDEX "AuthGroupRoleMapping_authGroup_idx" ON "AuthGroupRoleMapping"("authGroup");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "ScheduledJob_name_idx" ON "ScheduledJob"("name");
-- CreateIndex
CREATE INDEX "ScheduledJob_status_idx" ON "ScheduledJob"("status");
-- CreateIndex
CREATE INDEX "MediaObject_ownerUserId_idx" ON "MediaObject"("ownerUserId");
-- CreateIndex
CREATE INDEX "MediaObject_storageKey_idx" ON "MediaObject"("storageKey");
-- CreateIndex
CREATE UNIQUE INDEX "Application_code_key" ON "Application"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Application_applicationsClaim_key" ON "Application"("applicationsClaim");
-- CreateIndex
CREATE INDEX "Application_code_idx" ON "Application"("code");
-- CreateIndex
CREATE INDEX "Application_applicationsClaim_idx" ON "Application"("applicationsClaim");
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPermission" ADD CONSTRAINT "UserPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPermission" ADD CONSTRAINT "UserPermission_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthGroupRoleMapping" ADD CONSTRAINT "AuthGroupRoleMapping_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MediaObject" ADD CONSTRAINT "MediaObject_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,117 @@
-- CreateEnum
CREATE TYPE "DebtTransactionType" AS ENUM ('DEBT', 'PAYMENT');
-- AlterTable
ALTER TABLE "Application" ALTER COLUMN "createdAt" SET DEFAULT now(),
ALTER COLUMN "updatedAt" SET DEFAULT now();
-- CreateTable
CREATE TABLE "DebtGroup" (
"id" UUID NOT NULL,
"publicId" VARCHAR(20) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"ownerId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DebtGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DebtGroupMember" (
"id" UUID NOT NULL,
"groupId" UUID NOT NULL,
"userId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DebtGroupMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DebtPerson" (
"id" UUID NOT NULL,
"groupId" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DebtPerson_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DebtTransaction" (
"id" UUID NOT NULL,
"groupId" UUID NOT NULL,
"fromPersonId" UUID NOT NULL,
"toPersonId" UUID NOT NULL,
"amount" BIGINT NOT NULL,
"type" "DebtTransactionType" NOT NULL,
"description" TEXT,
"transactionDate" DATE NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdByUserId" UUID NOT NULL,
"requestId" VARCHAR(255),
CONSTRAINT "DebtTransaction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DebtGroup_publicId_key" ON "DebtGroup"("publicId");
-- CreateIndex
CREATE INDEX "DebtGroup_publicId_idx" ON "DebtGroup"("publicId");
-- CreateIndex
CREATE INDEX "DebtGroup_ownerId_idx" ON "DebtGroup"("ownerId");
-- CreateIndex
CREATE INDEX "DebtGroupMember_groupId_idx" ON "DebtGroupMember"("groupId");
-- CreateIndex
CREATE INDEX "DebtGroupMember_userId_idx" ON "DebtGroupMember"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "DebtGroupMember_groupId_userId_key" ON "DebtGroupMember"("groupId", "userId");
-- CreateIndex
CREATE INDEX "DebtPerson_groupId_idx" ON "DebtPerson"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "DebtPerson_groupId_name_key" ON "DebtPerson"("groupId", "name");
-- CreateIndex
CREATE INDEX "DebtTransaction_groupId_idx" ON "DebtTransaction"("groupId");
-- CreateIndex
CREATE INDEX "DebtTransaction_fromPersonId_idx" ON "DebtTransaction"("fromPersonId");
-- CreateIndex
CREATE INDEX "DebtTransaction_toPersonId_idx" ON "DebtTransaction"("toPersonId");
-- CreateIndex
CREATE UNIQUE INDEX "DebtTransaction_groupId_requestId_key" ON "DebtTransaction"("groupId", "requestId");
-- AddForeignKey
ALTER TABLE "DebtGroup" ADD CONSTRAINT "DebtGroup_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtGroupMember" ADD CONSTRAINT "DebtGroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DebtGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtGroupMember" ADD CONSTRAINT "DebtGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtPerson" ADD CONSTRAINT "DebtPerson_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DebtGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtTransaction" ADD CONSTRAINT "DebtTransaction_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DebtGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtTransaction" ADD CONSTRAINT "DebtTransaction_fromPersonId_fkey" FOREIGN KEY ("fromPersonId") REFERENCES "DebtPerson"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtTransaction" ADD CONSTRAINT "DebtTransaction_toPersonId_fkey" FOREIGN KEY ("toPersonId") REFERENCES "DebtPerson"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DebtTransaction" ADD CONSTRAINT "DebtTransaction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+38 -3
View File
@@ -2,14 +2,49 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { IdentityModule } from './modules/identity/identity.module';
import { AuthModule } from './modules/auth/auth.module';
import { HealthModule } from './modules/health/health.module';
import { AuthorizationModule } from './modules/authorization/authorization.module';
import { AuditModule } from './modules/audit/audit.module';
import { ApplicationModule } from './modules/application/application.module';
import { DebtModule } from './modules/bot-debt/debt.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
IdentityModule,
isGlobal: true,
// Load .env files depending on NODE_ENV. Default to development .env
envFilePath: process.env.NODE_ENV === 'production' ? '.env.production' : '.env',
// Basic validation: ensure expected frontend URLs and OIDC settings are present
validate: (env: Record<string, any>) => {
const errors: string[] = [];
const nodeEnv = env.NODE_ENV || process.env.NODE_ENV || 'development';
if (!env.FRONTEND_URL) errors.push('FRONTEND_URL is not set');
if (nodeEnv === 'production' && !env.PRODUCTION_FRONTEND_URL) errors.push('PRODUCTION_FRONTEND_URL is not set');
// OIDC required settings
if (!env.AUTHENTIK_ISSUER) errors.push('AUTHENTIK_ISSUER is not set');
if (!env.AUTHENTIK_CLIENT_ID) errors.push('AUTHENTIK_CLIENT_ID is not set');
if (!env.AUTHENTIK_CLIENT_SECRET) errors.push('AUTHENTIK_CLIENT_SECRET is not set');
if (!env.AUTHENTIK_REDIRECT_URI) errors.push('AUTHENTIK_REDIRECT_URI is not set');
if (errors.length > 0) throw new Error('Environment validation error: ' + errors.join('; '));
return env;
},
}),
IdentityModule,
AuthModule,
HealthModule,
// Authorization module provides permission checks and cache
AuthorizationModule,
AuditModule,
ApplicationModule,
DebtModule,
],
})
export class AppModule {}
@@ -0,0 +1,16 @@
export const PermissionType = {
USER_CREATE: 'USER_CREATE',
USER_READ: 'USER_READ',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
ROLE_CREATE: 'ROLE_CREATE',
ROLE_READ: 'ROLE_READ',
ROLE_UPDATE: 'ROLE_UPDATE',
ROLE_DELETE: 'ROLE_DELETE',
PERMISSION_CREATE: 'PERMISSION_CREATE',
PERMISSION_READ: 'PERMISSION_READ',
PERMISSION_UPDATE: 'PERMISSION_UPDATE',
PERMISSION_DELETE: 'PERMISSION_DELETE',
} as const;
+78 -14
View File
@@ -3,38 +3,102 @@ import {
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { IdentityData } from '../interfaces/identity-data';
import * as jwt from 'jsonwebtoken';
/**
* JwtAuthGuard verifies JWTs issued by the external Identity Provider (Authentik)
* using JWKS (RS256). It also accepts internal RayLab JWTs signed with a local secret.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
) {}
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
private readonly logger = new Logger('JwtAuthGuard');
constructor() {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest<Request & { identity?: IdentityData }>();
let token: string | undefined;
const authHeader = request.headers.authorization;
if (!authHeader) {
throw new UnauthorizedException('Authorization header is missing.');
if (authHeader) {
const [type, t] = authHeader.split(' ');
if (type === 'Bearer' && t) token = t;
}
const [type, token] = authHeader.split(' ');
// fallback to cookie if no Authorization header
if (!token) {
token = (request as any).cookies?.raylab_jwt;
this.logger.debug(`No Authorization header. Trying cookie. cookiePresent=${!!(request as any).cookies} tokenFromCookie=${!!token}`);
} else {
this.logger.debug('Authorization header found. Using Bearer token.');
}
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Invalid authorization header.');
if (!token) {
this.logger.debug('No token found in Authorization header or cookie.');
throw new UnauthorizedException('Authorization token is missing.');
}
const jwksUri = process.env.AUTHENTIK_JWKS_URI;
// First try verifying with external JWKS (Authentik)
if (jwksUri) {
try {
if (!this.jwks) this.jwks = createRemoteJWKSet(new URL(jwksUri));
const { payload } = await jwtVerify(token, this.jwks, {
issuer: process.env.AUTHENTIK_ISSUER,
audience: process.env.AUTHENTIK_AUDIENCE,
});
const identity = new IdentityData(
payload.sub as string,
(payload as any).preferred_username as string | undefined,
(payload as any).email as string | undefined,
payload as Record<string, any>,
);
request.identity = identity;
// mark as external (verified by Authentik JWKS)
(request as any).identitySource = 'external';
this.logger.debug(`Verified token using external JWKS. sub=${payload.sub}`);
return true;
} catch (err) {
this.logger.debug(`External JWKS verification failed: ${(err as Error).message}`);
// ignore and try internal verification
}
}
// Fallback: verify with internal symmetric secret
const secret = process.env.RAYLAB_JWT_SECRET;
if (!secret) {
this.logger.error('RAYLAB_JWT_SECRET is not configured.');
throw new UnauthorizedException('Invalid or expired token.');
}
try {
const payload = await this.jwtService.verifyAsync(token);
const payload = jwt.verify(token, secret) as any;
request['user'] = payload;
const identity = new IdentityData(
payload.sub as string,
payload.preferred_username as string | undefined,
payload.email as string | undefined,
payload as Record<string, any>,
);
request.identity = identity;
// mark as internal (verified by RayLab internal secret)
(request as any).identitySource = 'internal';
this.logger.debug(`Verified token using internal secret. sub=${payload.sub}`);
return true;
} catch {
} catch (err: any) {
this.logger.debug(`Internal token verification failed: ${(err as Error).message}`);
throw new UnauthorizedException('Invalid or expired token.');
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, Logger, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
private readonly logger = new Logger('JwtGuard');
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
this.logger.debug(`JwtGuard invoked. cookies=${JSON.stringify(req.cookies)} authHeader=${req.headers?.authorization}`);
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any, context?: any) {
if (err || !user) {
this.logger.debug(`JwtGuard handleRequest failed. err=${err} user=${!!user} info=${JSON.stringify(info)}`);
} else {
this.logger.debug(`JwtGuard handleRequest success user=${JSON.stringify(user)}`);
}
return super.handleRequest(err, user, info, context);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, Logger, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class RefreshGuard extends AuthGuard('refresh') {
private readonly logger = new Logger('RefreshGuard');
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
this.logger.debug(`RefreshGuard invoked. cookies=${JSON.stringify(req.cookies)} body=${JSON.stringify(req.body)}`);
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any, context?: any) {
if (err || !user) {
this.logger.debug(`RefreshGuard handleRequest failed. err=${err} user=${!!user} info=${JSON.stringify(info)}`);
} else {
this.logger.debug(`RefreshGuard handleRequest success userId=${user.id} authInfo=${JSON.stringify(info)}`);
}
return super.handleRequest(err, user, info, context);
}
}
@@ -1,12 +1,17 @@
import { Request } from 'express';
import { UserData } from '../../../modules/identity/domain/entities/user.entity';
import { IdentityData } from './identity-data';
export interface JwtPayload {
sub: string;
email: string;
role: string;
permissions: string[];
}
export type IdentitySource = 'internal' | 'external';
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
// Identity comes from the external Identity Provider (Authentik)
// JwtAuthGuard must set request.identity = payload
identity?: IdentityData;
// Indicate verification source: 'external' => verified via Authentik JWKS; 'internal' => verified via RayLab internal secret
identitySource?: IdentitySource;
// After CurrentUserGuard resolves the user from repository, it must set request.currentUser = User Domain
currentUser?: UserData;
}
@@ -0,0 +1,8 @@
export class IdentityData {
constructor(
public readonly sub: string,
public readonly preferred_username?: string,
public readonly email?: string,
public readonly claims?: Record<string, any>,
) {}
}
+29
View File
@@ -0,0 +1,29 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy as JwtStrategyBase, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
function cookieExtractor(req: any): string | null {
if (!req) return null;
if (req.cookies && req.cookies.raylab_jwt) return req.cookies.raylab_jwt as string;
return null;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(JwtStrategyBase, 'jwt') {
private readonly logger = new Logger('JwtStrategy');
constructor(private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([cookieExtractor, ExtractJwt.fromAuthHeaderAsBearerToken()]),
secretOrKey: config.get<string>('RAYLAB_JWT_SECRET') || process.env.RAYLAB_JWT_SECRET || 'raylab-secret',
algorithms: ['HS256'],
});
this.logger.debug('JwtStrategy initialized');
}
async validate(payload: any) {
this.logger.debug(`JwtStrategy.validate payload=${JSON.stringify(payload)}`);
return payload; // attached to req.user
}
}
@@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-strategy';
import { InMemoryRefreshStore } from '../../../modules/auth/refresh/inmemory-refresh.store';
import { IUser } from '../../../modules/identity/domain/repositories/user.interface';
import { Inject } from '@nestjs/common';
// Minimal Passport Strategy for refresh tokens (non-JWT random tokens)
class RefreshTokenStrategy extends Strategy {
name = 'refresh';
authenticate(req: any) {
// This will be overridden in PassportStrategy wrapper
this.error(new Error('Not implemented'));
}
}
@Injectable()
export class RefreshStrategy extends PassportStrategy(RefreshTokenStrategy, 'refresh') {
private readonly logger = new Logger('RefreshStrategy');
constructor(private refreshStore: InMemoryRefreshStore, @Inject(IUser) private userRepository: IUser) {
super();
this.logger.debug('RefreshStrategy initialized');
}
async authenticate(req: any, options?: any) {
const cookies = req.cookies || null;
const token = (cookies && cookies.raylab_refresh) || (req.body && req.body.refreshToken);
this.logger.debug(`RefreshStrategy.authenticate cookies=${JSON.stringify(cookies)} tokenExtracted=${!!token}`);
if (!token) {
this.logger.debug('RefreshStrategy: no refresh token provided');
return this.fail('Missing refresh token', 401);
}
try {
const data = await this.refreshStore.get(token);
if (!data) {
this.logger.debug('RefreshStrategy: refresh token not found/expired');
return this.fail('Invalid refresh token', 401);
}
const user = await this.userRepository.getById(data.userId);
if (!user) {
this.logger.debug('RefreshStrategy: user not found for refresh token');
return this.fail('User not found', 401);
}
// success - attach user + token info
const info = { refreshToken: token, userId: data.userId };
this.logger.debug(`RefreshStrategy: validated refresh token for userId=${data.userId}`);
return this.success(user, info);
} catch (e) {
this.logger.debug(`RefreshStrategy error: ${(e as Error).message}`);
return this.error(e as Error);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventEnvelope } from './event.interface';
type Handler = (event: EventEnvelope<any>) => Promise<void> | void;
@Injectable()
export class EventBus {
private handlers: Map<string, Handler[]> = new Map();
private readonly logger = new Logger(EventBus.name);
// Accept either an EventEnvelope or (type, payload) signature for backward compatibility
publish(eventOrType: EventEnvelope<any> | string, payload?: any) {
let envelope: EventEnvelope<any>;
if (typeof eventOrType === 'string') {
envelope = { id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: eventOrType, payload: payload ?? {} };
} else {
envelope = eventOrType;
}
const handlers = this.handlers.get(envelope.type) || [];
for (const h of handlers) {
try {
Promise.resolve(h(envelope)).catch((err) => this.logger.error('Event handler error', err));
} catch (e) {
this.logger.error('Event handler threw', e as any);
}
}
}
subscribe(eventType: string, handler: Handler) {
const list = this.handlers.get(eventType) || [];
list.push(handler);
this.handlers.set(eventType, list);
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface EventEnvelope<T = any> {
id: string;
timestamp: string; // ISO
type: string; // PascalCase event type
payload: T;
}
+33 -8
View File
@@ -1,4 +1,5 @@
import { ValidationPipe } from '@nestjs/common';
import { ValidationPipe, Logger } from '@nestjs/common';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -9,8 +10,12 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = app.get(ConfigService);
const logger = new Logger('Bootstrap');
app.setGlobalPrefix('api');
// enable cookie parser so req.cookies is populated
app.use(cookieParser());
app.setGlobalPrefix('');
app.useGlobalPipes(
new ValidationPipe({
@@ -20,10 +25,27 @@ async function bootstrap() {
}),
);
app.enableCors();
// CORS configuration: only allow configured frontend origins and enable credentials
const allowedOrigins: string[] = [];
const frontend = config.get<string>('FRONTEND_URL');
const prodFrontend = config.get<string>('PRODUCTION_FRONTEND_URL');
if (frontend) allowedOrigins.push(frontend);
if (prodFrontend) allowedOrigins.push(prodFrontend);
const swaggerEnabled =
config.get<string>('SWAGGER_ENABLED') === 'true';
app.enableCors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Accept',
'Origin',
'X-Requested-With',
],
});
const swaggerEnabled = config.get<string>('SWAGGER_ENABLED') === 'true';
if (swaggerEnabled) {
const swaggerConfig = new DocumentBuilder()
@@ -35,14 +57,17 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, document);
SwaggerModule.setup('ApiList', app, document);
}
const port = config.get<number>('PORT') || 3000;
const port = config.get<number>('PORT') || 3000;
logger.log(`Allowed CORS origins: ${JSON.stringify(allowedOrigins)}`);
logger.log(`Server listening on port ${port}`);
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
logger.log(`Server running on http://localhost:${port}`);
}
bootstrap();
@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { ApplicationsController } from './presentation/controllers/applications.controller';
import { PrismaApplicationRepository } from './infrastructure/repositories/prisma-application.repository';
import { IApplication } from './domain/repositories/application.interface';
import { GetApplicationsHandler } from './application/handlers/get-applications.handler';
import { GetApplicationHandler } from './application/handlers/get-application.handler';
import { CreateApplicationHandler } from './application/handlers/create-application.handler';
import { UpdateApplicationHandler } from './application/handlers/update-application.handler';
import { DeleteApplicationHandler } from './application/handlers/delete-application.handler';
import { GetMeApplicationsHandler } from './application/handlers/get-me-applications.handler';
import { ApplicationValidator } from './application/validators/application.validator';
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
import { IdentityModule } from '../identity/identity.module';
@Module({
imports: [IdentityModule],
providers: [
PrismaService,
PrismaApplicationRepository,
GetApplicationsHandler,
GetApplicationHandler,
CreateApplicationHandler,
UpdateApplicationHandler,
DeleteApplicationHandler,
GetMeApplicationsHandler,
ApplicationValidator,
JwtAuthGuard,
{
provide: IApplication,
useClass: PrismaApplicationRepository,
},
],
controllers: [ApplicationsController],
})
export class ApplicationModule {}
@@ -0,0 +1,26 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
import { ApplicationValidator } from '../../application/validators/application.validator';
import { ApplicationData } from '../../domain/entities/application.entity';
@Injectable()
export class CreateApplicationHandler {
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
async execute(payload: any) {
await this.validator.validateCreate(payload);
const app = ApplicationData.create({
code: payload.code,
name: payload.name,
description: payload.description,
icon: payload.icon,
url: payload.url,
applicationsClaim: payload.applicationsClaim,
displayOrder: payload.displayOrder,
});
const created = await this.appRepo.create(app);
return created;
}
}
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class DeleteApplicationHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(id: string) {
// ensure exists
await this.appRepo.findById(id);
await this.appRepo.delete(id);
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetApplicationHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(id: string) {
const app = await this.appRepo.findById(id);
return app;
}
}
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetApplicationsHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
const res = await this.appRepo.find({ page: query.page, limit: query.limit, search: query.search || null });
return res;
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class GetMeApplicationsHandler {
constructor(private readonly appRepo: IApplication) {}
async execute(applicationsClaimList: string[] | null | undefined) {
if (!applicationsClaimList || applicationsClaimList.length === 0) return { data: [], total: 0 };
// Only return active applications whose applicationsClaim exists in provided list
const res = await this.appRepo.find({ page: 1, limit: 1000, isActive: true, applicationsClaimIn: applicationsClaimList });
// sort by displayOrder asc
res.data.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
return res;
}
}
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
import { ApplicationValidator } from '../../application/validators/application.validator';
@Injectable()
export class UpdateApplicationHandler {
constructor(private readonly appRepo: IApplication, private readonly validator: ApplicationValidator) {}
async execute(id: string, payload: any) {
const existing = await this.appRepo.findById(id);
if (!existing) throw new Error('Application not found');
await this.validator.validateUpdate(id, payload);
existing.update(payload);
const updated = await this.appRepo.update(existing);
return updated;
}
}
@@ -0,0 +1,64 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { IApplication } from '../../domain/repositories/application.interface';
@Injectable()
export class ApplicationValidator {
constructor(private readonly appRepo: IApplication) {}
async validateCreate(payload: any) {
if (!payload || !payload.code) throw new BadRequestException('code is required');
if (!payload.name) throw new BadRequestException('name is required');
if (!payload.applicationsClaim) throw new BadRequestException('applicationsClaim is required');
// url validation if provided
if (payload.url) {
try {
// allow relative urls
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) {
throw new Error('invalid');
}
// new URL(payload.url) // avoid throwing for relative
} catch (e) {
throw new BadRequestException('invalid url');
}
}
// displayOrder
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
throw new BadRequestException('invalid displayOrder');
}
// unique code
const byCode = await this.appRepo.findByCode(payload.code);
if (byCode) throw new BadRequestException('duplicate code');
const byClaim = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
if (byClaim) throw new BadRequestException('duplicate applicationsClaim');
}
async validateUpdate(id: string, payload: any) {
if (!payload) return;
if (payload.url) {
try {
if (!payload.url.startsWith('/') && !payload.url.startsWith('http')) throw new Error('invalid');
} catch (e) {
throw new BadRequestException('invalid url');
}
}
if (payload.displayOrder !== undefined && typeof payload.displayOrder !== 'number') {
throw new BadRequestException('invalid displayOrder');
}
if (payload.code) {
const existing = await this.appRepo.findByCode(payload.code);
if (existing && existing.id !== id) throw new BadRequestException('duplicate code');
}
if (payload.applicationsClaim) {
const existing = await this.appRepo.findByApplicationsClaim(payload.applicationsClaim);
if (existing && existing.id !== id) throw new BadRequestException('duplicate applicationsClaim');
}
}
}
@@ -0,0 +1,100 @@
export class ApplicationData {
private constructor(
public readonly id: string,
public code: string,
public name: string,
public description: string | null,
public icon: string | null,
public url: string | null,
public applicationsClaim: string,
public isActive: boolean,
public displayOrder: number,
public createdAt: Date | null,
public updatedAt: Date | null,
) {}
static create(data: {
code: string;
name: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim: string;
displayOrder?: number;
}) {
return new ApplicationData(
crypto.randomUUID(),
data.code,
data.name,
data.description ?? null,
data.icon ?? null,
data.url ?? null,
data.applicationsClaim,
true,
data.displayOrder ?? 0,
new Date(),
new Date(),
);
}
static restore(props: {
id: string;
code: string;
name: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim: string;
isActive?: boolean;
displayOrder?: number;
createdAt?: Date | null;
updatedAt?: Date | null;
}) {
return new ApplicationData(
props.id,
props.code,
props.name,
props.description ?? null,
props.icon ?? null,
props.url ?? null,
props.applicationsClaim,
props.isActive !== undefined ? props.isActive : true,
props.displayOrder ?? 0,
props.createdAt ?? null,
props.updatedAt ?? null,
);
}
update(data: {
code?: string;
name?: string;
description?: string | null;
icon?: string | null;
url?: string | null;
applicationsClaim?: string;
isActive?: boolean;
displayOrder?: number;
}) {
if (data.code !== undefined) this.code = data.code;
if (data.name !== undefined) this.name = data.name;
if (data.description !== undefined) this.description = data.description;
if (data.icon !== undefined) this.icon = data.icon;
if (data.url !== undefined) this.url = data.url;
if (data.applicationsClaim !== undefined) this.applicationsClaim = data.applicationsClaim;
if (data.isActive !== undefined) this.isActive = data.isActive;
if (data.displayOrder !== undefined) this.displayOrder = data.displayOrder;
this.updatedAt = new Date();
}
toResponse() {
return {
id: this.id,
code: this.code,
name: this.name,
description: this.description,
icon: this.icon,
url: this.url,
displayOrder: this.displayOrder,
};
}
}
@@ -0,0 +1,12 @@
import { ApplicationData } from '../entities/application.entity';
export abstract class IApplication {
abstract find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }): Promise<{ data: ApplicationData[]; total: number }>;
abstract findById(id: string): Promise<ApplicationData>;
abstract findByCode(code: string): Promise<ApplicationData | null>;
abstract findByApplicationsClaim(claim: string): Promise<ApplicationData | null>;
abstract create(app: ApplicationData): Promise<ApplicationData>;
abstract update(app: ApplicationData): Promise<ApplicationData>;
abstract delete(appId: string): Promise<void>;
}
@@ -0,0 +1,19 @@
import { ApplicationData } from '../../domain/entities/application.entity';
export class PrismaApplicationMapper {
static toDomain(model: any): ApplicationData {
return ApplicationData.restore({
id: model.id,
code: model.code,
name: model.name,
description: model.description,
icon: model.icon,
url: model.url,
applicationsClaim: model.applicationsClaim,
isActive: model.isActive,
displayOrder: model.displayOrder,
createdAt: model.createdAt,
updatedAt: model.updatedAt,
});
}
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../shared/prisma.service';
import { IApplication } from '../../domain/repositories/application.interface';
import { PrismaApplicationMapper } from '../mappers/prisma-application.mapper';
import { ApplicationData } from '../../domain/entities/application.entity';
@Injectable()
export class PrismaApplicationRepository implements IApplication {
constructor(private readonly prisma: PrismaService) {}
async find(params: { page?: number; limit?: number; search?: string | null; isActive?: boolean | null; applicationsClaimIn?: string[] | null }) {
const page = params.page && params.page > 0 ? params.page : 1;
const limit = params.limit && params.limit > 0 ? params.limit : 25;
const where: any = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ code: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
];
}
if (params.isActive !== undefined && params.isActive !== null) {
where.isActive = params.isActive;
}
if (params.applicationsClaimIn && params.applicationsClaimIn.length > 0) {
where.applicationsClaim = { in: params.applicationsClaimIn };
}
const [total, items] = await Promise.all([
this.prisma.application.count({ where }),
this.prisma.application.findMany({ where, skip: (page - 1) * limit, take: limit, orderBy: { displayOrder: 'asc', createdAt: 'asc' } }),
]);
return { data: items.map(i => PrismaApplicationMapper.toDomain(i)), total };
}
async findById(id: string) {
const row = await this.prisma.application.findUnique({ where: { id } });
if (!row) throw new Error('Application not found');
return PrismaApplicationMapper.toDomain(row);
}
async findByCode(code: string) {
const row = await this.prisma.application.findUnique({ where: { code } });
if (!row) return null;
return PrismaApplicationMapper.toDomain(row);
}
async findByApplicationsClaim(claim: string) {
const row = await this.prisma.application.findUnique({ where: { applicationsClaim: claim } });
if (!row) return null;
return PrismaApplicationMapper.toDomain(row);
}
async create(app: ApplicationData) {
const created = await this.prisma.application.create({ data: {
id: app.id,
code: app.code,
name: app.name,
description: app.description,
icon: app.icon,
url: app.url,
applicationsClaim: app.applicationsClaim,
isActive: app.isActive,
displayOrder: app.displayOrder,
} });
return PrismaApplicationMapper.toDomain(created);
}
async update(app: ApplicationData) {
const updated = await this.prisma.application.update({ where: { id: app.id }, data: {
code: app.code,
name: app.name,
description: app.description,
icon: app.icon,
url: app.url,
applicationsClaim: app.applicationsClaim,
isActive: app.isActive,
displayOrder: app.displayOrder,
} });
return PrismaApplicationMapper.toDomain(updated);
}
async delete(appId: string) {
await this.prisma.application.delete({ where: { id: appId } });
}
}
@@ -0,0 +1,76 @@
import { Controller, Get, Param, UseGuards, Query, Patch, Delete, Post, Body, Req } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../../../core/auth/guards/jwt-auth.guard';
import { CurrentUserGuard } from '../../../identity/presentation/guards/current-user.guard';
import { GetApplicationsHandler } from '../../application/handlers/get-applications.handler';
import { GetApplicationHandler } from '../../application/handlers/get-application.handler';
import { CreateApplicationHandler } from '../../application/handlers/create-application.handler';
import { UpdateApplicationHandler } from '../../application/handlers/update-application.handler';
import { DeleteApplicationHandler } from '../../application/handlers/delete-application.handler';
import { GetMeApplicationsHandler } from '../../application/handlers/get-me-applications.handler';
@ApiTags('Applications')
@Controller()
export class ApplicationsController {
constructor(
private readonly getApplicationsHandler: GetApplicationsHandler,
private readonly getApplicationHandler: GetApplicationHandler,
private readonly createApplicationHandler: CreateApplicationHandler,
private readonly updateApplicationHandler: UpdateApplicationHandler,
private readonly deleteApplicationHandler: DeleteApplicationHandler,
private readonly getMeApplicationsHandler: GetMeApplicationsHandler,
) {}
@Get('applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'List applications' })
async findAll(@Query() query: any) {
const res = await this.getApplicationsHandler.execute({ page: query.page, limit: query.limit, search: query.search });
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
}
@Get('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Get application by id' })
async findOne(@Param('id') id: string) {
const app = await this.getApplicationHandler.execute(id);
return { success: true, data: app.toResponse(), meta: {} };
}
@Post('applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Create application' })
async create(@Body() body: any) {
const created = await this.createApplicationHandler.execute(body);
return { success: true, data: created.toResponse(), meta: {} };
}
@Patch('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Update application' })
async update(@Param('id') id: string, @Body() body: any) {
const updated = await this.updateApplicationHandler.execute(id, body);
return { success: true, data: updated.toResponse(), meta: {} };
}
@Delete('applications/:id')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: 'Delete application' })
async remove(@Param('id') id: string) {
await this.deleteApplicationHandler.execute(id);
return { success: true, data: null, meta: {} };
}
// Dashboard endpoint
@Get('me/applications')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
@ApiOperation({ summary: "Get current user's applications (filtered by Authentik claims)" })
async me(@Req() req: any) {
const ctx = req.raylabContext;
const identity = ctx && ctx.identity ? ctx.identity : {};
const applicationsClaimList = identity.applications || [];
const res = await this.getMeApplicationsHandler.execute(applicationsClaimList);
return { success: true, data: res.data.map(a => a.toResponse ? a.toResponse() : a), meta: { total: res.total } };
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { EventEnvelope } from '../../core/event-bus/event.interface';
import { AuditService } from './audit.service';
@Injectable()
export class AuditEventHandler {
private readonly logger = new Logger(AuditEventHandler.name);
constructor(private readonly events: EventBus, private readonly auditService: AuditService) {
this.events.subscribe('RolesSynchronized', (e) => this.handleRolesSynchronized(e));
this.events.subscribe('UserAuthenticated', (e) => this.handleUserAuthenticated(e));
this.events.subscribe('RoleCreated', (e) => this.handleGeneric(e));
this.events.subscribe('RoleUpdated', (e) => this.handleGeneric(e));
this.events.subscribe('RoleDeleted', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionCreated', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionUpdated', (e) => this.handleGeneric(e));
this.events.subscribe('PermissionDeleted', (e) => this.handleGeneric(e));
this.events.subscribe('UserActivated', (e) => this.handleGeneric(e));
this.events.subscribe('UserDeactivated', (e) => this.handleGeneric(e));
}
async handleRolesSynchronized(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
async handleUserAuthenticated(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
async handleGeneric(event: EventEnvelope<any>) {
try {
await this.auditService.createFromEvent(event);
} catch (e) {
this.logger.error('Audit handler failed', e as any);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditEventHandler } from './audit.event-handler';
import { PrismaService } from '../../shared/prisma.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
@Module({
providers: [AuditService, AuditEventHandler, PrismaService, EventBus],
exports: [AuditService],
})
export class AuditModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { EventEnvelope } from '../../core/event-bus/event.interface';
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(private readonly prisma: PrismaService) {}
async createFromEvent(event: EventEnvelope<any>) {
try {
// Map standard events to AuditLog entries
await this.prisma.auditLog.create({
data: {
userId: event.payload?.userId || null,
action: event.type,
resource: event.payload?.resource || null,
resourceId: event.payload?.resourceId || null,
details: event.payload,
createdAt: new Date(event.timestamp),
},
});
} catch (e) {
this.logger.error('Failed to write audit log', e as any);
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Controller, Get, Post, Query, Res, Req, Body, HttpCode, HttpStatus, Logger, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { Response, Request } from 'express';
import { JwtGuard } from '../../core/auth/guards/jwt.guard';
import { RefreshGuard } from '../../core/auth/guards/refresh.guard';
import { ConfigService } from '@nestjs/config';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger('AuthController');
constructor(private readonly authService: AuthService, private readonly config: ConfigService) {}
@Get('login')
@ApiOperation({ summary: 'Start Authorization Code + PKCE login (redirect to Identity Provider)' })
async login(@Query('returnTo') returnTo: string | undefined, @Res() res: Response) {
const redirect = await this.authService.createAuthorizationRedirect(returnTo);
return res.redirect(302, redirect);
}
@Get('callback')
@ApiOperation({ summary: 'OIDC callback endpoint' })
async callback(@Query('code') code: string, @Query('state') state: string, @Res() res: Response) {
const result = await this.authService.handleCallback(code, state);
// set cookies with environment-aware options
const isProd = this.config.get<string>('NODE_ENV') === 'production' || process.env.NODE_ENV === 'production';
const cookieDomain = this.config.get<string>('RAYLAB_COOKIE_DOMAIN') || (isProd ? '.raylab.site' : undefined);
const cookieOptions: any = {
httpOnly: true,
secure: isProd, // secure in production
sameSite: isProd ? 'none' : 'lax', // cross-site in production
path: '/',
};
if (cookieDomain) cookieOptions.domain = cookieDomain;
// access token cookie (internal JWT)
this.logger.log(`Setting access & refresh cookies. cookieOptions=${JSON.stringify(cookieOptions)}`);
res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 });
// refresh token cookie
res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 });
this.logger.debug(`Callback complete. returnTo=${result.returnTo} user=${JSON.stringify(result.user)}`);
return res.redirect(302, result.returnTo || '/');
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout (invalidate internal session and redirect to identity provider logout)' })
async logout(@Req() req: Request, @Res() res: Response) {
const refreshToken = req.cookies?.raylab_refresh || req.body?.refreshToken;
const redirect = await this.authService.logout(refreshToken);
return res.redirect(302, redirect);
}
@Post('refresh')
@UseGuards(RefreshGuard)
@ApiOperation({ summary: 'Refresh internal JWT using internal refresh token' })
async refresh(@Req() req: Request, @Res() res: Response) {
// req.authInfo should contain { refreshToken }
const refreshToken = (req as any).authInfo?.refreshToken || (req.cookies?.raylab_refresh) || (req.body?.refreshToken);
this.logger.debug(`Refresh called (guarded). cookies=${JSON.stringify((req as any).cookies)} authInfo=${JSON.stringify((req as any).authInfo)}`);
const result = await this.authService.refresh(refreshToken);
// set cookies with environment-aware options
const isProd = this.config.get<string>('NODE_ENV') === 'production' || process.env.NODE_ENV === 'production';
const cookieDomain = this.config.get<string>('RAYLAB_COOKIE_DOMAIN') || (isProd ? '.raylab.site' : undefined);
const cookieOptions: any = {
httpOnly: true,
secure: isProd,
sameSite: isProd ? 'none' : 'lax',
path: '/',
};
if (cookieDomain) cookieOptions.domain = cookieDomain;
// set new cookies (rotate)
res.cookie('raylab_jwt', result.accessToken, { ...cookieOptions, maxAge: result.expiresIn * 1000 });
res.cookie('raylab_refresh', result.refreshToken, { ...cookieOptions, maxAge: result.refreshTtl * 1000 });
this.logger.debug(`Refresh complete. set new cookies for user`);
return res.json({ success: true, data: result });
}
@Get('me')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Get current user from internal JWT (cookie or Authorization header)' })
async me(@Req() req: Request) {
// With JwtGuard, req.user should be payload from token
this.logger.debug(`Me called (guarded). cookies=${JSON.stringify((req as any).cookies)} user=${JSON.stringify((req as any).user)}`);
const payload = (req as any).user;
const user = await this.authService.me((req.cookies?.raylab_jwt) || (req.headers.authorization && (req.headers.authorization as string).replace(/^Bearer\s+/i, '')));
this.logger.debug(`Me returning user id=${user?.id}`);
return { success: true, data: user };
}
}
+77
View File
@@ -0,0 +1,77 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from '../../core/auth/strategies/jwt.strategy';
import { RefreshStrategy } from '../../core/auth/strategies/refresh.strategy';
import { JwtGuard } from '../../core/auth/guards/jwt.guard';
import { RefreshGuard } from '../../core/auth/guards/refresh.guard';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PrismaService } from '../../shared/prisma.service';
import { IUser } from '../identity/domain/repositories/user.interface';
import { PrismaUserRepository } from '../identity/infrastructure/repositories/prisma-user.repository';
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
import { IRole } from '../identity/domain/repositories/role.interface';
import { PrismaRoleRepository } from '../identity/infrastructure/repositories/prisma-role.repository';
import { IAuthConfig } from '../identity/application/config/i-auth-config';
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
import { RedisPkceStore } from './pkce/redis-pkce.store';
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
import { OidcService } from './oidc.service';
import { RoleSyncService } from './role-sync.service';
import { RedisService } from '../../shared/redis.service';
import { GroupHashService } from './group-hash.service';
import { AuthenticationService } from './authentication.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { AuditService } from '../audit/audit.service';
import { AuthorizationModule } from '../authorization/authorization.module';
@Module({
imports: [
ConfigModule,
AuthorizationModule,
PassportModule.register({ defaultStrategy: 'jwt', session: false }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
secret: config.get('RAYLAB_JWT_SECRET') || 'raylab-secret',
signOptions: { algorithm: 'HS256' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [
AuthService,
PrismaService,
PrismaUserRepository,
PrismaRoleRepository,
SyncIdentityHandler,
{ provide: IUser, useClass: PrismaUserRepository },
{ provide: IRole, useClass: PrismaRoleRepository },
{ provide: IAuthConfig, useClass: EnvAuthConfig },
// In-memory PKCE and Refresh stores (Redis removed)
RedisPkceStore,
InMemoryRefreshStore,
// OIDC & Role Sync
OidcService,
RoleSyncService,
GroupHashService,
AuthenticationService,
EventBus,
AuditService,
RedisService,
// Strategies & Guards
JwtStrategy,
RefreshStrategy,
JwtGuard,
RefreshGuard,
],
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
})
export class AuthModule {}
+236
View File
@@ -0,0 +1,236 @@
import { Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
import { IUser } from '../identity/domain/repositories/user.interface';
import { PrismaService } from '../../shared/prisma.service';
import { RedisPkceStore } from './pkce/redis-pkce.store';
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
// dynamic require to avoid TypeScript typing issues with installed openid-client
// eslint-disable-next-line @typescript-eslint/no-var-requires
const OpenIDClient = require('openid-client');
import crypto from 'crypto';
const logger = new Logger('AuthService');
@Injectable()
export class AuthService {
constructor(
private readonly config: ConfigService,
private readonly jwtService: JwtService,
private readonly prisma: PrismaService,
@Inject(IUser) private readonly userRepository: IUser,
private readonly syncIdentityHandler: SyncIdentityHandler,
private readonly pkceStore: RedisPkceStore,
private readonly refreshStore: InMemoryRefreshStore,
) {}
private issuer: any | null = null;
private client: any | null = null;
private async getIssuer() {
if (this.issuer) return this.issuer;
const issuerUrl = this.config.get<string>('AUTHENTIK_ISSUER');
if (!issuerUrl) throw new Error('AUTHENTIK_ISSUER not configured');
this.issuer = await OpenIDClient.Issuer.discover(issuerUrl);
return this.issuer;
}
private async getClient() {
if (this.client) return this.client;
const issuer = await this.getIssuer();
const clientId = this.config.get<string>('AUTHENTIK_CLIENT_ID');
const clientSecret = this.config.get<string>('AUTHENTIK_CLIENT_SECRET');
if (!clientId) throw new Error('AUTHENTIK_CLIENT_ID not configured');
this.client = new issuer.Client({ client_id: clientId, client_secret: clientSecret });
return this.client;
}
async createAuthorizationRedirect(returnTo?: string): Promise<string> {
const client = await this.getClient();
const redirectUri = this.config.get<string>('AUTHENTIK_REDIRECT_URI');
if (!redirectUri) {
throw new Error('AUTHENTIK_REDIRECT_URI is not configured');
}
const state = crypto.randomUUID();
const code_verifier = OpenIDClient.generators.codeVerifier();
const code_challenge = await OpenIDClient.generators.codeChallenge(code_verifier);
const nonce = OpenIDClient.generators.nonce();
// save PKCE session keyed by state
await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300);
const url = client.authorizationUrl({
redirect_uri: redirectUri,
scope: this.config.get<string>('AUTHENTIK_DEFAULT_SCOPE') || 'openid email profile',
response_type: 'code',
code_challenge,
code_challenge_method: 'S256',
state,
nonce,
});
return url;
}
async handleCallback(code: string, state: string) {
const client = await this.getClient();
// retrieve PKCE session using state provided by the IdP
const pkce = await this.pkceStore.get(state);
if (!pkce) throw new UnauthorizedException('Invalid or expired state');
const redirectUri = this.config.get<string>('AUTHENTIK_REDIRECT_URI');
if (!redirectUri) throw new Error('AUTHENTIK_REDIRECT_URI is not configured');
// Exchange code for tokens. Provide explicit checks: state, nonce and code_verifier.
let tokenSet: any;
try {
tokenSet = await client.callback(
redirectUri,
{ code, state },
{ state, nonce: pkce.nonce, code_verifier: pkce.code_verifier },
);
} catch (err) {
logger.debug('Authorization code exchange failed: ' + (err as Error).message);
// remove PKCE entry to avoid replay
await this.pkceStore.remove(state);
throw new UnauthorizedException('Authorization code exchange failed');
}
// remove PKCE entry after successful exchange
await this.pkceStore.remove(state);
// verify id_token and get claims
const claims = tokenSet.claims();
// fetch userinfo if available
let userInfo: Record<string, any> | null = null;
try {
if ((tokenSet as any).access_token && typeof client.userinfo === 'function') {
userInfo = await client.userinfo((tokenSet as any).access_token);
}
} catch (e) {
// ignore userinfo errors
}
const identity = {
sub: (userInfo && (userInfo as any).sub) || (claims as any).sub || null,
preferred_username:
(userInfo && ((userInfo as any).preferred_username || (userInfo as any).username || (userInfo as any).email)) ||
(claims as any).preferred_username || (claims as any).email,
email: (userInfo && (userInfo as any).email) || (claims as any).email,
raw: { tokenSet, userInfo, claims },
} as any;
// Sync identity to local user (create if needed)
const domainUser = await this.syncIdentityHandler.execute(identity as any);
// ensure active/not deleted
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
if (domainUser.deletedAt) throw new UnauthorizedException('User is deleted');
// create internal JWT
const jwtPayload = {
sub: domainUser.id,
preferred_username: domainUser.username,
email: domainUser.email,
};
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
const access = this.jwtService.sign(jwtPayload, { expiresIn });
logger.debug(`Created internal access token for user=${domainUser.id} expiresIn=${expiresIn}`);
// create internal refresh token
const refreshToken = crypto.randomUUID();
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days
await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl);
logger.debug(`Stored refresh token for user=${domainUser.id} ttl=${refreshTtl}`);
return {
accessToken: access,
refreshToken,
expiresIn,
refreshTtl,
user: {
id: domainUser.id,
username: domainUser.username,
email: domainUser.email,
roles: domainUser.roles || [],
},
returnTo: pkce.returnTo,
};
}
async refresh(refreshToken?: string) {
if (!refreshToken) {
logger.debug('Refresh called without refresh token');
throw new UnauthorizedException('Missing refresh token');
}
const data = await this.refreshStore.get(refreshToken);
if (!data) {
logger.debug(`Refresh token not found or expired: ${refreshToken}`);
throw new UnauthorizedException('Invalid refresh token');
}
logger.debug(`Refresh token validated for userId=${data.userId}`);
const userId = data.userId;
// load user
const domainUser = await this.userRepository.getById(userId);
if (!domainUser) throw new UnauthorizedException('User not found');
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
// rotate refresh token
await this.refreshStore.del(refreshToken);
const newRefresh = crypto.randomUUID();
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
logger.debug(`Rotated refresh token for userId=${userId} newRefresh=${newRefresh} ttl=${refreshTtl}`);
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
const access = this.jwtService.sign({ sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email }, { expiresIn });
logger.debug(`Issued new access token for user=${userId} expiresIn=${expiresIn}`);
return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl };
}
async logout(refreshToken?: string) {
if (refreshToken) {
await this.refreshStore.del(refreshToken);
}
const issuer = await this.getIssuer();
const endSession = issuer.metadata.end_session_endpoint;
const postLogout = this.config.get<string>('AUTHENTIK_POST_LOGOUT_REDIRECT') || '/';
if (endSession) {
// Redirect to identity provider logout
const url = new URL(endSession);
if (postLogout) url.searchParams.set('post_logout_redirect_uri', postLogout);
return url.toString();
}
return postLogout;
}
async me(token?: string) {
if (!token) {
logger.debug('Me called without token');
throw new UnauthorizedException('Missing token');
}
try {
const payload: any = this.jwtService.verify(token);
logger.debug(`Token verified successfully. payload.sub=${payload.sub}`);
const user = await this.userRepository.getById(payload.sub);
if (!user) throw new UnauthorizedException('User not found');
return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] };
} catch (e) {
logger.debug(`Token verification failed: ${(e as Error).message}`);
throw new UnauthorizedException('Invalid token');
}
}
}
@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { OidcService } from './oidc.service';
import { PrismaService } from '../../shared/prisma.service';
import { RoleSyncService } from './role-sync.service';
import { AuthorizationService } from '../authorization/authorization.service';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { RequestContext } from '../../shared/types/request-context';
@Injectable()
export class AuthenticationService {
private readonly logger = new Logger(AuthenticationService.name);
constructor(
private readonly oidc: OidcService,
private readonly prisma: PrismaService,
private readonly roleSync: RoleSyncService,
private readonly authorization: AuthorizationService,
private readonly events: EventBus,
) {}
async authenticate(bearerToken: string): Promise<RequestContext> {
// Validate token (signature/iss/aud/exp)
const claims = await this.oidc.verifyToken(bearerToken);
const sub = claims.sub;
if (!sub) throw new Error('Invalid token: missing sub');
// Resolve identity
const identity = { sub, email: claims.email, preferred_username: claims.preferred_username, raw: claims };
// Find or create user (materialize)
let user = await (this.prisma as any).user.findUnique({ where: { authentikId: sub } });
if (!user) {
user = await (this.prisma as any).user.create({ data: { authentikId: sub, username: identity.preferred_username || identity.email || sub, email: identity.email || null } });
}
// Synchronize roles
const groups: string[] = Array.isArray(claims.groups) ? claims.groups : [];
await this.roleSync.syncUserRolesFromAuthentik(user.id, groups);
// Load permissions
const permissions = await this.authorization.getUserPermissions(user.id);
// Build request context
const rolesRows = await (this.prisma as any).userRole.findMany({ where: { userId: user.id } });
const roles = rolesRows.map((r: any) => r.roleId);
const ctx: RequestContext = { user, identity, roles, permissions };
// Publish domain event (legacy string event name expected by some subscribers/tests)
this.events.publish('user.authenticated', { userId: user.id, identity });
return ctx;
}
}
+2
View File
@@ -0,0 +1,2 @@
// Login DTO removed. Password grant has been removed in favor of Authorization Code + PKCE flow.
// Formerly contained username/password properties.
+11
View File
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
@Injectable()
export class GroupHashService {
compute(groups: string[]): string {
const sorted = (groups || []).slice().sort();
const data = sorted.join(',');
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
}
+26
View File
@@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Logger, Inject } from '@nestjs/common';
import { OidcService } from '../oidc.service';
import { AuthenticationService } from '../authentication.service';
@Injectable()
export class OidcGuard implements CanActivate {
private readonly logger = new Logger(OidcGuard.name);
constructor(private readonly oidc: OidcService, private readonly authn: AuthenticationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const auth = req.headers['authorization'] || req.headers['Authorization'];
if (!auth || typeof auth !== 'string' || !auth.startsWith('Bearer ')) throw new UnauthorizedException('Missing bearer token');
const token = auth.substring(7).trim();
try {
const ctx = await this.authn.authenticate(token);
// attach context to request under a structured key
req.raylabContext = ctx;
return true;
} catch (e) {
this.logger.debug('Authentication failed', (e as any).message);
throw new UnauthorizedException('Invalid token or authentication failed');
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class OidcService {
private jwksUri: string | null = null;
private issuer: string;
private audience: string | string[] | undefined;
private logger = new Logger(OidcService.name);
constructor(private readonly config: ConfigService) {
this.issuer = this.config.get<string>('AUTHENTIK_ISSUER') || '';
this.audience = this.config.get<string>('AUTHENTIK_AUDIENCE') || undefined;
const jwksUri = this.config.get<string>('AUTHENTIK_JWKS_URI');
if (jwksUri) this.jwksUri = jwksUri;
else if (this.issuer) this.jwksUri = `${this.issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
}
async verifyToken(token: string) {
if (!this.jwksUri) throw new Error('JWKS not configured');
try {
// dynamic import to avoid ESM loading issues in test environment
const jose = await import('jose');
const jwks = jose.createRemoteJWKSet(new URL(this.jwksUri));
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: this.issuer || undefined,
audience: this.audience,
} as any);
return payload as Record<string, any>;
} catch (e) {
this.logger.debug('Token verification failed', (e as Error).message);
throw e;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
type PkceEntry = { code_verifier: string; nonce: string; returnTo?: string; expiresAt: number };
@Injectable()
export class RedisPkceStore implements OnModuleDestroy {
// In-memory PKCE store replacing Redis-backed implementation
private map = new Map<string, PkceEntry>();
private cleanupInterval?: NodeJS.Timeout;
constructor() {
// periodic cleanup
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [k, v] of this.map.entries()) {
if (v.expiresAt <= now) this.map.delete(k);
}
}, 60 * 1000);
}
private key(state: string) { return state; }
async save(state: string, data: { code_verifier: string; nonce: string; returnTo?: string }, ttlSeconds = 300) {
const expiresAt = Date.now() + ttlSeconds * 1000;
this.map.set(this.key(state), { ...data, expiresAt });
}
async get(state: string) {
const v = this.map.get(this.key(state));
if (!v) return null;
if (v.expiresAt <= Date.now()) { this.map.delete(this.key(state)); return null; }
return { code_verifier: v.code_verifier, nonce: v.nonce, returnTo: v.returnTo };
}
async remove(state: string) {
this.map.delete(this.key(state));
}
onModuleDestroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
@@ -0,0 +1,38 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
type RefreshEntry = { userId: string; expiresAt: number };
@Injectable()
export class InMemoryRefreshStore implements OnModuleDestroy {
private map = new Map<string, RefreshEntry>();
private cleanupInterval?: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [k, v] of this.map.entries()) {
if (v.expiresAt <= now) this.map.delete(k);
}
}, 60 * 1000);
}
async set(token: string, data: { userId: string }, ttlSeconds: number) {
const expiresAt = Date.now() + ttlSeconds * 1000;
this.map.set(token, { userId: data.userId, expiresAt });
}
async get(token: string) {
const v = this.map.get(token);
if (!v) return null;
if (v.expiresAt <= Date.now()) { this.map.delete(token); return null; }
return { userId: v.userId };
}
async del(token: string) {
this.map.delete(token);
}
onModuleDestroy() {
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { PermissionCache } from '../authorization/cache/permission-cache.interface';
import { EventBus } from '../../core/event-bus/event-bus.service';
import { GroupHashService } from './group-hash.service';
import { PERMISSION_CACHE } from '../authorization/authorization.service';
@Injectable()
export class RoleSyncService {
private readonly logger = new Logger(RoleSyncService.name);
constructor(
private readonly prisma: PrismaService,
private readonly groupHash: GroupHashService,
@Inject(PERMISSION_CACHE) private readonly permissionCache: PermissionCache,
private readonly events: EventBus,
) {}
computeGroupHash(groups: string[]): string {
return this.groupHash.compute(groups || []);
}
async mapGroupsToRoleIds(groups: string[]): Promise<string[]> {
if (!groups || groups.length === 0) return [];
const mappings = await (this.prisma as any).authGroupRoleMapping.findMany({ where: { authGroup: { in: groups } } });
const roleIds = mappings.map((m: any) => m.roleId);
return Array.from(new Set(roleIds));
}
async syncUserRolesFromAuthentik(userId: string, groups: string[]) {
const groupHash = this.computeGroupHash(groups || []);
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
if (!user) throw new Error('User not found');
if (user.lastGroupHash === groupHash) {
this.logger.debug('Group hash unchanged, skipping sync');
return { skipped: true };
}
const roleIds = await this.mapGroupsToRoleIds(groups || []);
const previousRoleRows = await (this.prisma as any).userRole.findMany({ where: { userId, source: 'AUTHENTIK' }, select: { roleId: true } });
const previousRoles = previousRoleRows.map((r: any) => r.roleId);
await (this.prisma as any).$transaction(async (tx: any) => {
await tx.userRole.deleteMany({ where: { userId: userId, source: 'AUTHENTIK' } });
if (roleIds.length > 0) {
const createData = roleIds.map((rid: string) => ({ userId, roleId: rid, source: 'AUTHENTIK' }));
await tx.userRole.createMany({ data: createData, skipDuplicates: true } as any);
}
await tx.user.update({ where: { id: userId }, data: { lastGroupHash: groupHash } });
});
// Invalidate permission cache through abstraction
try {
await this.permissionCache.invalidate(userId);
} catch (e) {
this.logger.error('Failed to invalidate permission cache', e as any);
}
// Publish event for audit and other subscribers
this.events.publish({
id: require('crypto').randomUUID(),
timestamp: new Date().toISOString(),
type: 'RolesSynchronized',
payload: { userId, groups, assignedRoleIds: roleIds, previousRoles },
});
return { skipped: false, assignedRoleIds: roleIds };
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthorizationService, PERMISSION_CACHE } from './authorization.service';
import { PrismaService } from '../../shared/prisma.service';
import { RedisService } from '../../shared/redis.service';
import { RedisPermissionCache } from './cache/redis-permission-cache.service';
@Module({
providers: [
AuthorizationService,
PrismaService,
RedisService,
{ provide: PERMISSION_CACHE, useClass: RedisPermissionCache },
],
exports: [AuthorizationService, PERMISSION_CACHE],
})
export class AuthorizationModule {}
@@ -0,0 +1,51 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { PermissionCache } from './cache/permission-cache.interface';
export const PERMISSION_CACHE = 'PERMISSION_CACHE';
@Injectable()
export class AuthorizationService {
private readonly logger = new Logger(AuthorizationService.name);
constructor(private readonly prisma: PrismaService, @Inject(PERMISSION_CACHE) private readonly cache: PermissionCache) {}
async getUserPermissions(userId: string): Promise<string[]> {
try {
const cached = await this.cache.get(userId);
if (cached) return cached;
} catch (e) {
this.logger.debug('PermissionCache get failed', e as any);
}
const rows = await this.prisma.$queryRaw`
SELECT p.code as code
FROM "UserRole" ur
JOIN "RolePermission" rp ON rp.role_id = ur.role_id
JOIN "Permission" p ON p.id = rp.permission_id
WHERE ur.user_id = ${userId}`;
const perms = Array.isArray(rows) ? rows.map((r: any) => r.code) : [];
try {
await this.cache.set(userId, perms);
} catch (e) {
this.logger.debug('PermissionCache set failed', e as any);
}
return perms;
}
async hasPermission(userId: string, permissionCode: string): Promise<boolean> {
const perms = await this.getUserPermissions(userId);
return perms.includes(permissionCode);
}
async invalidateUserPermissions(userId: string) {
try {
await this.cache.invalidate(userId);
} catch (e) {
this.logger.debug('PermissionCache invalidate failed', e as any);
}
}
}
@@ -0,0 +1,5 @@
export interface PermissionCache {
get(userId: string): Promise<string[] | null>;
set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void>;
invalidate(userId: string): Promise<void>;
}
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PermissionCache } from './permission-cache.interface';
import { RedisService } from '../../../shared/redis.service';
@Injectable()
export class RedisPermissionCache implements PermissionCache {
private readonly TTL = 60 * 5;
constructor(private readonly redis: RedisService) {}
private key(userId: string) {
const { CacheKeys } = require('../../../shared/cache-keys');
return CacheKeys.permission(userId);
}
async get(userId: string): Promise<string[] | null> {
const data = await this.redis.get(this.key(userId));
if (!data) return null;
try {
return JSON.parse(data) as string[];
} catch {
return null;
}
}
async set(userId: string, permissions: string[], ttlSeconds?: number): Promise<void> {
await this.redis.set(this.key(userId), JSON.stringify(permissions), ttlSeconds ?? this.TTL);
}
async invalidate(userId: string): Promise<void> {
await this.redis.del(this.key(userId));
}
}
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { AuthorizationService } from '../authorization.service';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly authz: AuthorizationService, private readonly permission: string) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const user = req.raylab?.user;
if (!user) throw new ForbiddenException('Missing user');
const allowed = await this.authz.hasPermission(user.id, this.permission);
if (!allowed) throw new ForbiddenException('Forbidden');
return true;
}
}
// Factory to create guard instances with permission string (used in decorators)
export const createPermissionGuard = (permission: string) => {
@Injectable()
class _Guard extends PermissionGuard {
constructor(authz: AuthorizationService) {
super(authz, permission);
}
}
return _Guard;
};
@@ -2,6 +2,8 @@ import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@@ -25,12 +27,20 @@ export class PermissionGuard implements CanActivate {
return true;
}
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest() as any;
const user = request.user;
const user = request.currentUser;
return permissions.every(permission =>
user.permissions.includes(permission),
);
if (!user) {
throw new UnauthorizedException('Current user is missing.');
}
const hasAll = permissions.every((permission) => {
return user.hasPermission(permission);
});
if (!hasAll) throw new ForbiddenException('Insufficient permissions.');
return true;
}
}
@@ -0,0 +1,319 @@
import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma.service';
import { randomBytes } from 'crypto';
import { SyncIdentityHandler } from '../../identity/application/handlers/user/sync-identity.handler';
import { IdentityData } from '../../../core/auth/interfaces/identity-data';
@Injectable()
export class DebtService {
constructor(private readonly prisma: PrismaService, private readonly syncIdentityHandler: SyncIdentityHandler) {}
// User sync for Telegram mapping using SyncIdentityHandler
async syncUser(telegramUserId: number | string, displayName?: string) {
if (!telegramUserId) throw new BadRequestException('telegramUserId required');
const sub = `telegram:${String(telegramUserId)}`;
const identity = new IdentityData(sub, displayName || undefined, undefined, { provider: 'telegram', telegramUserId: String(telegramUserId) });
const user = await this.syncIdentityHandler.execute(identity as any);
// UserData has username/email; return display-friendly name as username
return { id: user.id, username: user.username, name: user.username };
}
// Group operations
async createGroup(ownerUserId: string, name: string) {
if (!name) throw new BadRequestException('name required');
const publicId = await this.generatePublicId();
const group = await this.prisma.debtGroup.create({
data: { name, ownerId: ownerUserId, publicId },
});
// add owner as member
await this.prisma.debtGroupMember.create({ data: { groupId: group.id, userId: ownerUserId } });
return { id: group.id, publicId: group.publicId, name: group.name };
}
async listGroupsForUser(userId: string) {
const memberships = await this.prisma.debtGroupMember.findMany({ where: { userId }, include: { group: true } });
return memberships.map(m => ({ id: m.group.id, publicId: m.group.publicId, name: m.group.name, ownerId: m.group.ownerId }));
}
async getGroupDetail(userId: string, id: string) {
const group = await this.prisma.debtGroup.findUnique({ where: { id }, include: { members: true, people: true } });
if (!group) throw new NotFoundException('Group not found');
// check membership
const isMember = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId: id, userId } } });
if (!isMember) throw new ForbiddenException('Not a member');
return { id: group.id, publicId: group.publicId, name: group.name, people: group.people.map(p => ({ id: p.id, name: p.name })) };
}
async joinGroupByPublicId(userId: string, publicId: string) {
const group = await this.prisma.debtGroup.findUnique({ where: { publicId } });
if (!group) throw new NotFoundException('Group not found');
try {
const member = await this.prisma.debtGroupMember.create({ data: { groupId: group.id, userId } });
return { id: member.id };
} catch (e) {
// unique constraint -> already member
return { ok: true };
}
}
async leaveGroup(userId: string, groupId: string) {
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
await this.prisma.debtGroupMember.deleteMany({ where: { groupId, userId } });
return { ok: true };
}
// Person operations (owner only)
async addPerson(userId: string, groupId: string, name: string) {
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.ownerId !== userId) throw new ForbiddenException('Only owner can add person');
// unique name per group
try {
const person = await this.prisma.debtPerson.create({ data: { groupId, name } });
return { id: person.id, name: person.name };
} catch (e) {
throw new BadRequestException('Person with same name already exists in group');
}
}
async listPeople(userId: string, groupId: string) {
const member = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
if (!member) throw new ForbiddenException('Not a member');
const people = await this.prisma.debtPerson.findMany({ where: { groupId, isDeleted: false } });
return people.map(p => ({ id: p.id, name: p.name }));
}
async removePerson(userId: string, groupId: string, personId: string) {
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
if (group.ownerId !== userId) throw new ForbiddenException('Only owner can remove person');
// soft delete
await this.prisma.debtPerson.update({ where: { id: personId }, data: { isDeleted: true } });
return { ok: true };
}
// Transaction creation
private parseAmount(raw: string | number) {
if (typeof raw === 'number') return BigInt(raw);
if (typeof raw !== 'string') throw new BadRequestException('Nominal tidak valid. Gunakan format seperti 17000 atau 17.000.');
const normalized = raw.replace(/\./g, '');
if (!/^\d+$/.test(normalized)) throw new BadRequestException('Nominal tidak valid. Gunakan format seperti 17000 atau 17.000.');
const value = BigInt(normalized);
return value;
}
private parseDate(dateStr?: string) {
const tz = 'Asia/Jakarta';
if (!dateStr) {
// current date in Asia/Jakarta
const now = new Date();
const local = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Jakarta' }));
// keep date part only
local.setHours(0, 0, 0, 0);
return local;
}
// format yyyy/mm/dd
if (!/^\d{4}\/\d{2}\/\d{2}$/.test(dateStr)) throw new BadRequestException('Tanggal tidak valid. Gunakan format yyyy/mm/dd');
const [y, m, d] = dateStr.split('/').map(s => parseInt(s, 10));
// create Date in Asia/Jakarta by constructing as if local time then setHours 0
const dt = new Date(Date.UTC(y, m - 1, d));
// store as date-only; prisma maps to date
return dt;
}
async createTransaction(userId: string, groupId: string, type: 'DEBT' | 'PAYMENT', body: any) {
const { from, to, price, description, date, requestId } = body;
if (!from || !to) throw new BadRequestException('from and to required');
if (from === to) throw new BadRequestException('from and to cannot be same');
// validate group exists and member
const group = await this.prisma.debtGroup.findUnique({ where: { id: groupId } });
if (!group) throw new NotFoundException('Group not found');
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
if (!membership) throw new ForbiddenException('Not a member');
// find persons by name within group
const fromPerson = await this.prisma.debtPerson.findFirst({ where: { groupId, name: from, isDeleted: false } });
const toPerson = await this.prisma.debtPerson.findFirst({ where: { groupId, name: to, isDeleted: false } });
if (!fromPerson || !toPerson) throw new BadRequestException('Person not found in group');
if (!requestId) throw new BadRequestException('requestId is required for idempotency');
// idempotency: check existing in DB
const existing = await this.prisma.debtTransaction.findFirst({ where: { groupId, requestId } });
if (existing) return existing;
const amount = this.parseAmount(price);
if (amount <= 0n) throw new BadRequestException('Nominal harus lebih besar dari 0');
const txDate = this.parseDate(date);
if (type === 'DEBT') {
const created = await this.prisma.debtTransaction.create({
data: {
groupId,
fromPersonId: fromPerson.id,
toPersonId: toPerson.id,
amount,
type,
description,
transactionDate: txDate,
createdByUserId: userId,
requestId,
},
});
return created;
}
// PAYMENT handling with overpayment -> create reverse debt for remainder
if (type === 'PAYMENT') {
// compute current net (positive means from owes to)
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId, OR: [{ fromPersonId: fromPerson.id, toPersonId: toPerson.id }, { fromPersonId: toPerson.id, toPersonId: fromPerson.id }] } });
let net = 0n;
for (const t of txs) {
if (t.fromPersonId === fromPerson.id && t.toPersonId === toPerson.id) {
net += (t.type === 'DEBT' ? BigInt(t.amount) : -BigInt(t.amount));
} else if (t.fromPersonId === toPerson.id && t.toPersonId === fromPerson.id) {
net += (t.type === 'DEBT' ? -BigInt(t.amount) : BigInt(t.amount));
}
}
// create PAYMENT transaction
const payment = await this.prisma.debtTransaction.create({
data: {
groupId,
fromPersonId: fromPerson.id,
toPersonId: toPerson.id,
amount,
type,
description,
transactionDate: txDate,
createdByUserId: userId,
requestId,
},
});
// existing positive debt (from owes to)
const existingPositive = net > 0n ? net : 0n;
const remainder = amount - existingPositive;
if (remainder > 0n) {
// need creator name for description
const creator = await this.prisma.user.findUnique({ where: { id: userId } });
const creatorName = creator?.name || creator?.username || userId;
const rev = await this.prisma.debtTransaction.create({
data: {
groupId,
fromPersonId: toPerson.id,
toPersonId: fromPerson.id,
amount: remainder,
type: 'DEBT',
description: `Sisa pembayaran hutang dari ${creatorName}`,
transactionDate: txDate,
createdByUserId: userId,
},
});
return { payment, remainder: rev };
}
return payment;
}
throw new BadRequestException('Invalid transaction type');
}
async listTransactions(userId: string, groupId: string, page = 1, limit = 100) {
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
if (!membership) throw new ForbiddenException('Not a member');
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId }, orderBy: [{ transactionDate: 'asc' }, { createdAt: 'asc' }], include: { fromPerson: true, toPerson: true, createdByUser: true } });
return txs.map(t => ({ id: t.id, from: t.fromPerson.name, to: t.toPerson.name, amount: t.amount.toString(), type: t.type, description: t.description, transactionDate: t.transactionDate, createdAt: t.createdAt }));
}
async getDetail(userId: string, groupId: string) {
// history same as listTransactions
return this.listTransactions(userId, groupId);
}
async getSummary(userId: string, groupId: string) {
const membership = await this.prisma.debtGroupMember.findUnique({ where: { groupId_userId: { groupId, userId } } });
if (!membership) throw new ForbiddenException('Not a member');
const people = await this.prisma.debtPerson.findMany({ where: { groupId, isDeleted: false } });
const personIds = people.map(p => p.id);
const txs = await this.prisma.debtTransaction.findMany({ where: { groupId } });
// compute pairwise net: map fromId->toId -> bigint
const pairMap = new Map<string, bigint>();
const nameById = new Map<string, string>();
people.forEach(p => nameById.set(p.id, p.name));
for (const t of txs) {
const key = `${t.fromPersonId}::${t.toPersonId}`;
const prev = pairMap.get(key) || 0n;
if (t.type === 'DEBT') {
pairMap.set(key, prev + BigInt(t.amount));
} else {
pairMap.set(key, prev - BigInt(t.amount));
}
}
// collapse pairwise into net per pair (from->to positive means from owes to)
const netMap = new Map<string, bigint>();
for (const [key, val] of pairMap.entries()) {
const [a, b] = key.split('::');
const reverseKey = `${b}::${a}`;
const rev = pairMap.get(reverseKey) || 0n;
const net = (val - rev);
if (net === 0n) continue;
if (net > 0n) {
netMap.set(`${a}::${b}`, net);
} else {
netMap.set(`${b}::${a}`, -net);
}
}
// Now perform global netting using net balances
const balance = new Map<string, bigint>();
for (const p of people) balance.set(p.id, 0n);
for (const [k, v] of netMap.entries()) {
const [a, b] = k.split('::');
balance.set(a, (balance.get(a) || 0n) - v);
balance.set(b, (balance.get(b) || 0n) + v);
}
// Greedy settlement
const settlements: Array<{ from: string; to: string; amount: string }> = [];
const creditors = [] as Array<{ id: string; bal: bigint }>;
const debtors = [] as Array<{ id: string; bal: bigint }>;
balance.forEach((v, k) => {
if (v > 0n) creditors.push({ id: k, bal: v });
else if (v < 0n) debtors.push({ id: k, bal: -v });
});
creditors.sort((a, b) => Number(b.bal - a.bal));
debtors.sort((a, b) => Number(b.bal - a.bal));
let i = 0;
let j = 0;
while (i < debtors.length && j < creditors.length) {
const debtor = debtors[i];
const creditor = creditors[j];
const settle = debtor.bal < creditor.bal ? debtor.bal : creditor.bal;
settlements.push({ from: nameById.get(debtor.id) || debtor.id, to: nameById.get(creditor.id) || creditor.id, amount: settle.toString() });
debtor.bal -= settle;
creditor.bal -= settle;
if (debtor.bal === 0n) i++;
if (creditor.bal === 0n) j++;
}
return settlements;
}
private async generatePublicId(): Promise<string> {
// RL-XXXXXX style
const code = 'RL-' + randomBytes(4).toString('hex').toUpperCase().slice(0, 6);
// ensure uniqueness
const exists = await this.prisma.debtGroup.findUnique({ where: { publicId: code } });
if (exists) return this.generatePublicId();
return code;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma.service';
import { JwtAuthGuard } from '../../core/auth/guards/jwt-auth.guard';
import { DebtController } from './presentation/debt.controller';
import { DebtService } from './application/debt.service';
import { IdentityModule } from '../identity/identity.module';
@Module({
imports: [IdentityModule],
controllers: [DebtController],
providers: [PrismaService, DebtService, JwtAuthGuard],
exports: [DebtService],
})
export class DebtModule {}
@@ -0,0 +1,114 @@
import { Controller, Post, Body, UseGuards, Req, Get, Param, Patch, Delete, Query } from '@nestjs/common';
import { JwtAuthGuard } from '../../../core/auth/guards/jwt-auth.guard';
import { CurrentUserGuard } from '../../identity/presentation/guards/current-user.guard';
import { DebtService } from '../application/debt.service';
import { SyncUserDto } from './dto/sync-user.dto';
import { CreateGroupDto } from './dto/create-group.dto';
import { CreatePersonDto } from './dto/create-person.dto';
import { CreateTransactionDto } from './dto/create-transaction.dto';
@Controller('api/bot/debt')
@UseGuards(JwtAuthGuard, CurrentUserGuard)
export class DebtController {
constructor(private readonly debtService: DebtService) {}
@Post('users/sync')
async syncUser(@Body() body: SyncUserDto) {
const { telegramUserId, displayName } = body;
const user = await this.debtService.syncUser(telegramUserId, displayName);
return { success: true, data: user };
}
@Post('groups')
async createGroup(@Req() req: any, @Body() body: CreateGroupDto) {
const userId = req.currentUser.id;
const { name } = body;
const group = await this.debtService.createGroup(userId, name);
return { success: true, data: group };
}
@Get('groups')
async listGroups(@Req() req: any) {
const userId = req.currentUser.id;
const groups = await this.debtService.listGroupsForUser(userId);
return { success: true, data: groups };
}
@Get('groups/:id')
async getGroup(@Req() req: any, @Param('id') id: string) {
const userId = req.currentUser.id;
const group = await this.debtService.getGroupDetail(userId, id);
return { success: true, data: group };
}
@Post('groups/:publicId/join')
async joinGroup(@Req() req: any, @Param('publicId') publicId: string) {
const userId = req.currentUser.id;
const res = await this.debtService.joinGroupByPublicId(userId, publicId);
return { success: true, data: res };
}
@Post('groups/:id/leave')
async leaveGroup(@Req() req: any, @Param('id') id: string) {
const userId = req.currentUser.id;
const res = await this.debtService.leaveGroup(userId, id);
return { success: true, data: res };
}
@Post('groups/:id/people')
async addPerson(@Req() req: any, @Param('id') id: string, @Body() body: CreatePersonDto) {
const userId = req.currentUser.id;
const { name } = body;
const person = await this.debtService.addPerson(userId, id, name);
return { success: true, data: person };
}
@Get('groups/:id/people')
async listPeople(@Req() req: any, @Param('id') id: string) {
const userId = req.currentUser.id;
const people = await this.debtService.listPeople(userId, id);
return { success: true, data: people };
}
@Delete('groups/:id/people/:personId')
async removePerson(@Req() req: any, @Param('id') id: string, @Param('personId') personId: string) {
const userId = req.currentUser.id;
await this.debtService.removePerson(userId, id, personId);
return { success: true, data: null };
}
@Post('groups/:id/transactions/debt')
async addDebt(@Req() req: any, @Param('id') id: string, @Body() body: CreateTransactionDto) {
const userId = req.currentUser.id;
const res = await this.debtService.createTransaction(userId, id, 'DEBT', body);
return { success: true, data: res };
}
@Post('groups/:id/transactions/payment')
async addPayment(@Req() req: any, @Param('id') id: string, @Body() body: CreateTransactionDto) {
const userId = req.currentUser.id;
const res = await this.debtService.createTransaction(userId, id, 'PAYMENT', body);
return { success: true, data: res };
}
@Get('groups/:id/transactions')
async listTransactions(@Req() req: any, @Param('id') id: string, @Query('page') page = 1, @Query('limit') limit = 100) {
const userId = req.currentUser.id;
const res = await this.debtService.listTransactions(userId, id, Number(page), Number(limit));
return { success: true, data: res };
}
@Get('groups/:id/summary')
async summary(@Req() req: any, @Param('id') id: string) {
const userId = req.currentUser.id;
const res = await this.debtService.getSummary(userId, id);
return { success: true, data: res };
}
@Get('groups/:id/detail')
async detail(@Req() req: any, @Param('id') id: string) {
const userId = req.currentUser.id;
const res = await this.debtService.getDetail(userId, id);
return { success: true, data: res };
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGroupDto {
@IsString()
@IsNotEmpty()
name!: string;
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreatePersonDto {
@IsNotEmpty()
@IsString()
name!: string;
}
@@ -0,0 +1,31 @@
import { IsNotEmpty, IsString, Matches, IsOptional } from 'class-validator';
export class CreateTransactionDto {
@IsNotEmpty()
@IsString()
from!: string;
@IsNotEmpty()
@IsString()
to!: string;
@IsNotEmpty()
@IsString()
// Accept either plain digits or dot separated thousands: e.g. 17000 or 17.000
@Matches(/^(?:\d+|\d{1,3}(?:\.\d{3})+)$/)
price!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
// yyyy/mm/dd
@Matches(/^\d{4}\/\d{2}\/\d{2}$/)
date?: string;
@IsNotEmpty()
@IsString()
requestId!: string;
}
@@ -0,0 +1,10 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SyncUserDto {
@IsNotEmpty()
@IsString()
telegramUserId!: string;
@IsString()
displayName?: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { HealthService } from './health.service';
@Controller('api/health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
async get() {
return this.healthService.getHealth();
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
exports: [HealthService],
})
export class HealthModule {}
+12
View File
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HealthService {
async getHealth() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'raylab-core',
};
}
}
+1 -12
View File
@@ -1,12 +1 @@
modules/identity/application/
Penjelasan:
Layer application berisi use-case (services/commands/queries) yang mengorkestrasi domain dan infrastruktur.
Contoh file:
- services/get-user.service.ts
- commands/create-user.command.ts
Aturan:
- Application service boleh memanggil repository interface, domain services, dan event publisher.
- Application menangani transaksi jika diperlukan.
ini adalah manager, semua kegiatan diarahkan dari sini, disini bukan mengakses db, menghitung, dll, dari controller akan masuk ke dalam sini dan dijalankan prosesnya, tapi tidak tau http dan databasenya.
@@ -0,0 +1,15 @@
import { IAuthConfig } from './i-auth-config';
export class EnvAuthConfig implements IAuthConfig {
autoCreateUser(): boolean {
return (process.env.AUTH_AUTO_CREATE_USER ?? 'true') === 'true';
}
syncEmail(): boolean {
return (process.env.AUTH_SYNC_EMAIL ?? 'true') === 'true';
}
syncUsername(): boolean {
return (process.env.AUTH_SYNC_USERNAME ?? 'false') === 'true';
}
}
@@ -0,0 +1,5 @@
export abstract class IAuthConfig {
abstract autoCreateUser(): boolean;
abstract syncEmail(): boolean;
abstract syncUsername(): boolean;
}
@@ -0,0 +1 @@
semua method disini didapat dari interface, itu ada di domain/repositories.
@@ -1,28 +0,0 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
@Injectable()
export class DeleteUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<void> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
user.delete();
await this.userRepository.update(user);
// TODO:
// Publish UserDeletedEvent
}
}
@@ -1,24 +0,0 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUserHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(id: string): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException('User not found.');
}
return user;
}
}
@@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { UserRepository } from '../../domain/repositories/user.repository.interface';
import { User } from '../../domain/entities/user.entity';
@Injectable()
export class FindUsersHandler {
constructor(
private readonly userRepository: UserRepository,
) {}
async execute(): Promise<User[]> {
return await this.userRepository.findAll();
}
}
@@ -0,0 +1,29 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { PermissionData } from '../../../domain/entities/permission.entity';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
const crypto = require('crypto');
@Injectable()
export class CreatePermissionHandler {
constructor(private readonly permissionRepository: IPermission, private readonly events: EventBus) {}
async execute(dto: any) {
if (!dto.name || !dto.code) throw new ConflictException('Missing required fields');
const perm = PermissionData.restore({
id: crypto.randomUUID(),
code: dto.code || dto.name,
name: dto.name,
displayName: dto.displayName || dto.name,
description: dto.description || '',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const created = await this.permissionRepository.create(perm);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionCreated', payload: { permissionId: created.id } });
return created;
}
}
@@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class DeletePermissionHandler {
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string) {
const p = await this.permissionRepository.getById(id);
if (!p) throw new NotFoundException('Permission not found.');
// find roles that reference this permission
const rolesWithPerm = await (this.permissionRepository as any).findRoleIdsByPermission(id);
// delete permission
await this.permissionRepository.delete(id);
// invalidate caches for users who have affected roles
const userSet = new Set<string>();
for (const rid of rolesWithPerm) {
const uids = await this.roleRepository.getAssignedUserIds(rid);
uids.forEach(u => userSet.add(u));
}
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionDeleted', payload: { permissionId: id, affectedRoles: rolesWithPerm } });
return;
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class GetPermissionHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(id: string) {
return this.permissionRepository.getById(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
@Injectable()
export class GetPermissionsHandler {
constructor(private readonly permissionRepository: IPermission) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
return this.permissionRepository.find({ page: query.page, limit: query.limit, search: query.search || null });
}
}
@@ -0,0 +1,36 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class UpdatePermissionHandler {
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string, dto: any) {
const perm = await this.permissionRepository.getById(id);
if (!perm) throw new NotFoundException('Permission not found.');
if (dto.name) perm.changeName(dto.name);
if (dto.description !== undefined) perm.changeDescription(dto.description);
if (dto.code) perm.changeCode(dto.code);
const updated = await this.permissionRepository.update(perm);
// invalidate caches for users who belong to roles that reference this permission
const roleIds = await (this.permissionRepository as any).findRoleIdsByPermission(id);
const userSet = new Set<string>();
for (const rid of roleIds) {
const uids = await this.roleRepository.getAssignedUserIds(rid);
uids.forEach(u => userSet.add(u));
}
for (const uid of Array.from(userSet)) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'PermissionUpdated', payload: { permissionId: id } });
return updated;
}
}
@@ -0,0 +1,38 @@
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { IPermission } from '../../../domain/repositories/permission.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class RoleAssignPermissionHandler {
constructor(
private readonly roleRepository: IRole,
private readonly permissionRepository: IPermission,
private readonly authorizationService: AuthorizationService,
private readonly events: EventBus,
) {}
async execute(roleId: string, permissionId: string) {
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
const perm = await this.permissionRepository.getById(permissionId);
if (!perm) throw new NotFoundException('Permission not found.');
role.assignPermission(perm);
const updated = await this.roleRepository.update(role);
// Invalidate permissions cache for users who have this role
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
for (const uid of userIds) {
await this.authorizationService.invalidateUserPermissions(uid);
}
// Publish role.updated event
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, permissionId } });
return updated;
}
}
@@ -0,0 +1,37 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { RoleData } from '../../../domain/entities/role.entity';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
const crypto = require('crypto');
@Injectable()
export class CreateRoleHandler {
constructor(private readonly roleRepository: IRole, private readonly events: EventBus) {}
async execute(dto: any) {
// basic validation
if (!dto.code || !dto.name) throw new ConflictException('Missing required fields');
try {
const role = RoleData.restore({
id: crypto.randomUUID(),
code: dto.code,
name: dto.name,
description: dto.description || '',
permissions: [],
isDefault: dto.isDefault || false,
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const created = await this.roleRepository.create(role);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleCreated', payload: { roleId: created.id } });
return created;
} catch (e) {
throw new ConflictException('Role creation failed.');
}
}
}
@@ -0,0 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class DeleteRoleHandler {
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
// get affected users before delete
const userIds = await this.roleRepository.getAssignedUserIds(id);
await this.roleRepository.delete(id);
// invalidate caches
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleDeleted', payload: { roleId: id } });
return;
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class GetRoleHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(id: string) {
return this.roleRepository.findById(id);
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class GetRolesHandler {
constructor(private readonly roleRepository: IRole) {}
async execute(query: { page?: number; limit?: number; search?: string }) {
return this.roleRepository.find({ page: query.page, limit: query.limit, search: query.search || null });
}
}
@@ -0,0 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class RoleRemovePermissionHandler {
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(roleId: string, permissionId: string) {
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
role.removePermission(permissionId);
const updated = await this.roleRepository.update(role);
// Invalidate caches for users with the role
const userIds = await this.roleRepository.getAssignedUserIds(roleId);
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
// Publish event
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId, removedPermissionId: permissionId } });
return updated;
}
}
@@ -0,0 +1,31 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { IRole } from '../../../domain/repositories/role.interface';
import { AuthorizationService } from '../../../../authorization/authorization.service';
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
@Injectable()
export class UpdateRoleHandler {
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
async execute(id: string, dto: any) {
const role = await this.roleRepository.findById(id);
if (!role) throw new NotFoundException('Role not found.');
// validation
if (dto.name && dto.name.length < 2) throw new BadRequestException('Name too short');
if (dto.name) role.changeName(dto.name);
if (dto.description !== undefined) role.changeDescription(dto.description);
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
const updated = await this.roleRepository.update(role);
// invalidate caches for users with this role
const userIds = await this.roleRepository.getAssignedUserIds(id);
for (const uid of userIds) await this.authorizationService.invalidateUserPermissions(uid);
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'RoleUpdated', payload: { roleId: id } });
return updated;
}
}
@@ -0,0 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { PermissionData } from '../../../domain/entities/permission.entity';
@Injectable()
export class AssignPermissionHandler {
constructor(private readonly userRepository: IUser) {}
async execute(userId: string, permissionId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
// Permission repository is not available; create a minimal PermissionData
const perm = PermissionData.restore({
id: permissionId,
name: permissionId,
description: '',
createdAt: new Date(),
updatedAt: new Date(),
});
user.assignPermission(perm);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
import { IRole } from '../../../domain/repositories/role.interface';
@Injectable()
export class AssignRoleHandler {
constructor(
private readonly userRepository: IUser,
private readonly roleRepository: IRole,
) {}
async execute(userId: string, roleId: string) {
const user = await this.userRepository.getById(userId);
if (!user) throw new NotFoundException('User not found.');
const role = await this.roleRepository.findById(roleId);
if (!role) throw new NotFoundException('Role not found.');
user.assignRole(role);
return this.userRepository.update(user);
}
}
@@ -0,0 +1,31 @@
import {
Injectable,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { CreateUserDto } from '../../../presentation/dto/create-user.dto';
import { IUser } from '../../../domain/repositories/user.interface';
import { UserData } from '../../../domain/entities/user.entity';
import { UserService } from '../../services/user.service';
@Injectable()
export class CreateUserHandler {
constructor(
private readonly userRepository: IUser,
private readonly userService: UserService,
) {}
async execute(dto: CreateUserDto): Promise<UserData> {
const exists = await this.userRepository.existByEmail(dto.email);
if (exists) {
throw new ConflictException('Email already exists.');
}
// Provisioning disabled: Do not create users in external IdP from RayLab.
// Reject attempts to create users via API to enforce creation-at-Authentik policy.
throw new BadRequestException('User creation via RayLab API is disabled. Create users in Authentik and then login to sync.');
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class DeleteUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
await this.userRepository.softDelete(id);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class DisableUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
return this.userRepository.disable(id);
}
}
@@ -0,0 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class EnableUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
const exists = await this.userRepository.existsById(id);
if (!exists) throw new NotFoundException('User not found.');
return this.userRepository.enable(id);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { UserData } from '../../../domain/entities/user.entity';
@Injectable()
export class GetCurrentUserHandler {
async execute(currentUser: UserData) {
// currentUser is already domain user attached by CurrentUserGuard
return currentUser;
}
}
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { IUser } from '../../../domain/repositories/user.interface';
@Injectable()
export class GetUserHandler {
constructor(private readonly userRepository: IUser) {}
async execute(id: string) {
return this.userRepository.getById(id);
}
}

Some files were not shown because too many files have changed in this diff Show More