@volcanicminds/backend 2.3.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +221 -28
  2. package/dist/index.d.ts +3 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +24 -36
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
  7. package/dist/lib/api/auth/controller/auth.js +14 -7
  8. package/dist/lib/api/auth/controller/auth.js.map +1 -1
  9. package/dist/lib/api/health/controller/health.d.ts +1 -1
  10. package/dist/lib/api/health/controller/health.d.ts.map +1 -1
  11. package/dist/lib/api/health/controller/health.js +1 -1
  12. package/dist/lib/api/health/controller/health.js.map +1 -1
  13. package/dist/lib/api/health/routes.d.ts +1 -0
  14. package/dist/lib/api/health/routes.d.ts.map +1 -1
  15. package/dist/lib/api/health/routes.js +1 -0
  16. package/dist/lib/api/health/routes.js.map +1 -1
  17. package/dist/lib/api/tenants/controller/tenants.d.ts +17 -0
  18. package/dist/lib/api/tenants/controller/tenants.d.ts.map +1 -0
  19. package/dist/lib/api/tenants/controller/tenants.js +150 -0
  20. package/dist/lib/api/tenants/controller/tenants.js.map +1 -0
  21. package/dist/lib/api/tenants/routes.d.ts +167 -0
  22. package/dist/lib/api/tenants/routes.d.ts.map +1 -0
  23. package/dist/lib/api/tenants/routes.js +143 -0
  24. package/dist/lib/api/tenants/routes.js.map +1 -0
  25. package/dist/lib/config/plugins.js +1 -1
  26. package/dist/lib/config/plugins.js.map +1 -1
  27. package/dist/lib/database/typeorm/entities/change.d.ts +11 -0
  28. package/dist/lib/database/typeorm/entities/change.d.ts.map +1 -0
  29. package/dist/lib/database/typeorm/entities/change.js +4 -0
  30. package/dist/lib/database/typeorm/entities/change.js.map +1 -0
  31. package/dist/lib/database/typeorm/entities/tenant.d.ts +13 -0
  32. package/dist/lib/database/typeorm/entities/tenant.d.ts.map +1 -0
  33. package/dist/lib/database/typeorm/entities/tenant.js +4 -0
  34. package/dist/lib/database/typeorm/entities/tenant.js.map +1 -0
  35. package/dist/lib/database/typeorm/entities/token.d.ts +16 -0
  36. package/dist/lib/database/typeorm/entities/token.d.ts.map +1 -0
  37. package/dist/lib/database/typeorm/entities/token.js +4 -0
  38. package/dist/lib/database/typeorm/entities/token.js.map +1 -0
  39. package/dist/lib/database/typeorm/entities/user.d.ts +28 -0
  40. package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -0
  41. package/dist/lib/database/typeorm/entities/user.js +4 -0
  42. package/dist/lib/database/typeorm/entities/user.js.map +1 -0
  43. package/dist/lib/database/typeorm/loader/dataBaseManager.d.ts +6 -0
  44. package/dist/lib/database/typeorm/loader/dataBaseManager.d.ts.map +1 -0
  45. package/dist/lib/database/typeorm/loader/dataBaseManager.js +73 -0
  46. package/dist/lib/database/typeorm/loader/dataBaseManager.js.map +1 -0
  47. package/dist/lib/database/typeorm/loader/entities.d.ts +6 -0
  48. package/dist/lib/database/typeorm/loader/entities.d.ts.map +1 -0
  49. package/dist/lib/database/typeorm/loader/entities.js +37 -0
  50. package/dist/lib/database/typeorm/loader/entities.js.map +1 -0
  51. package/dist/lib/database/typeorm/loader/tenantManager.d.ts +17 -0
  52. package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -0
  53. package/dist/lib/database/typeorm/loader/tenantManager.js +193 -0
  54. package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -0
  55. package/dist/lib/database/typeorm/loader/tokenManager.d.ts +23 -0
  56. package/dist/lib/database/typeorm/loader/tokenManager.d.ts.map +1 -0
  57. package/dist/lib/database/typeorm/loader/tokenManager.js +131 -0
  58. package/dist/lib/database/typeorm/loader/tokenManager.js.map +1 -0
  59. package/dist/lib/database/typeorm/loader/userManager.d.ts +39 -0
  60. package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -0
  61. package/dist/lib/database/typeorm/loader/userManager.js +358 -0
  62. package/dist/lib/database/typeorm/loader/userManager.js.map +1 -0
  63. package/dist/lib/database/typeorm/query/builder.d.ts +2 -0
  64. package/dist/lib/database/typeorm/query/builder.d.ts.map +1 -0
  65. package/dist/lib/database/typeorm/query/builder.js +33 -0
  66. package/dist/lib/database/typeorm/query/builder.js.map +1 -0
  67. package/dist/lib/database/typeorm/query/parser.d.ts +2 -0
  68. package/dist/lib/database/typeorm/query/parser.d.ts.map +1 -0
  69. package/dist/lib/database/typeorm/query/parser.js +27 -0
  70. package/dist/lib/database/typeorm/query/parser.js.map +1 -0
  71. package/dist/lib/database/typeorm/query.d.ts +36 -0
  72. package/dist/lib/database/typeorm/query.d.ts.map +1 -0
  73. package/dist/lib/database/typeorm/query.js +259 -0
  74. package/dist/lib/database/typeorm/query.js.map +1 -0
  75. package/dist/lib/database/typeorm/util/crypto.d.ts +3 -0
  76. package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -0
  77. package/dist/lib/database/typeorm/util/crypto.js +46 -0
  78. package/dist/lib/database/typeorm/util/crypto.js.map +1 -0
  79. package/dist/lib/database/typeorm/util/error.d.ts +5 -0
  80. package/dist/lib/database/typeorm/util/error.d.ts.map +1 -0
  81. package/dist/lib/database/typeorm/util/error.js +8 -0
  82. package/dist/lib/database/typeorm/util/error.js.map +1 -0
  83. package/dist/lib/database/typeorm/util/logger.d.ts +7 -0
  84. package/dist/lib/database/typeorm/util/logger.d.ts.map +1 -0
  85. package/dist/lib/database/typeorm/util/logger.js +31 -0
  86. package/dist/lib/database/typeorm/util/logger.js.map +1 -0
  87. package/dist/lib/database/typeorm/util/yn.d.ts +2 -0
  88. package/dist/lib/database/typeorm/util/yn.d.ts.map +1 -0
  89. package/dist/lib/database/typeorm/util/yn.js +15 -0
  90. package/dist/lib/database/typeorm/util/yn.js.map +1 -0
  91. package/dist/lib/defaults/managers.d.ts.map +1 -1
  92. package/dist/lib/defaults/managers.js +186 -58
  93. package/dist/lib/defaults/managers.js.map +1 -1
  94. package/dist/lib/hooks/onRequest.d.ts.map +1 -1
  95. package/dist/lib/hooks/onRequest.js +11 -48
  96. package/dist/lib/hooks/onRequest.js.map +1 -1
  97. package/dist/lib/loader/router.d.ts.map +1 -1
  98. package/dist/lib/loader/router.js +92 -83
  99. package/dist/lib/loader/router.js.map +1 -1
  100. package/dist/lib/loader/tenant.d.ts.map +1 -1
  101. package/dist/lib/loader/tenant.js +18 -1
  102. package/dist/lib/loader/tenant.js.map +1 -1
  103. package/dist/lib/locales/en.json +12 -0
  104. package/dist/lib/locales/it.json +12 -0
  105. package/dist/lib/schemas/tenant.d.ts +88 -0
  106. package/dist/lib/schemas/tenant.d.ts.map +1 -0
  107. package/dist/lib/schemas/tenant.js +45 -0
  108. package/dist/lib/schemas/tenant.js.map +1 -0
  109. package/dist/lib/util/regexp.d.ts +2 -0
  110. package/dist/lib/util/regexp.d.ts.map +1 -1
  111. package/dist/lib/util/regexp.js +5 -3
  112. package/dist/lib/util/regexp.js.map +1 -1
  113. package/dist/lib/util/secret.d.ts +11 -0
  114. package/dist/lib/util/secret.d.ts.map +1 -0
  115. package/dist/lib/util/secret.js +70 -0
  116. package/dist/lib/util/secret.js.map +1 -0
  117. package/dist/server.js +4 -1
  118. package/dist/server.js.map +1 -1
  119. package/dist/typeorm.d.ts +15 -0
  120. package/dist/typeorm.d.ts.map +1 -0
  121. package/dist/typeorm.js +86 -0
  122. package/dist/typeorm.js.map +1 -0
  123. package/dist/types/database/typeorm/global.d.ts +56 -0
  124. package/dist/types/database/typeorm/global.d.ts.map +1 -0
  125. package/dist/types/database/typeorm/global.js +2 -0
  126. package/dist/types/database/typeorm/global.js.map +1 -0
  127. package/dist/types/global.d.ts +318 -0
  128. package/dist/types/orm.d.ts +22 -0
  129. package/lib/api/auth/controller/auth.ts +28 -12
  130. package/lib/api/health/controller/health.ts +1 -1
  131. package/lib/api/health/routes.ts +1 -0
  132. package/lib/api/tenants/controller/tenants.ts +211 -0
  133. package/lib/api/tenants/routes.ts +145 -0
  134. package/lib/config/plugins.ts +1 -1
  135. package/lib/database/typeorm/entities/change.ts +11 -0
  136. package/lib/database/typeorm/entities/tenant.ts +20 -0
  137. package/lib/database/typeorm/entities/token.ts +18 -0
  138. package/lib/database/typeorm/entities/user.ts +33 -0
  139. package/lib/database/typeorm/loader/dataBaseManager.ts +89 -0
  140. package/lib/database/typeorm/loader/entities.ts +47 -0
  141. package/lib/database/typeorm/loader/tenantManager.ts +260 -0
  142. package/lib/database/typeorm/loader/tokenManager.ts +157 -0
  143. package/lib/database/typeorm/loader/userManager.ts +432 -0
  144. package/lib/database/typeorm/query/builder.ts +39 -0
  145. package/lib/database/typeorm/query/parser.ts +29 -0
  146. package/lib/database/typeorm/query.ts +326 -0
  147. package/lib/database/typeorm/util/crypto.ts +52 -0
  148. package/lib/database/typeorm/util/error.ts +7 -0
  149. package/lib/database/typeorm/util/logger.ts +35 -0
  150. package/lib/database/typeorm/util/yn.ts +20 -0
  151. package/lib/defaults/managers.ts +191 -64
  152. package/lib/hooks/onRequest.ts +12 -64
  153. package/lib/loader/router.ts +133 -110
  154. package/lib/loader/tenant.ts +29 -13
  155. package/lib/schemas/tenant.ts +49 -0
  156. package/lib/util/regexp.ts +31 -8
  157. package/lib/util/secret.ts +113 -0
  158. package/package.json +54 -13
  159. package/dist/lib/apollo/context.d.ts +0 -6
  160. package/dist/lib/apollo/context.d.ts.map +0 -1
  161. package/dist/lib/apollo/context.js +0 -5
  162. package/dist/lib/apollo/context.js.map +0 -1
  163. package/dist/lib/apollo/resolvers.d.ts +0 -8
  164. package/dist/lib/apollo/resolvers.d.ts.map +0 -1
  165. package/dist/lib/apollo/resolvers.js +0 -8
  166. package/dist/lib/apollo/resolvers.js.map +0 -1
  167. package/dist/lib/apollo/type-defs.d.ts +0 -3
  168. package/dist/lib/apollo/type-defs.d.ts.map +0 -1
  169. package/dist/lib/apollo/type-defs.js +0 -8
  170. package/dist/lib/apollo/type-defs.js.map +0 -1
  171. package/lib/apollo/context.ts +0 -11
  172. package/lib/apollo/resolvers.ts +0 -12
  173. package/lib/apollo/type-defs.ts +0 -9
