Phase 3 Completed
This commit is contained in:
@@ -0,0 +1,165 @@
|
|||||||
|
Phase 1 Deliverables - RayLab Core
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Contents
|
||||||
|
--------
|
||||||
|
1. Architecture explanation
|
||||||
|
2. Folder structure
|
||||||
|
3. Source code references (what was added/changed)
|
||||||
|
4. Database schema (Prisma)
|
||||||
|
5. Migration (SQL)
|
||||||
|
6. Seed data (script)
|
||||||
|
7. API design (initial endpoints)
|
||||||
|
8. Design decisions
|
||||||
|
9. Advantages
|
||||||
|
10. Possible future extensions
|
||||||
|
11. How to run (migrate & seed)
|
||||||
|
|
||||||
|
1) Architecture explanation
|
||||||
|
--------------------------
|
||||||
|
Phase 1 establishes the foundational data model and project layout for RayLab Core. It prepares the system for secure integration with Authentik (OIDC) and supports role/permission based authorization owned by RayLab Core. The design follows Clean Architecture, SOLID principles, modular NestJS structure, and Prisma for type-safe DB access.
|
||||||
|
|
||||||
|
Key principles applied:
|
||||||
|
- RayLab Core does NOT implement authentication; it only validates external OIDC tokens (Phase 2).
|
||||||
|
- Roles, permissions, role mappings, users, and audit logs are owned by RayLab Core. Media Manager and Scheduler are intentionally excluded from Phase 1 and will be implemented in Phase 3.
|
||||||
|
- No Group entity is created in RayLab Core; only mapping configuration (auth group name string -> role) is stored.
|
||||||
|
- Critical operations (synchronization) will be transactional (Phase 2 implementation).
|
||||||
|
|
||||||
|
2) Folder structure
|
||||||
|
-------------------
|
||||||
|
(Only top-level relevant directories shown)
|
||||||
|
|
||||||
|
- prisma/
|
||||||
|
- schema.prisma // Prisma schema (source of truth for DB)
|
||||||
|
- migrations/0001_init/ // Initial SQL migration
|
||||||
|
- seed.ts // Seed script (idempotent upserts)
|
||||||
|
|
||||||
|
- src/
|
||||||
|
- main.ts
|
||||||
|
- app.module.ts
|
||||||
|
- modules/
|
||||||
|
- identity/ // DDD-based users/roles/permissions implementation
|
||||||
|
- auth/ // (Phase 2) OIDC integration
|
||||||
|
- authorization/ // (Phase 2) permission guards & services
|
||||||
|
- health/ // basic health endpoint (added)
|
||||||
|
- media/ // media manager module (phase 3)
|
||||||
|
- audit/ // audit module (phase 3)
|
||||||
|
- ... other domain modules
|
||||||
|
- shared/
|
||||||
|
- prisma.service.ts
|
||||||
|
- ...
|
||||||
|
|
||||||
|
3) Source code (what was added/changed)
|
||||||
|
---------------------------------------
|
||||||
|
- prisma/schema.prisma (UPDATED): replaced with production-ready schema containing:
|
||||||
|
User, Role, Permission, RolePermission, UserRole, AuthGroupRoleMapping, AuditLog
|
||||||
|
|
||||||
|
- prisma/migrations/0001_init/migration.sql (ADDED): initial SQL migration DDL
|
||||||
|
|
||||||
|
- prisma/seed.ts (UPDATED): seed script that creates default roles, permissions, role-permission assignments, and Authentik group -> role mappings
|
||||||
|
|
||||||
|
- src/modules/health/* (ADDED): HealthModule, Controller, Service (thin controller)
|
||||||
|
|
||||||
|
- src/shared/prisma.service.ts (EXISTING): PrismaService (already present)
|
||||||
|
|
||||||
|
4) Database schema
|
||||||
|
------------------
|
||||||
|
The Prisma schema (prisma/schema.prisma) is the authoritative schema. High-level model summary:
|
||||||
|
- User: id (uuid), externalId (unique), email, name, picture, metadata, createdAt, updatedAt
|
||||||
|
- Role: id, name (unique), displayName, description
|
||||||
|
- Permission: id, name (unique), description
|
||||||
|
- RolePermission: join table role <-> permission (unique constraint roleId+permissionId)
|
||||||
|
- UserRole: join table user <-> role with source (e.g., 'authentik') and syncedAt timestamp (unique userId+roleId)
|
||||||
|
- AuthGroupRoleMapping: mapping table authGroup (string) -> roleId. One authGroup may map to multiple roles (no uniqueness enforced on authGroup).
|
||||||
|
- AuditLog: append-only audit logs with JSON details
|
||||||
|
|
||||||
|
|
||||||
|
Indexes and constraints are included for common lookup paths. The migration SQL contains explicit DDL statements.
|
||||||
|
|
||||||
|
5) Migration
|
||||||
|
------------
|
||||||
|
- prisma/migrations/0001_init/migration.sql contains the SQL to create the schema.
|
||||||
|
- The migration file includes CREATE EXTENSION IF NOT EXISTS "pgcrypto" to provide gen_random_uuid().
|
||||||
|
- Recommended migration procedure in production:
|
||||||
|
1. Ensure backups and schedule maintenance window for initial deployment.
|
||||||
|
2. Run migrations with Prisma or psql: "psql < migration.sql" or use "prisma migrate deploy" after generating migrations from schema.prisma.
|
||||||
|
|
||||||
|
6) Seed data
|
||||||
|
------------
|
||||||
|
- prisma/seed.ts seeds the following:
|
||||||
|
- Default roles: Owner, Employee, Family
|
||||||
|
- Default permissions following resource.action naming convention (users.create, users.read, ..., scheduler.manage)
|
||||||
|
- RolePermission assignments:
|
||||||
|
- Owner receives all permissions
|
||||||
|
- Employee and Family receive reasonable subsets (editable later)
|
||||||
|
- Authentik group mappings:
|
||||||
|
- RL-Owner -> Owner
|
||||||
|
- RL-Employee -> Employee
|
||||||
|
- RL-Family -> Family
|
||||||
|
|
||||||
|
The seed script is idempotent using upsert operations.
|
||||||
|
|
||||||
|
7) API design (initial)
|
||||||
|
-----------------------
|
||||||
|
Phase 1 provides a minimal, safe public surface to verify system health and readiness.
|
||||||
|
- GET /api/health
|
||||||
|
- Returns: { status: 'ok', timestamp, service }
|
||||||
|
|
||||||
|
Identity module controllers (users, roles, permissions) are present under src/modules/identity/presentation/controllers and follow RESTful conventions, but full auth/guards are implemented in Phase 2.
|
||||||
|
|
||||||
|
8) Design decisions
|
||||||
|
-------------------
|
||||||
|
- Prisma selected for type-safe DB access and migrations. (User requested Prisma)
|
||||||
|
- UUID primary keys (Postgres gen_random_uuid()) for scalability and horizontal distribution.
|
||||||
|
- JSONB (Prisma Json) used for flexible metadata and audit details.
|
||||||
|
- AuthGroupRoleMapping stores external group string only; RayLab Core does NOT model Groups as domain entities.
|
||||||
|
- Seed sets conservative permission assignments; owners get full permissions.
|
||||||
|
- Minimal initial API surface (health) avoids unintentionally exposing functionality before auth & guards are in place.
|
||||||
|
|
||||||
|
9) Advantages
|
||||||
|
-------------
|
||||||
|
- Production-ready DB schema with indices and constraints for performance and data integrity.
|
||||||
|
- Modular NestJS structure aligned with Clean Architecture / DDD; easy to extend in subsequent phases.
|
||||||
|
- Prisma provides type-safety and reduces runtime errors.
|
||||||
|
- Seed script allows reproducible environments and CI setup.
|
||||||
|
- Audit and media tables are present to support required domain features.
|
||||||
|
|
||||||
|
10) Possible future extensions
|
||||||
|
------------------------------
|
||||||
|
Planned for Phase 2 and 3 (once Phase 1 is approved):
|
||||||
|
- OIDC validation using openid-client and configuration from env vars
|
||||||
|
- Role synchronization flow (validate token -> read groups -> map -> compute hash -> replace UserRole in transaction -> invalidate permission cache -> write audit)
|
||||||
|
- Permission cache using Redis, invalidation hooks
|
||||||
|
- Auth guards and AuthorizationService for permission checks
|
||||||
|
- Media Manager: presigned URLs, storage backend (S3/MinIO)
|
||||||
|
- Scheduler: BullMQ integration with Redis
|
||||||
|
- Monitoring: Prometheus metrics, structured logging, Sentry
|
||||||
|
|
||||||
|
11) How to run (migrate & seed)
|
||||||
|
-------------------------------
|
||||||
|
Prerequisites:
|
||||||
|
- Node 20+
|
||||||
|
- PostgreSQL reachable via DATABASE_URL env var
|
||||||
|
- Install dependencies: npm ci
|
||||||
|
|
||||||
|
Generate Prisma client and run migration:
|
||||||
|
|
||||||
|
1) Generate Prisma client:
|
||||||
|
npm run prisma:generate
|
||||||
|
|
||||||
|
2) Apply migration (development):
|
||||||
|
npm run prisma:migrate
|
||||||
|
|
||||||
|
Or run SQL directly against the database:
|
||||||
|
psql "$DATABASE_URL" -f prisma/migrations/0001_init/migration.sql
|
||||||
|
|
||||||
|
3) Run seed script:
|
||||||
|
npm run prisma:seed
|
||||||
|
|
||||||
|
4) Start the app (development):
|
||||||
|
npm run start:dev
|
||||||
|
|
||||||
|
Next steps
|
||||||
|
----------
|
||||||
|
Please review Phase 1 deliverables. After your approval I will implement Phase 2 (OIDC module, Authentik integration, Group->Role Sync, Authorization Guard, Permission Cache, Audit logging) following the same level of production-quality implementation.
|
||||||
|
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
API Spec - Contoh REST API (Bahasa Indonesia)
|
||||||
|
|
||||||
|
Ringkasan
|
||||||
|
--------
|
||||||
|
API ini adalah contoh RESTful API yang bisa dipakai untuk manajemen pengguna, item, dan pesanan. Menggunakan JSON untuk request/response. Autentikasi memakai Bearer Token (JWT).
|
||||||
|
|
||||||
|
Base URL
|
||||||
|
--------
|
||||||
|
- https://api.example.com/v1
|
||||||
|
|
||||||
|
Header Umum
|
||||||
|
-----------
|
||||||
|
- Authorization: Bearer <token> (kecuali endpoint login/register)
|
||||||
|
- Content-Type: application/json
|
||||||
|
- Accept: application/json
|
||||||
|
|
||||||
|
Autentikasi
|
||||||
|
-----------
|
||||||
|
- POST /auth/login
|
||||||
|
- Body (application/json):
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "string"
|
||||||
|
}
|
||||||
|
- Response 200:
|
||||||
|
{
|
||||||
|
"access_token": "<jwt_token>",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
- Kesalahan: 400 (invalid input), 401 (invalid credentials)
|
||||||
|
|
||||||
|
- POST /auth/register
|
||||||
|
- Body:
|
||||||
|
{
|
||||||
|
"name": "Nama User",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}
|
||||||
|
- Response 201: user created (id, name, email)
|
||||||
|
|
||||||
|
Endpoint Pengguna (Users)
|
||||||
|
-------------------------
|
||||||
|
1) GET /users
|
||||||
|
- Deskripsi: Mendapatkan daftar pengguna (admin)
|
||||||
|
- Query params:
|
||||||
|
- page (int, optional, default=1)
|
||||||
|
- per_page (int, optional, default=20, max=100)
|
||||||
|
- sort (string, optional, contoh: "created_at:desc")
|
||||||
|
- q (string, optional) - pencarian nama/email
|
||||||
|
- Response 200:
|
||||||
|
{
|
||||||
|
"data": [ {"id":1, "name":"...", "email":"..."} , ...],
|
||||||
|
"meta": {"page":1, "per_page":20, "total":123}
|
||||||
|
}
|
||||||
|
|
||||||
|
2) GET /users/{id}
|
||||||
|
- Path params:
|
||||||
|
- id (integer, required)
|
||||||
|
- Response 200: user object
|
||||||
|
- Errors: 404 jika tidak ditemukan
|
||||||
|
|
||||||
|
3) PUT /users/{id}
|
||||||
|
- Path params: id
|
||||||
|
- Body (application/json):
|
||||||
|
{
|
||||||
|
"name": "Nama Baru",
|
||||||
|
"email": "email@baru.com"
|
||||||
|
}
|
||||||
|
- Validasi: email harus format valid; name min 2 karakter
|
||||||
|
- Response 200: updated user
|
||||||
|
|
||||||
|
4) DELETE /users/{id}
|
||||||
|
- Path params: id
|
||||||
|
- Response 204: no content
|
||||||
|
- Permissions: hanya admin
|
||||||
|
|
||||||
|
Endpoint Item (Items)
|
||||||
|
---------------------
|
||||||
|
1) GET /items
|
||||||
|
- Query params:
|
||||||
|
- page, per_page, sort (lihat Users)
|
||||||
|
- category (string, optional)
|
||||||
|
- min_price, max_price (decimal, optional)
|
||||||
|
- Response: list items dengan fields id, name, description, price, stock
|
||||||
|
|
||||||
|
2) POST /items
|
||||||
|
- Body:
|
||||||
|
{
|
||||||
|
"name": "Nama Item",
|
||||||
|
"description": "Deskripsi...",
|
||||||
|
"price": 125000.50,
|
||||||
|
"stock": 10,
|
||||||
|
"category": "Elektronik"
|
||||||
|
}
|
||||||
|
- Validasi:
|
||||||
|
- name (required, max 255)
|
||||||
|
- price (required, >=0)
|
||||||
|
- stock (integer, >=0)
|
||||||
|
- Response 201: created item
|
||||||
|
- Permissions: admin atau vendor
|
||||||
|
|
||||||
|
3) GET /items/{id}
|
||||||
|
- Response 200: item object
|
||||||
|
- 404 jika tidak ditemukan
|
||||||
|
|
||||||
|
4) PUT /items/{id}
|
||||||
|
- Body: fields yang boleh diupdate (name, description, price, stock, category)
|
||||||
|
- Response 200
|
||||||
|
|
||||||
|
5) DELETE /items/{id}
|
||||||
|
- Response 204
|
||||||
|
|
||||||
|
Endpoint Pesanan (Orders)
|
||||||
|
-------------------------
|
||||||
|
1) POST /orders
|
||||||
|
- Body:
|
||||||
|
{
|
||||||
|
"user_id": 12, // optional jika token sudah mewakili user
|
||||||
|
"items": [
|
||||||
|
{"item_id": 5, "quantity": 2},
|
||||||
|
{"item_id": 7, "quantity": 1}
|
||||||
|
],
|
||||||
|
"shipping_address": "Alamat lengkap",
|
||||||
|
"note": "Catatan opsional"
|
||||||
|
}
|
||||||
|
- Validasi: setiap item quantity >= 1 dan tersedia di stock
|
||||||
|
- Response 201:
|
||||||
|
{
|
||||||
|
"order_id": 987,
|
||||||
|
"status": "pending",
|
||||||
|
"total": 375000.00
|
||||||
|
}
|
||||||
|
|
||||||
|
2) GET /orders/{id}
|
||||||
|
- Path param: id
|
||||||
|
- Response 200: full order detail (items, prices, shipping, status)
|
||||||
|
- Permissions: hanya pemilik order atau admin
|
||||||
|
|
||||||
|
3) PATCH /orders/{id}/status
|
||||||
|
- Body: {"status": "shipped"}
|
||||||
|
- Allowed status: pending, confirmed, shipped, delivered, cancelled
|
||||||
|
- Permissions: hanya admin atau staff
|
||||||
|
|
||||||
|
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 harus mengandung total, page, per_page, total_pages
|
||||||
|
|
||||||
|
Response Error Umum
|
||||||
|
-------------------
|
||||||
|
- 400 Bad Request - payload tidak valid
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
|
||||||
|
Rate Limiting
|
||||||
|
-------------
|
||||||
|
- Contoh: 1000 requests per 1 jam per API key
|
||||||
|
- Header terkait:
|
||||||
|
- X-RateLimit-Limit: 1000
|
||||||
|
- X-RateLimit-Remaining: 750
|
||||||
|
- X-RateLimit-Reset: 1650000000 (epoch seconds)
|
||||||
|
|
||||||
|
Keamanan dan Persyaratan
|
||||||
|
------------------------
|
||||||
|
- Semua permintaan harus lewat HTTPS (TLS 1.2+)
|
||||||
|
- Gunakan header Authorization: Bearer <token>
|
||||||
|
- CORS: domain yang diijinkan harus didaftarkan
|
||||||
|
- Validasi input di server (length, tipe, range)
|
||||||
|
- Sanitasi data untuk mencegah injection
|
||||||
|
|
||||||
|
Versioning
|
||||||
|
----------
|
||||||
|
- Versi di URL: /v1/
|
||||||
|
- Buat v2 jika ada breaking change
|
||||||
|
|
||||||
|
Contoh Request/Response (cURL)
|
||||||
|
-----------------------------
|
||||||
|
Login:
|
||||||
|
curl -X POST "https://api.example.com/v1/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"user@example.com","password":"password123"}'
|
||||||
|
|
||||||
|
Ambil daftar item:
|
||||||
|
curl "https://api.example.com/v1/items?page=1&per_page=20" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
Footer
|
||||||
|
------
|
||||||
|
Spesifikasi ini adalah contoh umum. Sesuaikan endpoint, nama field, aturan autentikasi, dan kebijakan rate limit sesuai kebutuhan aplikasi Anda.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/tests/**/*.spec.ts', '**/?(*.)+(spec|test).ts'],
|
||||||
|
testPathIgnorePatterns: ['/tests/api/', '/tests/integration/'],
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: 'tsconfig.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+680
-7
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.379.0",
|
||||||
"@nestjs/common": "^10.4.20",
|
"@nestjs/common": "^10.4.20",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.20",
|
"@nestjs/core": "^10.4.20",
|
||||||
@@ -18,13 +19,16 @@
|
|||||||
"@nestjs/platform-express": "^10.4.20",
|
"@nestjs/platform-express": "^10.4.20",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"axios": "^1.4.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"bullmq": "^1.73.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jose": "^6.2.6",
|
"jose": "^6.2.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^6.9.4",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -61,6 +65,314 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/checksums": {
|
||||||
|
"version": "3.1000.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.24.tgz",
|
||||||
|
"integrity": "sha512-7TWLjypP8kk3savsDBRuhZJx7mBuFFA2136BQhwwLllsAnO4Tmq/p+SXZaNxbuulkzUFz3BZzj0bb4YzexZcNQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/client-s3": {
|
||||||
|
"version": "3.1101.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1101.0.tgz",
|
||||||
|
"integrity": "sha512-16EFb1aTEBgPcfUAWAjjlB57IZCyn7B3rlfT+xqE7M6WoH8AMMU3vFZO0UOitwh/xvvzVx73YED1/n0PU4qBMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/checksums": "^3.1000.24",
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.76",
|
||||||
|
"@aws-sdk/middleware-sdk-s3": "^3.972.70",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/fetch-http-handler": "^5.6.13",
|
||||||
|
"@smithy/node-http-handler": "^4.9.13",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/core": {
|
||||||
|
"version": "3.977.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz",
|
||||||
|
"integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@aws-sdk/xml-builder": "^3.972.37",
|
||||||
|
"@aws/lambda-invoke-store": "^0.3.0",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/signature-v4": "^5.6.12",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"bowser": "^2.11.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
|
"version": "3.972.65",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz",
|
||||||
|
"integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
|
"version": "3.972.67",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz",
|
||||||
|
"integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/fetch-http-handler": "^5.6.13",
|
||||||
|
"@smithy/node-http-handler": "^4.9.13",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
|
"version": "3.973.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz",
|
||||||
|
"integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.65",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.67",
|
||||||
|
"@aws-sdk/credential-provider-login": "^3.972.72",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.65",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.973.9",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.39",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/credential-provider-imds": "^4.4.16",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
|
"version": "3.972.72",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz",
|
||||||
|
"integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.39",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
|
"version": "3.972.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz",
|
||||||
|
"integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/credential-provider-env": "^3.972.65",
|
||||||
|
"@aws-sdk/credential-provider-http": "^3.972.67",
|
||||||
|
"@aws-sdk/credential-provider-ini": "^3.973.10",
|
||||||
|
"@aws-sdk/credential-provider-process": "^3.972.65",
|
||||||
|
"@aws-sdk/credential-provider-sso": "^3.973.9",
|
||||||
|
"@aws-sdk/credential-provider-web-identity": "^3.972.71",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/credential-provider-imds": "^4.4.16",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
|
"version": "3.972.65",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz",
|
||||||
|
"integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
|
"version": "3.973.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz",
|
||||||
|
"integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.39",
|
||||||
|
"@aws-sdk/token-providers": "3.1100.0",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
|
"version": "3.972.71",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz",
|
||||||
|
"integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.39",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||||
|
"version": "3.972.70",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.70.tgz",
|
||||||
|
"integrity": "sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
|
"version": "3.997.39",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz",
|
||||||
|
"integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.43",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/fetch-http-handler": "^5.6.13",
|
||||||
|
"@smithy/node-http-handler": "^4.9.13",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
|
"version": "3.996.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz",
|
||||||
|
"integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/signature-v4": "^5.6.12",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/token-providers": {
|
||||||
|
"version": "3.1100.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz",
|
||||||
|
"integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.4",
|
||||||
|
"@aws-sdk/nested-clients": "^3.997.39",
|
||||||
|
"@aws-sdk/types": "^3.974.2",
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/types": {
|
||||||
|
"version": "3.974.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz",
|
||||||
|
"integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws-sdk/xml-builder": {
|
||||||
|
"version": "3.972.37",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz",
|
||||||
|
"integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@aws/lambda-invoke-store": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1278,6 +1590,84 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz",
|
||||||
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="
|
"integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@nestjs/common": {
|
"node_modules/@nestjs/common": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.22",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz",
|
||||||
@@ -1674,6 +2064,87 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@smithy/core": {
|
||||||
|
"version": "3.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz",
|
||||||
|
"integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/credential-provider-imds": {
|
||||||
|
"version": "4.4.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz",
|
||||||
|
"integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/fetch-http-handler": {
|
||||||
|
"version": "5.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz",
|
||||||
|
"integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/node-http-handler": {
|
||||||
|
"version": "4.9.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz",
|
||||||
|
"integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/signature-v4": {
|
||||||
|
"version": "5.6.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz",
|
||||||
|
"integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@smithy/core": "^3.31.1",
|
||||||
|
"@smithy/types": "^4.16.1",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@smithy/types": {
|
||||||
|
"version": "4.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz",
|
||||||
|
"integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||||
@@ -2480,8 +2951,7 @@
|
|||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/atomic-sleep": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -2491,6 +2961,18 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.6",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
@@ -2688,6 +3170,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/bowser": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -2773,6 +3261,78 @@
|
|||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "1.91.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-1.91.1.tgz",
|
||||||
|
"integrity": "sha512-u7dat9I8ZwouZ651AMZkBSvB6NVUPpnAjd4iokd9DM41whqIBnDjuL11h7+kEjcpiDKj6E+wxZiER00FqirZQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "^4.6.0",
|
||||||
|
"get-port": "6.1.2",
|
||||||
|
"glob": "^8.0.3",
|
||||||
|
"ioredis": "^5.2.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"msgpackr": "^1.6.2",
|
||||||
|
"semver": "^7.3.7",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/glob": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^5.0.1",
|
||||||
|
"once": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/minimatch": {
|
||||||
|
"version": "5.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
|
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/uuid": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||||
|
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -3028,7 +3588,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
},
|
},
|
||||||
@@ -3157,6 +3716,19 @@
|
|||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"deprecated": "v4 is no longer maintained, upgrade to v5",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3220,7 +3792,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
@@ -3426,7 +3997,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"get-intrinsic": "^1.2.6",
|
"get-intrinsic": "^1.2.6",
|
||||||
@@ -3943,11 +4513,30 @@
|
|||||||
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
|
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
@@ -4111,6 +4700,18 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-port": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-port/-/get-port-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-proto": {
|
"node_modules/get-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
@@ -4240,7 +4841,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
},
|
},
|
||||||
@@ -5462,6 +6062,15 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||||
@@ -5668,6 +6277,37 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz",
|
||||||
|
"integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
@@ -5729,6 +6369,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -5744,6 +6399,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nopt": {
|
"node_modules/nopt": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
@@ -6326,6 +6990,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
+7
-2
@@ -39,7 +39,7 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"jose": "^6.2.6",
|
"jose": "^6.2.6",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@@ -47,7 +47,12 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"bullmq": "^1.73.0",
|
||||||
|
"nodemailer": "^6.9.4",
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.379.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
-- Initial migration for RayLab Core (PostgreSQL)
|
||||||
|
-- Requires: CREATE EXTENSION IF NOT EXISTS pgcrypto; (for gen_random_uuid())
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Extension for UUID generation
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- Enum types
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'userrolesource') THEN
|
||||||
|
CREATE TYPE "UserRoleSource" AS ENUM ('AUTHENTIK', 'SYSTEM');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE IF NOT EXISTS "User" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
authentik_id text UNIQUE,
|
||||||
|
authentik_user_id text UNIQUE,
|
||||||
|
authentik_subject text UNIQUE,
|
||||||
|
username varchar(255) UNIQUE,
|
||||||
|
email varchar(320) UNIQUE,
|
||||||
|
name varchar(255),
|
||||||
|
picture text,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
last_seen_at timestamptz,
|
||||||
|
last_synced_at timestamptz,
|
||||||
|
sync_status text,
|
||||||
|
storage_quota bigint DEFAULT 10737418240,
|
||||||
|
storage_used bigint DEFAULT 0,
|
||||||
|
metadata jsonb,
|
||||||
|
last_group_hash text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_authentik_id ON "User" (authentik_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_username ON "User" (username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email ON "User" (email);
|
||||||
|
|
||||||
|
-- Roles
|
||||||
|
CREATE TABLE IF NOT EXISTS "Role" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(100) NOT NULL UNIQUE,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
display_name varchar(255),
|
||||||
|
description text,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Permissions
|
||||||
|
CREATE TABLE IF NOT EXISTS "Permission" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(150) NOT NULL UNIQUE,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
display_name varchar(255),
|
||||||
|
description text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RolePermission
|
||||||
|
CREATE TABLE IF NOT EXISTS "RolePermission" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
|
||||||
|
permission_id uuid NOT NULL REFERENCES "Permission"(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_role_permission UNIQUE (role_id, permission_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rolepermission_role_id ON "RolePermission" (role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rolepermission_permission_id ON "RolePermission" (permission_id);
|
||||||
|
|
||||||
|
-- UserRole
|
||||||
|
CREATE TABLE IF NOT EXISTS "UserRole" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES "User"(id) ON DELETE CASCADE,
|
||||||
|
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
|
||||||
|
source "UserRoleSource" NOT NULL DEFAULT 'AUTHENTIK',
|
||||||
|
synced_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_role UNIQUE (user_id, role_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_userrole_user_id ON "UserRole" (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_userrole_role_id ON "UserRole" (role_id);
|
||||||
|
|
||||||
|
-- AuthGroupRoleMapping
|
||||||
|
CREATE TABLE IF NOT EXISTS "AuthGroupRoleMapping" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
auth_group varchar(255) NOT NULL,
|
||||||
|
role_id uuid NOT NULL REFERENCES "Role"(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_authgroup_name ON "AuthGroupRoleMapping" (auth_group);
|
||||||
|
|
||||||
|
-- AuditLog
|
||||||
|
CREATE TABLE IF NOT EXISTS "AuditLog" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
|
||||||
|
action varchar(200) NOT NULL,
|
||||||
|
resource varchar(200),
|
||||||
|
resource_id text,
|
||||||
|
details jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auditlog_user_id ON "AuditLog" (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auditlog_action ON "AuditLog" (action);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Phase 4 migration: add ScheduledJob and MediaObject
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ScheduledJob
|
||||||
|
CREATE TABLE IF NOT EXISTS "ScheduledJob" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
payload jsonb,
|
||||||
|
cron varchar(255),
|
||||||
|
run_at timestamptz,
|
||||||
|
status varchar(50) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts integer NOT NULL DEFAULT 0,
|
||||||
|
last_run_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduledjob_name ON "ScheduledJob" (name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduledjob_status ON "ScheduledJob" (status);
|
||||||
|
|
||||||
|
-- MediaObject
|
||||||
|
CREATE TABLE IF NOT EXISTS "MediaObject" (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
owner_user_id uuid REFERENCES "User"(id) ON DELETE SET NULL,
|
||||||
|
storage_key varchar(1024) NOT NULL,
|
||||||
|
filename varchar(1024),
|
||||||
|
mime_type varchar(255),
|
||||||
|
size bigint,
|
||||||
|
is_public boolean NOT NULL DEFAULT false,
|
||||||
|
metadata jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_owner_user_id ON "MediaObject" (owner_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_storage_key ON "MediaObject" (storage_key);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: add Application table
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "Application" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"code" varchar(100) NOT NULL UNIQUE,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"icon" text,
|
||||||
|
"url" text,
|
||||||
|
"applicationsClaim" varchar(255) NOT NULL UNIQUE,
|
||||||
|
"isActive" boolean NOT NULL DEFAULT true,
|
||||||
|
"displayOrder" integer NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_code_idx" ON "Application" ("code");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Application_applicationsClaim_idx" ON "Application" ("applicationsClaim");
|
||||||
+149
-78
@@ -1,5 +1,5 @@
|
|||||||
// Prisma schema
|
// Prisma schema for RayLab Core - Phase 1
|
||||||
// Basic User model for RayLab Core
|
// PostgreSQL datasource. DATABASE_URL must be set in environment.
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
@@ -11,99 +11,170 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
authentikId String? @unique
|
||||||
authentikId String? @unique
|
|
||||||
authentikUserId String? @unique
|
authentikUserId String? @unique
|
||||||
authentikSubject String? @unique
|
authentikSubject String? @unique
|
||||||
|
username String? @db.VarChar(255) @unique
|
||||||
|
email String? @db.VarChar(320) @unique
|
||||||
|
name String? @db.VarChar(255)
|
||||||
|
picture String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
deletedAt DateTime?
|
||||||
|
lastSeenAt DateTime?
|
||||||
|
lastSyncedAt DateTime?
|
||||||
|
syncStatus String?
|
||||||
|
storageQuota BigInt? @default(10737418240)
|
||||||
|
storageUsed BigInt? @default(0)
|
||||||
|
metadata Json?
|
||||||
|
userRoles UserRole[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
lastGroupHash String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
username String @unique
|
@@index([authentikId])
|
||||||
email String @unique
|
@@index([username])
|
||||||
|
@@index([email])
|
||||||
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
|
|
||||||
// storage (in bytes)
|
|
||||||
storageQuota BigInt @default(10737418240)
|
|
||||||
storageUsed BigInt @default(0)
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
deletedAt DateTime?
|
|
||||||
lastSeenAt DateTime?
|
|
||||||
lastSyncedAt DateTime?
|
|
||||||
syncStatus String?
|
|
||||||
|
|
||||||
roles UserRole[]
|
|
||||||
permissions UserPermission[]
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
code String @unique @db.VarChar(100)
|
||||||
code String @unique
|
name String @db.VarChar(255)
|
||||||
name String
|
displayName String? @db.VarChar(255)
|
||||||
description String?
|
description String?
|
||||||
|
isDefault Boolean @default(false)
|
||||||
isDefault Boolean @default(false)
|
rolePermissions RolePermission[]
|
||||||
|
userRoles UserRole[]
|
||||||
createdAt DateTime @default(now())
|
mappings AuthGroupRoleMapping[]
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
users UserRole[]
|
|
||||||
permissions RolePermission[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Permission {
|
model Permission {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
code String @unique @db.VarChar(150)
|
||||||
code String @unique
|
name String @db.VarChar(255)
|
||||||
name String
|
displayName String? @db.VarChar(255)
|
||||||
description String?
|
description String?
|
||||||
|
rolePermissions RolePermission[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
roles RolePermission[]
|
|
||||||
users UserPermission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserRole {
|
|
||||||
userId String
|
|
||||||
roleId String
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
assignedAt DateTime @default(now())
|
|
||||||
assignedBy String
|
|
||||||
|
|
||||||
@@id([userId, roleId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model RolePermission {
|
model RolePermission {
|
||||||
roleId String
|
id String @id @default(uuid()) @db.Uuid
|
||||||
permissionId String
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
roleId String @db.Uuid
|
||||||
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
permissionId String @db.Uuid
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
@@unique([roleId, permissionId])
|
||||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
@@index([roleId])
|
||||||
|
@@index([permissionId])
|
||||||
assignedAt DateTime @default(now())
|
|
||||||
assignedBy String
|
|
||||||
|
|
||||||
@@id([roleId, permissionId])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPermission {
|
enum UserRoleSource {
|
||||||
userId String
|
AUTHENTIK
|
||||||
permissionId String
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String @db.Uuid
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
roleId String @db.Uuid
|
||||||
|
source UserRoleSource @default(AUTHENTIK)
|
||||||
|
syncedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, roleId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([roleId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthGroupRoleMapping {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
authGroup String @db.VarChar(255)
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
roleId String @db.Uuid
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([authGroup])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
userId String? @db.Uuid
|
||||||
|
action String @db.VarChar(200)
|
||||||
|
resource String? @db.VarChar(200)
|
||||||
|
resourceId String?
|
||||||
|
details Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([action])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ScheduledJob {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
payload Json?
|
||||||
|
cron String?
|
||||||
|
runAt DateTime?
|
||||||
|
status String @db.VarChar(50) @default("pending")
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastRunAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([name])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MediaObject {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
ownerUserId String? @db.Uuid
|
||||||
|
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||||
|
storageKey String @db.VarChar(1024)
|
||||||
|
filename String? @db.VarChar(1024)
|
||||||
|
mimeType String? @db.VarChar(255)
|
||||||
|
size BigInt?
|
||||||
|
isPublic Boolean @default(false)
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([ownerUserId])
|
||||||
|
@@index([storageKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
model Application {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
code String @unique @db.VarChar(100)
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
icon String?
|
||||||
|
url String?
|
||||||
|
applicationsClaim String @unique @db.VarChar(255)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
displayOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([code])
|
||||||
|
@@index([applicationsClaim])
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
assignedAt DateTime @default(now())
|
|
||||||
assignedBy String
|
|
||||||
|
|
||||||
@@id([userId, permissionId])
|
|
||||||
}
|
|
||||||
+171
-1
@@ -1 +1,171 @@
|
|||||||
// prisma seed placeholder
|
/**
|
||||||
|
* Prisma seed script for RayLab Core - Phase 1
|
||||||
|
* Run with: npx ts-node prisma/seed.ts
|
||||||
|
* Requires DATABASE_URL env var.
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding default roles, permissions, and mappings...');
|
||||||
|
|
||||||
|
// Default roles
|
||||||
|
const roles = [
|
||||||
|
{ name: 'Owner', displayName: 'Owner', description: 'Platform owner with full access' },
|
||||||
|
{ name: 'Employee', displayName: 'Employee', description: 'Employee role with internal permissions' },
|
||||||
|
{ name: 'Family', displayName: 'Family', description: 'Family role with limited access' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const r of roles) {
|
||||||
|
const code = r.name.toLowerCase();
|
||||||
|
await prisma.role.upsert({
|
||||||
|
where: { code },
|
||||||
|
update: { name: r.name, displayName: r.displayName, description: r.description },
|
||||||
|
create: { code, name: r.name, displayName: r.displayName, description: r.description },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default permissions (resource.action lowercase as requested)
|
||||||
|
const permissions = [
|
||||||
|
'users.create',
|
||||||
|
'users.read',
|
||||||
|
'users.update',
|
||||||
|
'users.delete',
|
||||||
|
'roles.create',
|
||||||
|
'roles.read',
|
||||||
|
'roles.update',
|
||||||
|
'roles.delete',
|
||||||
|
'permissions.create',
|
||||||
|
'permissions.read',
|
||||||
|
'permissions.update',
|
||||||
|
'permissions.delete',
|
||||||
|
'audit.read',
|
||||||
|
'storage.read',
|
||||||
|
'storage.write',
|
||||||
|
'storage.delete',
|
||||||
|
'media.read',
|
||||||
|
'media.write',
|
||||||
|
'media.delete',
|
||||||
|
'scheduler.read',
|
||||||
|
'scheduler.manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of permissions) {
|
||||||
|
// Use code as canonical identifier (resource.action). Use name == code for now.
|
||||||
|
const code = p;
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { code },
|
||||||
|
update: { name: p, description: null },
|
||||||
|
create: { code, name: p, description: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign permissions to roles (example mapping)
|
||||||
|
const owner = await prisma.role.findUnique({ where: { code: 'owner' } });
|
||||||
|
const employee = await prisma.role.findUnique({ where: { code: 'employee' } });
|
||||||
|
const family = await prisma.role.findUnique({ where: { code: 'family' } });
|
||||||
|
|
||||||
|
if (!owner || !employee || !family) {
|
||||||
|
throw new Error('Default roles missing after upsert');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner -> all permissions
|
||||||
|
const allPerms = await prisma.permission.findMany();
|
||||||
|
for (const perm of allPerms) {
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: { roleId_permissionId: { roleId: owner.id, permissionId: perm.id } },
|
||||||
|
update: {},
|
||||||
|
create: { roleId: owner.id, permissionId: perm.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Employee -> a subset
|
||||||
|
const employeePerms = [
|
||||||
|
'users.read',
|
||||||
|
'permissions.read',
|
||||||
|
'scheduler.read',
|
||||||
|
'scheduler.manage',
|
||||||
|
'media.read',
|
||||||
|
'media.write',
|
||||||
|
];
|
||||||
|
for (const p of employeePerms) {
|
||||||
|
const perm = await prisma.permission.findUnique({ where: { code: p } });
|
||||||
|
if (perm) {
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: { roleId_permissionId: { roleId: employee.id, permissionId: perm.id } },
|
||||||
|
update: {},
|
||||||
|
create: { roleId: employee.id, permissionId: perm.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family -> limited
|
||||||
|
const familyPerms = [
|
||||||
|
'media.read',
|
||||||
|
'media.write',
|
||||||
|
'media.delete',
|
||||||
|
];
|
||||||
|
for (const p of familyPerms) {
|
||||||
|
const perm = await prisma.permission.findUnique({ where: { code: p } });
|
||||||
|
if (perm) {
|
||||||
|
await prisma.rolePermission.upsert({
|
||||||
|
where: { roleId_permissionId: { roleId: family.id, permissionId: perm.id } },
|
||||||
|
update: {},
|
||||||
|
create: { roleId: family.id, permissionId: perm.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentik group mappings
|
||||||
|
const mappings = [
|
||||||
|
{ authGroup: 'RL-Owner', roleName: 'Owner' },
|
||||||
|
{ authGroup: 'RL-Employee', roleName: 'Employee' },
|
||||||
|
{ authGroup: 'RL-Family', roleName: 'Family' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const m of mappings) {
|
||||||
|
const role = await prisma.role.findUnique({ where: { name: m.roleName } });
|
||||||
|
if (!role) continue;
|
||||||
|
// Ensure mapping exists (authGroup + roleId). AuthGroup can map to multiple roles.
|
||||||
|
const existing = await prisma.authGroupRoleMapping.findFirst({ where: { authGroup: m.authGroup, roleId: role.id } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.authGroupRoleMapping.create({ data: { authGroup: m.authGroup, roleId: role.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed application catalog
|
||||||
|
const apps = [
|
||||||
|
{ code: 'core', name: 'Core', description: 'RayLab Core Dashboard', icon: 'mdi-apps', url: '/', applicationsClaim: 'core', displayOrder: 0 },
|
||||||
|
{ code: 'warung', name: 'Warung', description: 'Warung app', icon: 'mdi-store', url: '/warung', applicationsClaim: 'warung', displayOrder: 10 },
|
||||||
|
{ code: 'nextcloud', name: 'Nextcloud', description: 'Nextcloud', icon: 'mdi-cloud', url: 'https://nextcloud.example', applicationsClaim: 'nextcloud', displayOrder: 20 },
|
||||||
|
{ code: 'jellyfin', name: 'Jellyfin', description: 'Jellyfin media server', icon: 'mdi-movie', url: 'https://jellyfin.example', applicationsClaim: 'jellyfin', displayOrder: 30 },
|
||||||
|
{ code: 'forgejo', name: 'Forgejo', description: 'Forgejo git server', icon: 'mdi-source-repository', url: 'https://forgejo.example', applicationsClaim: 'forgejo', displayOrder: 40 },
|
||||||
|
{ code: 'immich', name: 'Immich', description: 'Immich photo server', icon: 'mdi-image', url: 'https://immich.example', applicationsClaim: 'immich', displayOrder: 50 },
|
||||||
|
{ code: 'grafana', name: 'Grafana', description: 'Grafana monitoring', icon: 'mdi-chart-line', url: 'https://grafana.example', applicationsClaim: 'grafana', displayOrder: 60 },
|
||||||
|
{ code: 'vaultwarden', name: 'Vaultwarden', description: 'Vaultwarden password manager', icon: 'mdi-lock', url: 'https://vaultwarden.example', applicationsClaim: 'vaultwarden', displayOrder: 70 },
|
||||||
|
{ code: 'homepage', name: 'Homepage', description: 'Homepage', icon: 'mdi-home', url: 'https://home.example', applicationsClaim: 'homepage', displayOrder: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const a of apps) {
|
||||||
|
await prisma.application.upsert({
|
||||||
|
where: { code: a.code },
|
||||||
|
update: { name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
|
||||||
|
create: { code: a.code, name: a.name, description: a.description, icon: a.icon, url: a.url, applicationsClaim: a.applicationsClaim, isActive: true, displayOrder: a.displayOrder },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding completed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -3,6 +3,11 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
|
|
||||||
import { IdentityModule } from './modules/identity/identity.module';
|
import { IdentityModule } from './modules/identity/identity.module';
|
||||||
import { AuthModule } from './modules/auth/auth.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';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -10,8 +15,14 @@ import { AuthModule } from './modules/auth/auth.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
HealthModule,
|
||||||
|
// Authorization module provides permission checks and cache
|
||||||
|
AuthorizationModule,
|
||||||
|
AuditModule,
|
||||||
|
ApplicationModule,
|
||||||
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
publish(event: EventEnvelope<any>) {
|
||||||
|
const handlers = this.handlers.get(event.type) || [];
|
||||||
|
for (const h of handlers) {
|
||||||
|
try {
|
||||||
|
Promise.resolve(h(event)).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface EventEnvelope<T = any> {
|
||||||
|
id: string;
|
||||||
|
timestamp: string; // ISO
|
||||||
|
type: string; // PascalCase event type
|
||||||
|
payload: T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 { CurrentUserGuard } from '../identity/presentation/guards/current-user.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
PrismaApplicationRepository,
|
||||||
|
GetApplicationsHandler,
|
||||||
|
GetApplicationHandler,
|
||||||
|
CreateApplicationHandler,
|
||||||
|
UpdateApplicationHandler,
|
||||||
|
DeleteApplicationHandler,
|
||||||
|
GetMeApplicationsHandler,
|
||||||
|
ApplicationValidator,
|
||||||
|
JwtAuthGuard,
|
||||||
|
CurrentUserGuard,
|
||||||
|
{
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,20 @@ import { IAuthConfig } from '../identity/application/config/i-auth-config';
|
|||||||
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
|
import { EnvAuthConfig } from '../identity/application/config/env-auth-config';
|
||||||
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
||||||
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
AuthorizationModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (config: ConfigService) => ({
|
useFactory: async (config: ConfigService) => ({
|
||||||
@@ -40,7 +50,16 @@ import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
|
|||||||
// In-memory PKCE and Refresh stores (Redis removed)
|
// In-memory PKCE and Refresh stores (Redis removed)
|
||||||
RedisPkceStore,
|
RedisPkceStore,
|
||||||
InMemoryRefreshStore,
|
InMemoryRefreshStore,
|
||||||
|
|
||||||
|
// OIDC & Role Sync
|
||||||
|
OidcService,
|
||||||
|
RoleSyncService,
|
||||||
|
GroupHashService,
|
||||||
|
AuthenticationService,
|
||||||
|
EventBus,
|
||||||
|
AuditService,
|
||||||
|
RedisService,
|
||||||
],
|
],
|
||||||
exports: [AuthService],
|
exports: [AuthService, OidcService, RoleSyncService, AuthenticationService, EventBus],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
this.events.publish({ id: require('crypto').randomUUID(), timestamp: new Date().toISOString(), type: 'UserAuthenticated', payload: { userId: user.id, identity } });
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,21 +1,29 @@
|
|||||||
import { Injectable, ConflictException } from '@nestjs/common';
|
import { Injectable, ConflictException } from '@nestjs/common';
|
||||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||||
import { PermissionData } from '../../../domain/entities/permission.entity';
|
import { PermissionData } from '../../../domain/entities/permission.entity';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreatePermissionHandler {
|
export class CreatePermissionHandler {
|
||||||
constructor(private readonly permissionRepository: IPermission) {}
|
constructor(private readonly permissionRepository: IPermission, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(dto: any) {
|
async execute(dto: any) {
|
||||||
|
if (!dto.name || !dto.code) throw new ConflictException('Missing required fields');
|
||||||
|
|
||||||
const perm = PermissionData.restore({
|
const perm = PermissionData.restore({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
code: dto.code || dto.name,
|
code: dto.code || dto.name,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
|
displayName: dto.displayName || dto.name,
|
||||||
description: dto.description || '',
|
description: dto.description || '',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return this.permissionRepository.create(perm);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
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()
|
@Injectable()
|
||||||
export class DeletePermissionHandler {
|
export class DeletePermissionHandler {
|
||||||
constructor(private readonly permissionRepository: IPermission) {}
|
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(id: string) {
|
async execute(id: string) {
|
||||||
const p = await this.permissionRepository.getById(id);
|
const p = await this.permissionRepository.getById(id);
|
||||||
if (!p) throw new NotFoundException('Permission not found.');
|
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
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()
|
@Injectable()
|
||||||
export class UpdatePermissionHandler {
|
export class UpdatePermissionHandler {
|
||||||
constructor(private readonly permissionRepository: IPermission) {}
|
constructor(private readonly permissionRepository: IPermission, private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(id: string, dto: any) {
|
async execute(id: string, dto: any) {
|
||||||
const perm = await this.permissionRepository.getById(id);
|
const perm = await this.permissionRepository.getById(id);
|
||||||
@@ -13,6 +16,21 @@ export class UpdatePermissionHandler {
|
|||||||
if (dto.description !== undefined) perm.changeDescription(dto.description);
|
if (dto.description !== undefined) perm.changeDescription(dto.description);
|
||||||
if (dto.code) perm.changeCode(dto.code);
|
if (dto.code) perm.changeCode(dto.code);
|
||||||
|
|
||||||
return this.permissionRepository.update(perm);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, Inject } from '@nestjs/common';
|
||||||
import { IRole } from '../../../domain/repositories/role.interface';
|
import { IRole } from '../../../domain/repositories/role.interface';
|
||||||
import { IPermission } from '../../../domain/repositories/permission.interface';
|
import { IPermission } from '../../../domain/repositories/permission.interface';
|
||||||
|
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoleAssignPermissionHandler {
|
export class RoleAssignPermissionHandler {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly roleRepository: IRole,
|
private readonly roleRepository: IRole,
|
||||||
private readonly permissionRepository: IPermission,
|
private readonly permissionRepository: IPermission,
|
||||||
|
private readonly authorizationService: AuthorizationService,
|
||||||
|
private readonly events: EventBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(roleId: string, permissionId: string) {
|
async execute(roleId: string, permissionId: string) {
|
||||||
@@ -18,6 +22,17 @@ export class RoleAssignPermissionHandler {
|
|||||||
|
|
||||||
role.assignPermission(perm);
|
role.assignPermission(perm);
|
||||||
|
|
||||||
return this.roleRepository.update(role);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Injectable, ConflictException } from '@nestjs/common';
|
import { Injectable, ConflictException } from '@nestjs/common';
|
||||||
import { IRole } from '../../../domain/repositories/role.interface';
|
import { IRole } from '../../../domain/repositories/role.interface';
|
||||||
import { RoleData } from '../../../domain/entities/role.entity';
|
import { RoleData } from '../../../domain/entities/role.entity';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateRoleHandler {
|
export class CreateRoleHandler {
|
||||||
constructor(private readonly roleRepository: IRole) {}
|
constructor(private readonly roleRepository: IRole, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(dto: any) {
|
async execute(dto: any) {
|
||||||
// check uniqueness by code
|
// basic validation
|
||||||
// simple check
|
if (!dto.code || !dto.name) throw new ConflictException('Missing required fields');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// attempt to create; repository may enforce uniqueness
|
|
||||||
const role = RoleData.restore({
|
const role = RoleData.restore({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
@@ -22,7 +25,11 @@ export class CreateRoleHandler {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return this.roleRepository.create(role);
|
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) {
|
} catch (e) {
|
||||||
throw new ConflictException('Role creation failed.');
|
throw new ConflictException('Role creation failed.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { IRole } from '../../../domain/repositories/role.interface';
|
import { IRole } from '../../../domain/repositories/role.interface';
|
||||||
|
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeleteRoleHandler {
|
export class DeleteRoleHandler {
|
||||||
constructor(private readonly roleRepository: IRole) {}
|
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(id: string) {
|
async execute(id: string) {
|
||||||
const role = await this.roleRepository.findById(id);
|
const role = await this.roleRepository.findById(id);
|
||||||
if (!role) throw new NotFoundException('Role not found.');
|
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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { IRole } from '../../../domain/repositories/role.interface';
|
import { IRole } from '../../../domain/repositories/role.interface';
|
||||||
|
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoleRemovePermissionHandler {
|
export class RoleRemovePermissionHandler {
|
||||||
constructor(private readonly roleRepository: IRole) {}
|
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(roleId: string, permissionId: string) {
|
async execute(roleId: string, permissionId: string) {
|
||||||
const role = await this.roleRepository.findById(roleId);
|
const role = await this.roleRepository.findById(roleId);
|
||||||
@@ -11,6 +13,15 @@ export class RoleRemovePermissionHandler {
|
|||||||
|
|
||||||
role.removePermission(permissionId);
|
role.removePermission(permissionId);
|
||||||
|
|
||||||
return this.roleRepository.update(role);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { IRole } from '../../../domain/repositories/role.interface';
|
import { IRole } from '../../../domain/repositories/role.interface';
|
||||||
|
import { AuthorizationService } from '../../../../authorization/authorization.service';
|
||||||
|
import { EventBus } from '../../../../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UpdateRoleHandler {
|
export class UpdateRoleHandler {
|
||||||
constructor(private readonly roleRepository: IRole) {}
|
constructor(private readonly roleRepository: IRole, private readonly authorizationService: AuthorizationService, private readonly events: EventBus) {}
|
||||||
|
|
||||||
async execute(id: string, dto: any) {
|
async execute(id: string, dto: any) {
|
||||||
const role = await this.roleRepository.findById(id);
|
const role = await this.roleRepository.findById(id);
|
||||||
if (!role) throw new NotFoundException('Role not found.');
|
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.name) role.changeName(dto.name);
|
||||||
if (dto.description !== undefined) role.changeDescription(dto.description);
|
if (dto.description !== undefined) role.changeDescription(dto.description);
|
||||||
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
|
if (dto.isDefault !== undefined) role.setDefault(!!dto.isDefault);
|
||||||
|
|
||||||
return this.roleRepository.update(role);
|
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,25 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { IUser } from '../../../domain/repositories/user.interface';
|
||||||
|
import { UpdateUserDto } from '../../../presentation/dto/update-user.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UpdateUserHandler {
|
||||||
|
constructor(private readonly userRepository: IUser) {}
|
||||||
|
|
||||||
|
async execute(id: string, dto: UpdateUserDto) {
|
||||||
|
// Password management is not allowed in RayLab Core
|
||||||
|
if ((dto as any).password) throw new BadRequestException('Password management is not allowed.');
|
||||||
|
|
||||||
|
const user = await this.userRepository.getById(id);
|
||||||
|
if (!user) throw new NotFoundException('User not found.');
|
||||||
|
|
||||||
|
// Only allow updating profile fields: name, email, metadata, storage settings
|
||||||
|
if (dto.name !== undefined) user.changeUsername(dto.name);
|
||||||
|
if (dto.email !== undefined) user.changeEmail(dto.email);
|
||||||
|
// metadata field not handled at domain level yet
|
||||||
|
if ((dto as any).storageQuota !== undefined) user.setStorageQuota((dto as any).storageQuota);
|
||||||
|
if ((dto as any).storageUsed !== undefined) user.setStorageUsed((dto as any).storageUsed);
|
||||||
|
|
||||||
|
return this.userRepository.update(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,7 @@ export abstract class IPermission {
|
|||||||
abstract create(permission: PermissionData): Promise<PermissionData>;
|
abstract create(permission: PermissionData): Promise<PermissionData>;
|
||||||
abstract update(permission: PermissionData): Promise<PermissionData>;
|
abstract update(permission: PermissionData): Promise<PermissionData>;
|
||||||
abstract delete(id: string): Promise<void>;
|
abstract delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
// Returns role ids that reference this permission
|
||||||
|
abstract findRoleIdsByPermission(permissionId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ export abstract class IRole {
|
|||||||
abstract create(role: RoleData): Promise<RoleData>;
|
abstract create(role: RoleData): Promise<RoleData>;
|
||||||
abstract update(role: RoleData): Promise<RoleData>;
|
abstract update(role: RoleData): Promise<RoleData>;
|
||||||
abstract delete(roleId: string): Promise<void>;
|
abstract delete(roleId: string): Promise<void>;
|
||||||
|
|
||||||
|
// Returns user ids assigned to a role (preserves layering)
|
||||||
|
abstract getAssignedUserIds(roleId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { IRole } from './domain/repositories/role.interface';
|
|||||||
import { IPermission } from './domain/repositories/permission.interface';
|
import { IPermission } from './domain/repositories/permission.interface';
|
||||||
import { IAuthConfig } from './application/config/i-auth-config';
|
import { IAuthConfig } from './application/config/i-auth-config';
|
||||||
import { EnvAuthConfig } from './application/config/env-auth-config';
|
import { EnvAuthConfig } from './application/config/env-auth-config';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
|
import { EventBus } from '../../core/event-bus/event-bus.service';
|
||||||
|
|
||||||
import { GetUsersHandler } from './application/handlers/user/get-users.handler';
|
import { GetUsersHandler } from './application/handlers/user/get-users.handler';
|
||||||
import { GetUserHandler } from './application/handlers/user/get-user.handler';
|
import { GetUserHandler } from './application/handlers/user/get-user.handler';
|
||||||
@@ -28,10 +31,9 @@ import { EnableUserHandler } from './application/handlers/user/enable-user.handl
|
|||||||
import { DisableUserHandler } from './application/handlers/user/disable-user.handler';
|
import { DisableUserHandler } from './application/handlers/user/disable-user.handler';
|
||||||
import { DeleteUserHandler } from './application/handlers/user/delete-user.handler';
|
import { DeleteUserHandler } from './application/handlers/user/delete-user.handler';
|
||||||
import { RestoreUserHandler } from './application/handlers/user/restore-user.handler';
|
import { RestoreUserHandler } from './application/handlers/user/restore-user.handler';
|
||||||
import { AssignRoleHandler } from './application/handlers/user/assign-role.handler';
|
import { UpdateUserHandler } from './application/handlers/user/update-user.handler';
|
||||||
import { RemoveRoleHandler } from './application/handlers/user/remove-role.handler';
|
|
||||||
import { AssignPermissionHandler } from './application/handlers/user/assign-permission.handler';
|
|
||||||
import { RemovePermissionHandler } from './application/handlers/user/remove-permission.handler';
|
|
||||||
|
|
||||||
import { GetRolesHandler } from './application/handlers/role/get-roles.handler';
|
import { GetRolesHandler } from './application/handlers/role/get-roles.handler';
|
||||||
import { GetRoleHandler } from './application/handlers/role/get-role.handler';
|
import { GetRoleHandler } from './application/handlers/role/get-role.handler';
|
||||||
@@ -48,7 +50,9 @@ import { UpdatePermissionHandler } from './application/handlers/permission/updat
|
|||||||
import { DeletePermissionHandler } from './application/handlers/permission/delete-permission.handler';
|
import { DeletePermissionHandler } from './application/handlers/permission/delete-permission.handler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [AuthModule, AuthorizationModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
|
||||||
//#region User
|
//#region User
|
||||||
// CreateUserHandler has been removed: provisioning disabled; users must be created in Authentik.
|
// CreateUserHandler has been removed: provisioning disabled; users must be created in Authentik.
|
||||||
SyncIdentityHandler,
|
SyncIdentityHandler,
|
||||||
@@ -59,11 +63,9 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
|||||||
EnableUserHandler,
|
EnableUserHandler,
|
||||||
DisableUserHandler,
|
DisableUserHandler,
|
||||||
DeleteUserHandler,
|
DeleteUserHandler,
|
||||||
RestoreUserHandler,
|
RestoreUserHandler,
|
||||||
AssignRoleHandler,
|
UpdateUserHandler,
|
||||||
RemoveRoleHandler,
|
|
||||||
AssignPermissionHandler,
|
|
||||||
RemovePermissionHandler,
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// role & permission handlers
|
// role & permission handlers
|
||||||
@@ -75,21 +77,24 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
|||||||
RoleAssignPermissionHandler,
|
RoleAssignPermissionHandler,
|
||||||
RoleRemovePermissionHandler,
|
RoleRemovePermissionHandler,
|
||||||
|
|
||||||
GetPermissionsHandler,
|
GetPermissionsHandler,
|
||||||
GetPermissionHandler,
|
GetPermissionHandler,
|
||||||
CreatePermissionHandler,
|
CreatePermissionHandler,
|
||||||
UpdatePermissionHandler,
|
UpdatePermissionHandler,
|
||||||
DeletePermissionHandler,
|
DeletePermissionHandler,
|
||||||
|
|
||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
|
|
||||||
CurrentUserGuard,
|
CurrentUserGuard,
|
||||||
PermissionGuard,
|
PermissionGuard,
|
||||||
Reflector,
|
Reflector,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
PrismaUserRepository,
|
PrismaUserRepository,
|
||||||
PrismaRoleRepository,
|
PrismaRoleRepository,
|
||||||
PrismaPermissionRepository,
|
PrismaPermissionRepository,
|
||||||
UserService,
|
UserService,
|
||||||
|
UpdateUserHandler,
|
||||||
|
|
||||||
{
|
{
|
||||||
provide: IUser,
|
provide: IUser,
|
||||||
useClass: PrismaUserRepository,
|
useClass: PrismaUserRepository,
|
||||||
@@ -106,8 +111,7 @@ import { DeletePermissionHandler } from './application/handlers/permission/delet
|
|||||||
provide: IAuthConfig,
|
provide: IAuthConfig,
|
||||||
useClass: EnvAuthConfig,
|
useClass: EnvAuthConfig,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: [],
|
|
||||||
controllers: [UsersController, RolesController, PermissionsController],
|
controllers: [UsersController, RolesController, PermissionsController],
|
||||||
})
|
})
|
||||||
export class IdentityModule {}
|
export class IdentityModule {}
|
||||||
|
|||||||
@@ -50,4 +50,10 @@ export class PrismaPermissionRepository implements IPermission {
|
|||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
await this.prisma.permission.delete({ where: { id } });
|
await this.prisma.permission.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRoleIdsByPermission(permissionId: string): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.rolePermission.findMany({ where: { permissionId }, select: { roleId: true } });
|
||||||
|
return rows.map(r => r.roleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,4 +106,10 @@ export class PrismaRoleRepository implements IRole {
|
|||||||
async delete(roleId: string) {
|
async delete(roleId: string) {
|
||||||
await this.prisma.role.delete({ where: { id: roleId } });
|
await this.prisma.role.delete({ where: { id: roleId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAssignedUserIds(roleId: string): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.userRole.findMany({ where: { roleId }, select: { userId: true } });
|
||||||
|
return rows.map(r => r.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,8 @@ import { EnableUserHandler } from '../../application/handlers/user/enable-user.h
|
|||||||
import { DisableUserHandler } from '../../application/handlers/user/disable-user.handler';
|
import { DisableUserHandler } from '../../application/handlers/user/disable-user.handler';
|
||||||
import { DeleteUserHandler } from '../../application/handlers/user/delete-user.handler';
|
import { DeleteUserHandler } from '../../application/handlers/user/delete-user.handler';
|
||||||
import { RestoreUserHandler } from '../../application/handlers/user/restore-user.handler';
|
import { RestoreUserHandler } from '../../application/handlers/user/restore-user.handler';
|
||||||
import { AssignRoleHandler } from '../../application/handlers/user/assign-role.handler';
|
import { UpdateUserHandler } from '../../application/handlers/user/update-user.handler';
|
||||||
import { RemoveRoleHandler } from '../../application/handlers/user/remove-role.handler';
|
|
||||||
import { AssignPermissionHandler } from '../../application/handlers/user/assign-permission.handler';
|
|
||||||
import { RemovePermissionHandler } from '../../application/handlers/user/remove-permission.handler';
|
|
||||||
|
|
||||||
@ApiTags('Users')
|
@ApiTags('Users')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@@ -31,10 +29,7 @@ export class UsersController {
|
|||||||
private readonly disableUserHandler: DisableUserHandler,
|
private readonly disableUserHandler: DisableUserHandler,
|
||||||
private readonly deleteUserHandler: DeleteUserHandler,
|
private readonly deleteUserHandler: DeleteUserHandler,
|
||||||
private readonly restoreUserHandler: RestoreUserHandler,
|
private readonly restoreUserHandler: RestoreUserHandler,
|
||||||
private readonly assignRoleHandler: AssignRoleHandler,
|
private readonly updateUserHandler: UpdateUserHandler,
|
||||||
private readonly removeRoleHandler: RemoveRoleHandler,
|
|
||||||
private readonly assignPermissionHandler: AssignPermissionHandler,
|
|
||||||
private readonly removePermissionHandler: RemovePermissionHandler,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -82,6 +77,14 @@ export class UsersController {
|
|||||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@Permissions(PermissionType.USER_UPDATE)
|
||||||
|
@ApiOperation({ summary: 'Update user profile' })
|
||||||
|
async update(@Param('id') id: string, @Body() body: any) {
|
||||||
|
const updated = await this.updateUserHandler.execute(id, body);
|
||||||
|
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Permissions(PermissionType.USER_DELETE)
|
@Permissions(PermissionType.USER_DELETE)
|
||||||
@ApiOperation({ summary: 'Soft delete user' })
|
@ApiOperation({ summary: 'Soft delete user' })
|
||||||
@@ -98,37 +101,5 @@ export class UsersController {
|
|||||||
return { success: true, data: (restored as any).toResponse ? (restored as any).toResponse() : restored, meta: {} };
|
return { success: true, data: (restored as any).toResponse ? (restored as any).toResponse() : restored, meta: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/roles')
|
|
||||||
@Permissions(PermissionType.USER_UPDATE)
|
|
||||||
@ApiOperation({ summary: 'Assign role to user' })
|
|
||||||
async assignRole(@Param('id') id: string, @Body() body: any) {
|
|
||||||
const roleId = body.roleId;
|
|
||||||
const updated = await this.assignRoleHandler.execute(id, roleId);
|
|
||||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id/roles/:roleId')
|
|
||||||
@Permissions(PermissionType.USER_UPDATE)
|
|
||||||
@ApiOperation({ summary: 'Remove role from user' })
|
|
||||||
async removeRole(@Param('id') id: string, @Param('roleId') roleId: string) {
|
|
||||||
const updated = await this.removeRoleHandler.execute(id, roleId);
|
|
||||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/permissions')
|
|
||||||
@Permissions(PermissionType.USER_UPDATE)
|
|
||||||
@ApiOperation({ summary: 'Assign permission to user' })
|
|
||||||
async assignPermission(@Param('id') id: string, @Body() body: any) {
|
|
||||||
const permissionId = body.permissionId;
|
|
||||||
const updated = await this.assignPermissionHandler.execute(id, permissionId);
|
|
||||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id/permissions/:permissionId')
|
|
||||||
@Permissions(PermissionType.USER_UPDATE)
|
|
||||||
@ApiOperation({ summary: 'Remove permission from user' })
|
|
||||||
async removePermission(@Param('id') id: string, @Param('permissionId') permissionId: string) {
|
|
||||||
const updated = await this.removePermissionHandler.execute(id, permissionId);
|
|
||||||
return { success: true, data: (updated as any).toResponse ? (updated as any).toResponse() : updated, meta: {} };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ export class CurrentUserGuard implements CanActivate {
|
|||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest() as any;
|
const request = context.switchToHttp().getRequest() as any;
|
||||||
|
|
||||||
|
// Prefer RequestContext produced by AuthenticationService
|
||||||
|
const ctx = request.raylabContext as any;
|
||||||
|
if (ctx && ctx.user) {
|
||||||
|
request.currentUser = ctx.user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy identity if present
|
||||||
const identity = request.identity as IdentityData | undefined;
|
const identity = request.identity as IdentityData | undefined;
|
||||||
|
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const CacheKeys = {
|
||||||
|
permission: (userId: string) => `permissions:${userId}`,
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from '@nestjs/common';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
// Keep client typed as any to avoid tight coupling to ioredis types in tests
|
||||||
|
private client: any = null;
|
||||||
|
private readonly logger = new Logger(RedisService.name);
|
||||||
|
private lastLogAt = 0;
|
||||||
|
private readonly LOG_THROTTLE_MS = 5000; // throttle repeated error logs
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const enabled = (process.env.REDIS_ENABLED || 'true').toLowerCase() === 'true';
|
||||||
|
if (!enabled) {
|
||||||
|
this.logger.log('Redis disabled via REDIS_ENABLED=false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
|
||||||
|
// Use lazyConnect so application can start even if Redis is unavailable temporarily
|
||||||
|
this.client = new IORedis(url, {
|
||||||
|
lazyConnect: true,
|
||||||
|
// limit retries to avoid infinite reconnect storms
|
||||||
|
maxRetriesPerRequest: 5,
|
||||||
|
// automatic reconnection strategy
|
||||||
|
reconnectOnError: (err: any) => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
enableOfflineQueue: true,
|
||||||
|
// optional reconnect strategy
|
||||||
|
retryStrategy: (times: number) => {
|
||||||
|
// exponential backoff capped at 5s
|
||||||
|
const delay = Math.min(50 * Math.pow(2, times), 5000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('connect', () => this.logger.log('Connected to Redis'));
|
||||||
|
this.client.on('ready', () => this.logger.log('Redis ready'));
|
||||||
|
this.client.on('error', (err: any) => this.handleError(err));
|
||||||
|
this.client.on('close', () => this.logger.warn('Redis connection closed'));
|
||||||
|
this.client.on('reconnecting', () => this.logger.log('Redis reconnecting'));
|
||||||
|
|
||||||
|
// attempt to connect but do not throw if it fails
|
||||||
|
this.client.connect().catch((err: any) => {
|
||||||
|
this.handleError(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(err: any) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastLogAt > this.LOG_THROTTLE_MS) {
|
||||||
|
this.logger.error('Redis error', err instanceof Error ? err.message : err);
|
||||||
|
this.lastLogAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.client) {
|
||||||
|
try {
|
||||||
|
this.client.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): any {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureClient() {
|
||||||
|
// returns true if client is connected/usable
|
||||||
|
return this.client && this.client.status && this.client.status !== 'end' && this.client.status !== 'close';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.ensureClient()) return null;
|
||||||
|
try {
|
||||||
|
return await this.client.get(key);
|
||||||
|
} catch (e) {
|
||||||
|
this.handleError(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttlSeconds?: number) {
|
||||||
|
if (!this.ensureClient()) return;
|
||||||
|
try {
|
||||||
|
if (ttlSeconds) {
|
||||||
|
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||||
|
} else {
|
||||||
|
await this.client.set(key, value);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string) {
|
||||||
|
if (!this.ensureClient()) return;
|
||||||
|
try {
|
||||||
|
await this.client.del(key);
|
||||||
|
} catch (e) {
|
||||||
|
this.handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RequestContext {
|
||||||
|
user: any;
|
||||||
|
identity: Record<string, any>;
|
||||||
|
roles: string[]; // role ids
|
||||||
|
permissions: string[]; // permission codes
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { AuthenticationService } from '../src/modules/auth/authentication.service';
|
||||||
|
|
||||||
|
describe('AuthenticationService', () => {
|
||||||
|
let service: AuthenticationService;
|
||||||
|
const mockOidc: any = { verifyToken: jest.fn() };
|
||||||
|
const mockPrisma: any = {};
|
||||||
|
const mockRoleSync: any = { syncUserRolesFromAuthentik: jest.fn() };
|
||||||
|
const mockAuthz: any = { getUserPermissions: jest.fn() };
|
||||||
|
const mockEvents: any = { publish: jest.fn() };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma.user = { findUnique: jest.fn(), create: jest.fn() };
|
||||||
|
mockPrisma.userRole = { findMany: jest.fn() };
|
||||||
|
|
||||||
|
service = new AuthenticationService(mockOidc as any, mockPrisma as any, mockRoleSync as any, mockAuthz as any, mockEvents as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticate creates context on valid token', async () => {
|
||||||
|
const token = 'valid';
|
||||||
|
mockOidc.verifyToken.mockResolvedValue({ sub: 'sub1', email: 'u@example.com', groups: ['RL-Owner'] });
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrisma.user.create.mockResolvedValue({ id: 'uid', authentikId: 'sub1', email: 'u@example.com' });
|
||||||
|
mockRoleSync.syncUserRolesFromAuthentik.mockResolvedValue({ skipped: false, assignedRoleIds: ['r1'] });
|
||||||
|
mockAuthz.getUserPermissions.mockResolvedValue(['users.read']);
|
||||||
|
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||||
|
|
||||||
|
const ctx = await service.authenticate(token);
|
||||||
|
|
||||||
|
expect(ctx.user.id).toBe('uid');
|
||||||
|
expect(ctx.permissions).toContain('users.read');
|
||||||
|
expect(mockEvents.publish).toHaveBeenCalledWith('user.authenticated', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticate throws for missing sub', async () => {
|
||||||
|
mockOidc.verifyToken.mockResolvedValue({ email: 'u@example.com' });
|
||||||
|
await expect(service.authenticate('bad')).rejects.toThrow('Invalid token: missing sub');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { AuthorizationService, PERMISSION_CACHE } from '../src/modules/authorization/authorization.service';
|
||||||
|
|
||||||
|
describe('AuthorizationService', () => {
|
||||||
|
let service: AuthorizationService;
|
||||||
|
const mockPrisma: any = { $queryRaw: jest.fn(), $queryRawUnsafe: jest.fn() };
|
||||||
|
const mockCache: any = { get: jest.fn(), set: jest.fn(), invalidate: jest.fn() };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new AuthorizationService(mockPrisma as any, mockCache as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUserPermissions uses cache when available', async () => {
|
||||||
|
mockCache.get.mockResolvedValue(['users.read']);
|
||||||
|
const perms = await service.getUserPermissions('uid');
|
||||||
|
expect(perms).toEqual(['users.read']);
|
||||||
|
expect(mockCache.get).toHaveBeenCalledWith('uid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUserPermissions queries DB and sets cache when missing', async () => {
|
||||||
|
mockCache.get.mockResolvedValue(null);
|
||||||
|
mockPrisma.$queryRaw = jest.fn().mockResolvedValue([{ code: 'users.read' }]);
|
||||||
|
const perms = await service.getUserPermissions('uid');
|
||||||
|
expect(perms).toEqual(['users.read']);
|
||||||
|
expect(mockCache.set).toHaveBeenCalledWith('uid', ['users.read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasPermission returns correct boolean', async () => {
|
||||||
|
jest.spyOn(service, 'getUserPermissions' as any).mockResolvedValue(['users.read']);
|
||||||
|
const ok = await service.hasPermission('uid', 'users.read');
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const nok = await service.hasPermission('uid', 'users.delete');
|
||||||
|
expect(nok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||||
|
|
||||||
|
describe('RoleSyncService - transaction rollback', () => {
|
||||||
|
let service: RoleSyncService;
|
||||||
|
const mockPrisma: any = {};
|
||||||
|
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||||
|
const mockEvents: any = { publish: jest.fn() };
|
||||||
|
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma.user = { findUnique: jest.fn() };
|
||||||
|
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||||
|
mockPrisma.userRole = { findMany: jest.fn().mockResolvedValue([]) };
|
||||||
|
mockPrisma.user = mockPrisma.user;
|
||||||
|
// simulate transaction throwing
|
||||||
|
mockPrisma.$transaction = jest.fn(async (cb: any) => { throw new Error('tx failed'); });
|
||||||
|
|
||||||
|
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not invalidate cache or publish event when transaction fails', async () => {
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||||
|
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||||
|
|
||||||
|
await expect(service.syncUserRolesFromAuthentik('uid', ['RL-Owner'])).rejects.toThrow('tx failed');
|
||||||
|
|
||||||
|
expect(mockPermissionCache.invalidate).not.toHaveBeenCalled();
|
||||||
|
expect(mockEvents.publish).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { RoleSyncService } from '../src/modules/auth/role-sync.service';
|
||||||
|
|
||||||
|
describe('RoleSyncService', () => {
|
||||||
|
const mockPrisma: any = {};
|
||||||
|
const mockPermissionCache: any = { invalidate: jest.fn() };
|
||||||
|
const mockEvents: any = { publish: jest.fn() };
|
||||||
|
const mockGroupHash: any = { compute: (g: any) => require('crypto').createHash('sha256').update((g||[]).slice().sort().join(','), 'utf8').digest('hex') };
|
||||||
|
let service: RoleSyncService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPrisma.user = { findUnique: jest.fn() };
|
||||||
|
mockPrisma.authGroupRoleMapping = { findMany: jest.fn() };
|
||||||
|
mockPrisma.userRole = { findMany: jest.fn() };
|
||||||
|
mockPrisma.auditLog = { create: jest.fn() };
|
||||||
|
mockPrisma.user = mockPrisma.user;
|
||||||
|
mockPrisma.$transaction = jest.fn(async (cb: any) => {
|
||||||
|
// simulate transaction by calling provided callback with tx = mockPrisma
|
||||||
|
await cb(mockPrisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
service = new RoleSyncService(mockPrisma as any, mockGroupHash as any, mockPermissionCache as any, mockEvents as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeGroupHash consistent and order independent', () => {
|
||||||
|
const a = ['b', 'a', 'c'];
|
||||||
|
const h1 = service.computeGroupHash(a);
|
||||||
|
const h2 = service.computeGroupHash(['a', 'b', 'c']);
|
||||||
|
expect(h1).toBe(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips sync when group hash unchanged', async () => {
|
||||||
|
const groups = ['RL-Owner'];
|
||||||
|
const hash = service.computeGroupHash(groups);
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: hash });
|
||||||
|
|
||||||
|
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||||
|
expect(res.skipped).toBe(true);
|
||||||
|
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('performs transaction and writes audit when changed', async () => {
|
||||||
|
const groups = ['RL-Owner'];
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue({ id: 'uid', lastGroupHash: 'old' });
|
||||||
|
mockPrisma.authGroupRoleMapping.findMany.mockResolvedValue([{ roleId: 'r1' }]);
|
||||||
|
mockPrisma.userRole.findMany.mockResolvedValue([{ roleId: 'r_old' }]);
|
||||||
|
|
||||||
|
// spy on tx ops
|
||||||
|
mockPrisma.userRole.deleteMany = jest.fn();
|
||||||
|
mockPrisma.userRole.createMany = jest.fn();
|
||||||
|
mockPrisma.user.update = jest.fn();
|
||||||
|
|
||||||
|
mockPrisma.auditLog.create = jest.fn();
|
||||||
|
|
||||||
|
const res = await service.syncUserRolesFromAuthentik('uid', groups);
|
||||||
|
|
||||||
|
expect(mockPrisma.$transaction).toHaveBeenCalled();
|
||||||
|
expect(mockPrisma.userRole.deleteMany).toHaveBeenCalledWith({ where: { userId: 'uid', source: 'AUTHENTIK' } });
|
||||||
|
expect(mockPrisma.userRole.createMany).toHaveBeenCalled();
|
||||||
|
expect(mockPrisma.user.update).toHaveBeenCalled();
|
||||||
|
expect(mockPermissionCache.invalidate).toHaveBeenCalledWith('uid');
|
||||||
|
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RolesSynchronized' }));
|
||||||
|
expect(res.skipped).toBe(false);
|
||||||
|
expect(res.assignedRoleIds).toEqual(['r1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { RoleAssignPermissionHandler } from '../src/modules/identity/application/handlers/role/assign-permission.handler';
|
||||||
|
import { RoleRemovePermissionHandler } from '../src/modules/identity/application/handlers/role/remove-permission.handler';
|
||||||
|
|
||||||
|
describe('Role permission handlers', () => {
|
||||||
|
test('assign permission invalidates cache and publishes event', async () => {
|
||||||
|
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1','u2']) };
|
||||||
|
const mockPermRepo: any = { getById: jest.fn() };
|
||||||
|
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||||
|
const mockEvents: any = { publish: jest.fn() };
|
||||||
|
|
||||||
|
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', assignPermission: jest.fn() });
|
||||||
|
mockPermRepo.getById.mockResolvedValue({ id: 'p1' });
|
||||||
|
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||||
|
|
||||||
|
const handler = new RoleAssignPermissionHandler(mockRoleRepo, mockPermRepo, mockAuthz as any, mockEvents as any);
|
||||||
|
|
||||||
|
const res = await handler.execute('r1', 'p1');
|
||||||
|
|
||||||
|
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||||
|
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||||
|
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u2');
|
||||||
|
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove permission invalidates cache and publishes event', async () => {
|
||||||
|
const mockRoleRepo: any = { findById: jest.fn(), update: jest.fn(), getAssignedUserIds: jest.fn().mockResolvedValue(['u1']) };
|
||||||
|
const mockAuthz: any = { invalidateUserPermissions: jest.fn() };
|
||||||
|
const mockEvents: any = { publish: jest.fn() };
|
||||||
|
|
||||||
|
mockRoleRepo.findById.mockResolvedValue({ id: 'r1', removePermission: jest.fn() });
|
||||||
|
mockRoleRepo.update.mockResolvedValue({ id: 'r1' });
|
||||||
|
|
||||||
|
const handler = new RoleRemovePermissionHandler(mockRoleRepo as any, mockAuthz as any, mockEvents as any);
|
||||||
|
|
||||||
|
const res = await handler.execute('r1', 'p1');
|
||||||
|
|
||||||
|
expect(mockRoleRepo.update).toHaveBeenCalled();
|
||||||
|
expect(mockAuthz.invalidateUserPermissions).toHaveBeenCalledWith('u1');
|
||||||
|
expect(mockEvents.publish).toHaveBeenCalledWith(expect.objectContaining({ type: 'RoleUpdated' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { UpdateUserHandler } from '../src/modules/identity/application/handlers/user/update-user.handler';
|
||||||
|
|
||||||
|
describe('UpdateUserHandler', () => {
|
||||||
|
test('rejects password in DTO', async () => {
|
||||||
|
const mockRepo: any = { getById: jest.fn(), update: jest.fn() };
|
||||||
|
mockRepo.getById.mockResolvedValue({ id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() });
|
||||||
|
|
||||||
|
const handler = new UpdateUserHandler(mockRepo as any);
|
||||||
|
|
||||||
|
await expect(handler.execute('u1', { password: 'secret' } as any)).rejects.toThrow('Password management is not allowed.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates allowed fields', async () => {
|
||||||
|
const userObj: any = { id: 'u1', changeUsername: jest.fn(), changeEmail: jest.fn(), setStorageQuota: jest.fn(), setStorageUsed: jest.fn() };
|
||||||
|
const mockRepo: any = { getById: jest.fn().mockResolvedValue(userObj), update: jest.fn().mockResolvedValue(userObj) };
|
||||||
|
|
||||||
|
const handler = new UpdateUserHandler(mockRepo as any);
|
||||||
|
|
||||||
|
const dto = { name: 'New Name', email: 'a@b.com', storageQuota: 1000, storageUsed: 10 } as any;
|
||||||
|
|
||||||
|
const res = await handler.execute('u1', dto);
|
||||||
|
|
||||||
|
expect(userObj.changeUsername).toHaveBeenCalledWith('New Name');
|
||||||
|
expect(userObj.changeEmail).toHaveBeenCalledWith('a@b.com');
|
||||||
|
expect(userObj.setStorageQuota).toHaveBeenCalledWith(1000);
|
||||||
|
expect(userObj.setStorageUsed).toHaveBeenCalledWith(10);
|
||||||
|
expect(mockRepo.update).toHaveBeenCalledWith(userObj);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user