@@ -0,0 +1,145 @@
1
+ const { multi_tenant } = global.config?.options || {}
2
+ const isEnabled = multi_tenant?.enabled || false
3
+
4
+ export default {
5
+ config: {
6
+ title: 'Tenant Management',
7
+ description: 'Administration of tenants (global scope)',
8
+ controller: 'controller',
9
+ enable: isEnabled,
10
+ tenantContext: false, // Critical: Operate on global scope (public schema), bypass tenant context
11
+ tags: ['tenants']
12
+ },
13
+ routes: [
14
+ {
15
+ method: 'GET',
16
+ path: '/',
17
+ roles: [roles.admin],
18
+ handler: 'tenants.list',
19
+ middlewares: ['global.isAuthenticated'],
20
+ config: {
21
+ title: 'List Tenants',
22
+ description: 'Retrieve all tenants.',
23
+ response: {
24
+ 200: { $ref: 'tenantListResponseSchema#' }
25
+ }
26
+ }
27
+ },
28
+ {
29
+ method: 'POST',
30
+ path: '/',
31
+ roles: [roles.admin],
32
+ handler: 'tenants.create',
33
+ middlewares: ['global.isAuthenticated'],
34
+ config: {
35
+ title: 'Create Tenant',
36
+ description: 'Create a new tenant with its own schema.',
37
+ body: { $ref: 'tenantBodySchema#' },
38
+ response: {
39
+ 201: { $ref: 'tenantResponseSchema#' }
40
+ }
41
+ }
42
+ },
43
+ {
44
+ method: 'GET',
45
+ path: '/:id',
46
+ roles: [roles.admin],
47
+ handler: 'tenants.findOne',
48
+ middlewares: ['global.isAuthenticated'],
49
+ config: {
50
+ title: 'Get Tenant',
51
+ description: 'Retrieve a single tenant by ID.',
52
+ params: { $ref: 'globalParamsSchema#' },
53
+ response: {
54
+ 200: { $ref: 'tenantResponseSchema#' }
55
+ }
56
+ }
57
+ },
58
+ {
59
+ method: 'PUT',
60
+ path: '/:id',
61
+ roles: [roles.admin],
62
+ handler: 'tenants.update',
63
+ middlewares: ['global.isAuthenticated'],
64
+ config: {
65
+ title: 'Update Tenant',
66
+ description: 'Update an existing tenant.',
67
+ params: { $ref: 'globalParamsSchema#' },
68
+ body: { $ref: 'tenantUpdateBodySchema#' },
69
+ response: {
70
+ 200: { $ref: 'tenantResponseSchema#' }
71
+ }
72
+ }
73
+ },
74
+ {
75
+ method: 'DELETE',
76
+ path: '/:id',
77
+ roles: [roles.admin],
78
+ handler: 'tenants.remove',
79
+ middlewares: ['global.isAuthenticated'],
80
+ config: {
81
+ title: 'Archive Tenant',
82
+ description: 'Soft delete (archive) a tenant.',
83
+ params: { $ref: 'globalParamsSchema#' },
84
+ response: {
85
+ 200: { $ref: 'defaultResponse#' }
86
+ }
87
+ }
88
+ },
89
+ {
90
+ method: 'POST',
91
+ path: '/:id/restore',
92
+ roles: [roles.admin],
93
+ handler: 'tenants.restore',
94
+ middlewares: ['global.isAuthenticated'],
95
+ config: {
96
+ title: 'Restore Tenant',
97
+ description: 'Restore a soft-deleted tenant.',
98
+ params: { $ref: 'globalParamsSchema#' },
99
+ response: {
100
+ 200: { $ref: 'defaultResponse#' }
101
+ }
102
+ }
103
+ },
104
+ {
105
+ method: 'POST',
106
+ path: '/impersonate',
107
+ roles: [roles.admin],
108
+ handler: 'tenants.impersonate',
109
+ middlewares: ['global.isAuthenticated'],
110
+ config: {
111
+ title: 'Impersonate User',
112
+ description:
113
+ 'Generate an impersonation token for a specific user in a target tenant (System Admin or Tenant Admin).',
114
+ tenantContext: true, // Enable tenant context to allow Tenant Admins to be authenticated
115
+ // Optional: Define body schema for documentation
116
+ body: {
117
+ type: 'object',
118
+ properties: {
119
+ targetTenantSlug: { type: 'string' },
120
+ targetTenantId: { type: 'string' },
121
+ targetRole: { type: 'string' },
122
+ targetUserEmail: { type: 'string' },
123
+ targetUserId: { type: 'string' }
124
+ }
125
+ },
126
+ response: {
127
+ 200: {
128
+ type: 'object',
129
+ properties: {
130
+ token: { type: 'string' },
131
+ expiresAt: { type: 'string', format: 'date-time' },
132
+ impersonatedUser: {
133
+ type: 'object',
134
+ properties: {
135
+ email: { type: 'string' },
136
+ id: { type: 'string' }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ]
145
+ }
@@ -44,7 +44,7 @@ export default [
44
44
  },
45
45
  {
46
46
  name: 'helmet',
47
- enable: false,
47
+ enable: true,
48
48
  options: {}
49
49
  },
50
50
  {
@@ -0,0 +1,11 @@
1
+ import { BaseEntity } from 'typeorm'
2
+
3
+ export abstract class Change extends BaseEntity {
4
+ abstract createdAt: Date
5
+ abstract updatedAt: Date
6
+ abstract userId: string
7
+ abstract status: string
8
+ abstract entityName: string
9
+ abstract entityId: string
10
+ abstract contents: object
11
+ }
@@ -0,0 +1,20 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { BaseEntity } from 'typeorm'
3
+
4
+ export abstract class Tenant extends BaseEntity {
5
+ abstract id: any
6
+ abstract name: string
7
+ abstract slug: string
8
+
9
+ // Campi specifici per strategia
10
+ abstract dbSchema?: string // Per Postgres (schema splitting)
11
+ abstract dbName?: string // Per Mongo (database splitting, opzionale)
12
+
13
+ abstract status: 'active' | 'suspended' | 'archived'
14
+
15
+ abstract createdAt: Date
16
+ abstract updatedAt: Date
17
+
18
+ // Configurazione opzionale
19
+ abstract getConfig(): Record<string, any>
20
+ }
@@ -0,0 +1,18 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { BaseEntity } from 'typeorm'
3
+
4
+ export abstract class Token extends BaseEntity {
5
+ // abstract id: any
6
+ abstract externalId: string
7
+ abstract name: string
8
+ abstract description: string
9
+ abstract blocked: boolean
10
+ abstract blockedReason: string
11
+ abstract blockedAt: Date
12
+ abstract roles: string[]
13
+ abstract createdAt: Date
14
+ abstract updatedAt: Date
15
+ abstract deletedAt: Date
16
+ abstract setId(id: any)
17
+ abstract getId(): any
18
+ }
@@ -0,0 +1,33 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { BaseEntity } from 'typeorm'
3
+
4
+ export abstract class User extends BaseEntity {
5
+ // abstract id: any
6
+ abstract externalId: string
7
+ abstract username: string
8
+ abstract email: string
9
+ abstract password: string
10
+ abstract confirmed: boolean
11
+ abstract confirmedAt: Date
12
+ abstract passwordChangedAt: Date
13
+ abstract blocked: boolean
14
+ abstract blockedReason: string
15
+ abstract blockedAt: Date
16
+ abstract resetPasswordToken: string
17
+ abstract resetPasswordTokenAt: Date
18
+ abstract confirmationToken: string
19
+ abstract roles: string[]
20
+ abstract version: number
21
+ abstract createdAt: Date
22
+ abstract updatedAt: Date
23
+ abstract deletedAt: Date
24
+
25
+ // MFA Fields
26
+ abstract mfaEnabled: boolean
27
+ abstract mfaSecret: string
28
+ abstract mfaType: string
29
+ abstract mfaRecoveryCodes: string[]
30
+
31
+ abstract setId(id: any): void
32
+ abstract getId(): any
33
+ }
@@ -0,0 +1,89 @@
1
+ import { QueryRunner, EntityManager } from 'typeorm'
2
+ import * as log from '../util/logger.js'
3
+
4
+ export function isImplemented() {
5
+ return true
6
+ }
7
+
8
+ export async function synchronizeSchemas(runner?: QueryRunner) {
9
+ try {
10
+ if (runner) {
11
+ await runner.connection.synchronize()
12
+ } else {
13
+ await global.connection.synchronize()
14
+ }
15
+ return true
16
+ } catch (error) {
17
+ log.error(`Volcanic-TypeORM: ${error}`)
18
+ throw error
19
+ }
20
+ }
21
+
22
+ export async function retrieveBy(entityName: string, entityId: string, context?: QueryRunner | EntityManager) {
23
+ try {
24
+ const { multi_tenant } = (global as any).config?.options || {}
25
+
26
+ if (multi_tenant?.enabled && !context) {
27
+ const errorMsg =
28
+ '[DataBaseManager] ⛔️ CRITICAL: Missing DB Context (Runner/Manager) in Multi-Tenant Environment! Implicit fallback to public schema forbidden.'
29
+ log.error(errorMsg)
30
+ throw new Error(errorMsg)
31
+ }
32
+
33
+ if (context) {
34
+ // Handle QueryRunner logic
35
+ if ((context as QueryRunner).manager) {
36
+ return await (context as QueryRunner).manager.findOneBy(entityName, { id: entityId })
37
+ }
38
+ // Handle EntityManager logic
39
+ return await (context as EntityManager).findOneBy(entityName, { id: entityId })
40
+ }
41
+ return await global.connection.getRepository(global.entity[entityName]).findOneBy({ id: entityId })
42
+ } catch (error) {
43
+ if (!(entityName in global.entity)) {
44
+ log.error(`Volcanic-TypeORM: ${entityName} not found in global.entity`)
45
+ }
46
+ throw error
47
+ }
48
+ }
49
+
50
+ export async function addChange(
51
+ entityName: string,
52
+ entityId: string,
53
+ status: string,
54
+ userId: string,
55
+ contents: unknown,
56
+ changeEntity = 'Change',
57
+ context?: QueryRunner | EntityManager
58
+ ) {
59
+ try {
60
+ const { multi_tenant } = (global as any).config?.options || {}
61
+
62
+ if (multi_tenant?.enabled && !context) {
63
+ const errorMsg =
64
+ '[DataBaseManager] ⛔️ CRITICAL: Missing DB Context (Runner/Manager) in Multi-Tenant Environment! Implicit fallback to public schema forbidden.'
65
+ log.error(errorMsg)
66
+ throw new Error(errorMsg)
67
+ }
68
+
69
+ if (context) {
70
+ let repo
71
+ if ((context as QueryRunner).manager) {
72
+ repo = (context as QueryRunner).manager.getRepository(changeEntity)
73
+ } else {
74
+ repo = (context as EntityManager).getRepository(changeEntity)
75
+ }
76
+
77
+ const newChange = repo.create({ entityName, entityId, status, userId, contents })
78
+ return repo.save(newChange)
79
+ }
80
+ const repo = global.connection.getRepository(global.entity[changeEntity])
81
+ const newChange = repo.create({ entityName, entityId, status, userId, contents })
82
+ return repo.save(newChange)
83
+ } catch (error) {
84
+ if (!(changeEntity in global.entity)) {
85
+ log.error(`Volcanic-TypeORM: ${changeEntity} not found in global.entity`)
86
+ }
87
+ throw error
88
+ }
89
+ }
@@ -0,0 +1,47 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import * as path from 'path'
3
+ import { globSync } from 'glob'
4
+ import pluralize from 'pluralize'
5
+ import { fileURLToPath, pathToFileURL } from 'url'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const __dirname = path.dirname(__filename)
9
+
10
+ export async function load() {
11
+ const classes: any = {}
12
+ const repositories: any = {}
13
+ const entities: any[] = []
14
+
15
+ const patterns = [
16
+ path.join(__dirname, '..', 'entities', '*.e.{ts,js}'),
17
+ path.join(process.cwd(), 'src', 'entities', '*.e.{ts,js}'),
18
+ path.join(process.cwd(), 'dist', 'src', 'entities', '*.e.{ts,js}')
19
+ ]
20
+
21
+ for (const pattern of patterns) {
22
+ const files = globSync(pattern, { nodir: true, windowsPathsNoEscape: true })
23
+
24
+ const modules = await Promise.all(
25
+ files.map((f) => {
26
+ const fileUrl = pathToFileURL(f).href
27
+ return import(fileUrl)
28
+ })
29
+ )
30
+
31
+ for (const entityModule of modules) {
32
+ const entityClass = entityModule.default || entityModule
33
+ const entityNames = Object.keys(entityClass)
34
+
35
+ entityNames.forEach((name) => {
36
+ const entity = entityClass[name]
37
+ if (typeof entity === 'function') {
38
+ classes[name] = entity
39
+ repositories[pluralize(name.toLowerCase())] = entity
40
+ entities.push(entity)
41
+ }
42
+ })
43
+ }
44
+ }
45
+
46
+ return { classes, repositories, entities }
47
+ }
@@ -0,0 +1,260 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { DataSource, EntityManager } from 'typeorm'
3
+ import { Tenant } from '../entities/tenant.js'
4
+
5
+ export class TenantManager {
6
+ constructor(private dataSource: DataSource) {}
7
+
8
+ isImplemented() {
9
+ return true
10
+ }
11
+
12
+ async resolveTenant(req: any): Promise<Tenant | null> {
13
+ const { multi_tenant } = (global as any).config?.options || {}
14
+ const headerKey = multi_tenant?.header_key || 'x-tenant-id'
15
+
16
+ // 1. Single Tenant Mode (Default)
17
+ if (!multi_tenant?.enabled) {
18
+ return null
19
+ }
20
+
21
+ // 2. Multi-Tenant Mode: Extract Identifiers
22
+ // Parse JWT from headers if not already attached (Fastify might attach it to req.user)
23
+ const jwtTid = req.user?.tid
24
+ const headerTid = req.headers[headerKey]
25
+
26
+ // 3. Validation Logic (Strict)
27
+ // Rule A: Header is MANDATORY in Multi-Tenant
28
+ if (!headerTid) {
29
+ // Log debug and return null to let the caller handle 404/403
30
+ if ((global as any).log?.d) (global as any).log.debug('[TenantManager] No tenant header found')
31
+ return null
32
+ }
33
+
34
+ // Rule B: JWT is OPTIONAL (For Public Routes/Login).
35
+ // Rule C: If the JWT carries a tenant binding, the header MUST match it.
36
+ // A token bound to tenant X requesting tenant Y via header is a tenant-isolation
37
+ // bypass (IDOR) attempt — REJECT it, do not merely warn. If a privileged
38
+ // System-Admin impersonation flow is ever required, gate it HERE behind an
39
+ // explicit, verified role check on `req.user` (e.g. a dedicated `system_admin`
40
+ // role) — never relax this default.
41
+ if (jwtTid && jwtTid !== headerTid) {
42
+ if ((global as any).log?.w)
43
+ (global as any).log.warn(`[TenantManager] Tenant spoofing blocked: Token(${jwtTid}) vs Header(${headerTid})`)
44
+ return null
45
+ }
46
+
47
+ // Prefer the JWT-bound tenant when present; fall back to the header (public/login routes).
48
+ const tenantId = jwtTid || headerTid
49
+
50
+ // 5. Database Lookup
51
+ const tenantRepo = this.dataSource.getRepository('Tenant')
52
+
53
+ // Support lookup by ID or SLUG
54
+ const tenant = (await tenantRepo.findOne({
55
+ where: [{ slug: tenantId }]
56
+ })) as unknown as Tenant
57
+
58
+ if (!tenant) {
59
+ if ((global as any).log?.d) (global as any).log.debug(`[TenantManager] Tenant ${tenantId} not found in DB`)
60
+ return null
61
+ }
62
+
63
+ if (tenant.status !== 'active') {
64
+ if ((global as any).log?.d) (global as any).log.debug(`[TenantManager] Tenant ${tenantId} is not active`)
65
+ return null
66
+ }
67
+
68
+ return tenant
69
+ }
70
+
71
+ /**
72
+ * Switches the database context to the specific tenant schema.
73
+ * CRITICAL SECURITY: This method MUST receive an EntityManager (db) when using Postgres Multi-Tenancy.
74
+ * Modifying the global search_path is strictly forbidden to prevent data leaks.
75
+ */
76
+ async switchContext(tenant: Tenant, db?: EntityManager) {
77
+ const driver = this.dataSource.driver.options.type
78
+
79
+ if (driver === 'postgres') {
80
+ const schema = tenant.dbSchema || 'public'
81
+ const safeSchema = schema.replace(/[^a-z0-9_]/gi, '')
82
+
83
+ if (db) {
84
+ if ((global as any).log?.t)
85
+ (global as any).log.trace(`[TenantManager] Context-Aware Switch Schema to: ${safeSchema}`)
86
+ // Execute SET search_path only on the specific transactional QueryRunner
87
+ await db.query(`SET search_path TO "${safeSchema}", public`)
88
+ } else {
89
+ // STRICT SECURITY CHECK
90
+ // If no db (EntityManager) is provided, we used to fallback to global dataSource.
91
+ // This is now FORBIDDEN as it poisons the connection pool.
92
+ const errorMsg =
93
+ '[TenantManager] ⛔️ CRITICAL: Attempted UNSAFE global context switch without QueryRunner. This operation has been blocked to prevent data leaks.'
94
+ if ((global as any).log?.f) (global as any).log.fatal(errorMsg)
95
+ throw new Error(errorMsg)
96
+ }
97
+ } else if (driver === 'mongodb') {
98
+ // Mongo implementation stub
99
+ if ((global as any).log?.w)
100
+ (global as any).log.warn('[TenantManager] Mongo Multi-Tenancy context switch is not enforcing isolation yet.')
101
+ }
102
+ }
103
+
104
+ async createTenant(data: any): Promise<Tenant> {
105
+ const driver = this.dataSource.driver.options.type
106
+ const repo = this.dataSource.getRepository('Tenant')
107
+
108
+ // Data validation should be handled by Schema validation in API
109
+ // Enforce lowercase slug
110
+ if (data.slug) data.slug = data.slug.toLowerCase()
111
+
112
+ // Check existence
113
+ const existing = await repo.findOne({
114
+ where: [{ slug: data.slug }, { dbSchema: data.dbSchema }]
115
+ })
116
+ if (existing) {
117
+ throw new Error('Tenant with this slug or dbSchema already exists')
118
+ }
119
+
120
+ const tenant = repo.create({
121
+ ...data,
122
+ status: 'active'
123
+ })
124
+ const savedTenant = (await repo.save(tenant)) as any as Tenant
125
+
126
+ if (driver === 'postgres') {
127
+ const schema = data.dbSchema
128
+ if (!schema) throw new Error('dbSchema is required for Postgres Multi-Tenancy')
129
+
130
+ const safeSchema = schema.replace(/[^a-z0-9_]/gi, '')
131
+ const qr = this.dataSource.createQueryRunner()
132
+ await qr.connect()
133
+ try {
134
+ await qr.query(`CREATE SCHEMA IF NOT EXISTS "${safeSchema}"`)
135
+
136
+ // --- Schema Synchronization ---
137
+ // We create an ephemeral DataSource to force TypeORM to synchronize only the specific schema.
138
+ // This is necessary because the global DataSource is bound to the public/default schema.
139
+ if ((global as any).log?.i)
140
+ (global as any).log.info(`[TenantManager] Synchronizing tables for schema: ${safeSchema}`)
141
+
142
+ const tenantDs = new DataSource({
143
+ ...(this.dataSource.options as any),
144
+ schema: safeSchema,
145
+ synchronize: true,
146
+ name: `sync_${safeSchema}_${Date.now()}`
147
+ })
148
+ await tenantDs.initialize()
149
+ await tenantDs.destroy()
150
+
151
+ if ((global as any).log?.i)
152
+ (global as any).log.info(`[TenantManager] Schema ${safeSchema} synchronized successfully`)
153
+
154
+ await qr.startTransaction()
155
+
156
+ // Context Switch within the same transaction to seed the user
157
+ // Use SET LOCAL so it automatically reverts at the end of the transaction
158
+ await qr.query(`SET LOCAL search_path TO "${safeSchema}", public`)
159
+
160
+ if (data.adminEmail && data.adminPassword) {
161
+ if ((global as any).log?.i) (global as any).log.info(`[TenantManager] Seeding Admin User: ${data.adminEmail}`)
162
+ const { createUser } = await import('./userManager.js')
163
+
164
+ await createUser(
165
+ {
166
+ email: data.adminEmail,
167
+ password: data.adminPassword,
168
+ roles: ['admin'],
169
+ firstName: data.adminFirstName || 'Tenant',
170
+ lastName: data.adminLastName || 'Admin'
171
+ } as any,
172
+ qr
173
+ )
174
+ }
175
+
176
+ await qr.commitTransaction()
177
+ if ((global as any).log?.i) (global as any).log.info(`[TenantManager] Created Schema & Admin: ${safeSchema}`)
178
+ } catch (err) {
179
+ if (qr.isTransactionActive) {
180
+ await qr.rollbackTransaction()
181
+ }
182
+ throw err
183
+ } finally {
184
+ try {
185
+ // Reset to public for safety before releasing
186
+ await qr.query(`SET search_path TO public`)
187
+ } catch (cleanupErr) {
188
+ if ((global as any).log?.e)
189
+ (global as any).log.error(`[TenantManager] Failed to reset search_path: ${cleanupErr}`)
190
+ }
191
+ await qr.release()
192
+ }
193
+ } else {
194
+ // Mongo or others
195
+ if ((global as any).log?.i) (global as any).log.info(`[TenantManager] Created Tenant Record for ${driver}`)
196
+ }
197
+
198
+ return savedTenant
199
+ }
200
+
201
+ async deleteTenant(id: string): Promise<void> {
202
+ const repo = this.dataSource.getRepository('Tenant')
203
+ await repo.softDelete(id)
204
+ }
205
+
206
+ async restoreTenant(id: string) {
207
+ const repo = this.dataSource.getRepository('Tenant')
208
+ await repo.restore(id)
209
+ return repo.findOneBy({ id })
210
+ }
211
+
212
+ async getTenant(id: string) {
213
+ const repo = this.dataSource.getRepository('Tenant')
214
+ return (await repo.findOneBy({ id })) as unknown as Tenant
215
+ }
216
+
217
+ async updateTenant(id: string, data: Partial<Tenant>) {
218
+ const repo = this.dataSource.getRepository('Tenant')
219
+ // Exclude dbSchema from updates as it requires migration of tables
220
+
221
+ const { dbSchema: _ignore, ...updateData } = data
222
+ await repo.update(id, updateData as any)
223
+ return repo.findOneBy({ id })
224
+ }
225
+
226
+ async listTenants() {
227
+ const repo = this.dataSource.getRepository('Tenant')
228
+ return repo.find({ order: { createdAt: 'DESC' } as any })
229
+ }
230
+
231
+ /**
232
+ * Safe utility to execute a function within a specific Tenant Context (e.g. for Background Jobs or System Admin tasks).
233
+ * This handles the creation, connection, context switch, and release of a QueryRunner automatically.
234
+ */
235
+ async runInTenantContext<T>(tenantId: string, callback: (em: EntityManager) => Promise<T>): Promise<T> {
236
+ const tenant = await this.getTenant(tenantId)
237
+ if (!tenant) throw new Error(`Tenant ${tenantId} not found`)
238
+ if (tenant.status !== 'active') throw new Error(`Tenant ${tenantId} is not active`)
239
+
240
+ const qr = this.dataSource.createQueryRunner()
241
+ await qr.connect()
242
+
243
+ try {
244
+ // Enforce context switch on this specific runner
245
+ await this.switchContext(tenant, qr.manager)
246
+
247
+ // Execute callback with the isolated EntityManager
248
+ const result = await callback(qr.manager)
249
+ return result
250
+ } finally {
251
+ try {
252
+ await qr.query('SET search_path TO public')
253
+ } catch (e) {
254
+ if ((global as any).log?.w)
255
+ (global as any).log.warn(`[TenantManager] Failed to reset search_path context: ${e}`)
256
+ }
257
+ await qr.release()
258
+ }
259
+ }
260
+ }