@volcanicminds/backend 3.0.1 → 3.1.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 (102) hide show
  1. package/README.md +128 -27
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +13 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/api/auth/controller/auth.d.ts +1 -1
  6. package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
  7. package/dist/lib/api/auth/controller/auth.js +90 -66
  8. package/dist/lib/api/auth/controller/auth.js.map +1 -1
  9. package/dist/lib/api/auth/routes.d.ts +29 -0
  10. package/dist/lib/api/auth/routes.d.ts.map +1 -1
  11. package/dist/lib/api/auth/routes.js +10 -0
  12. package/dist/lib/api/auth/routes.js.map +1 -1
  13. package/dist/lib/api/token/controller/token.js +17 -17
  14. package/dist/lib/api/token/controller/token.js.map +1 -1
  15. package/dist/lib/api/users/controller/user.d.ts.map +1 -1
  16. package/dist/lib/api/users/controller/user.js +25 -19
  17. package/dist/lib/api/users/controller/user.js.map +1 -1
  18. package/dist/lib/api/users/routes.d.ts.map +1 -1
  19. package/dist/lib/api/users/routes.js.map +1 -1
  20. package/dist/lib/config/plugins.d.ts +17 -0
  21. package/dist/lib/config/plugins.d.ts.map +1 -1
  22. package/dist/lib/config/plugins.js +2 -2
  23. package/dist/lib/config/plugins.js.map +1 -1
  24. package/dist/lib/database/typeorm/embedded.d.ts +12 -0
  25. package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
  26. package/dist/lib/database/typeorm/embedded.js +92 -0
  27. package/dist/lib/database/typeorm/embedded.js.map +1 -0
  28. package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
  29. package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
  30. package/dist/lib/database/typeorm/entities/user.js.map +1 -1
  31. package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
  32. package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
  33. package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
  34. package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
  35. package/dist/lib/database/typeorm/loader/userManager.js +2 -1
  36. package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
  37. package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
  38. package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
  39. package/dist/lib/database/typeorm/query/operators.js +122 -0
  40. package/dist/lib/database/typeorm/query/operators.js.map +1 -0
  41. package/dist/lib/database/typeorm/query.d.ts +2 -0
  42. package/dist/lib/database/typeorm/query.d.ts.map +1 -1
  43. package/dist/lib/database/typeorm/query.js +29 -62
  44. package/dist/lib/database/typeorm/query.js.map +1 -1
  45. package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
  46. package/dist/lib/database/typeorm/util/crypto.js +50 -26
  47. package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
  48. package/dist/lib/hooks/onRequest.d.ts.map +1 -1
  49. package/dist/lib/hooks/onRequest.js +15 -29
  50. package/dist/lib/hooks/onRequest.js.map +1 -1
  51. package/dist/lib/loader/router.d.ts +2 -0
  52. package/dist/lib/loader/router.d.ts.map +1 -1
  53. package/dist/lib/loader/router.js +7 -3
  54. package/dist/lib/loader/router.js.map +1 -1
  55. package/dist/lib/loader/tracking.d.ts.map +1 -1
  56. package/dist/lib/loader/tracking.js +6 -1
  57. package/dist/lib/loader/tracking.js.map +1 -1
  58. package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
  59. package/dist/lib/middleware/isAdmin.js +2 -1
  60. package/dist/lib/middleware/isAdmin.js.map +1 -1
  61. package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
  62. package/dist/lib/middleware/isAuthenticated.js +2 -1
  63. package/dist/lib/middleware/isAuthenticated.js.map +1 -1
  64. package/dist/lib/schemas/user.d.ts +17 -0
  65. package/dist/lib/schemas/user.d.ts.map +1 -1
  66. package/dist/lib/schemas/user.js +8 -1
  67. package/dist/lib/schemas/user.js.map +1 -1
  68. package/dist/lib/util/errors.d.ts.map +1 -1
  69. package/dist/lib/util/errors.js +3 -2
  70. package/dist/lib/util/errors.js.map +1 -1
  71. package/dist/lib/util/httpError.d.ts +8 -0
  72. package/dist/lib/util/httpError.d.ts.map +1 -0
  73. package/dist/lib/util/httpError.js +19 -0
  74. package/dist/lib/util/httpError.js.map +1 -0
  75. package/dist/typeorm.d.ts +3 -2
  76. package/dist/typeorm.d.ts.map +1 -1
  77. package/dist/typeorm.js +14 -2
  78. package/dist/typeorm.js.map +1 -1
  79. package/dist/types/global.d.ts +4 -1
  80. package/lib/api/auth/controller/auth.ts +113 -67
  81. package/lib/api/auth/routes.ts +17 -0
  82. package/lib/api/token/controller/token.ts +17 -17
  83. package/lib/api/users/controller/user.ts +32 -19
  84. package/lib/api/users/routes.ts +3 -0
  85. package/lib/config/plugins.ts +5 -2
  86. package/lib/database/typeorm/embedded.ts +173 -0
  87. package/lib/database/typeorm/entities/user.ts +2 -0
  88. package/lib/database/typeorm/loader/tenantManager.ts +8 -1
  89. package/lib/database/typeorm/loader/userManager.ts +6 -1
  90. package/lib/database/typeorm/query/operators.ts +187 -0
  91. package/lib/database/typeorm/query.ts +48 -73
  92. package/lib/database/typeorm/util/crypto.ts +89 -27
  93. package/lib/defaults/managers.ts +1 -1
  94. package/lib/hooks/onRequest.ts +17 -29
  95. package/lib/loader/router.ts +8 -2
  96. package/lib/loader/tracking.ts +10 -1
  97. package/lib/middleware/isAdmin.ts +3 -1
  98. package/lib/middleware/isAuthenticated.ts +4 -1
  99. package/lib/schemas/user.ts +17 -1
  100. package/lib/util/errors.ts +4 -2
  101. package/lib/util/httpError.ts +37 -0
  102. package/package.json +29 -3
@@ -1,24 +1,11 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  /* eslint-disable prefer-const */
3
- import {
4
- Not,
5
- Like,
6
- ILike,
7
- Raw,
8
- Equal,
9
- IsNull,
10
- In,
11
- Between,
12
- MoreThan,
13
- MoreThanOrEqual,
14
- LessThan,
15
- LessThanOrEqual,
16
- QueryRunner
17
- } from 'typeorm'
3
+ import { QueryRunner } from 'typeorm'
18
4
  import yn from './util/yn.js'
19
5
  import * as log from './util/logger.js'
20
6
  import { parseLogicExpression } from './query/parser.js'
21
7
  import { buildWhereFromAst } from './query/builder.js'
8
+ import { buildReservedOperators } from './query/operators.js'
22
9
 
23
10
  let sensitiveFields = ['password', 'mfaSecret', 'resetPasswordToken', 'confirmationToken']
24
11
 
@@ -29,12 +16,34 @@ export const configureSensitiveFields = (fields: string[]) => {
29
16
  }
30
17
  }
31
18
 
32
- const evalOrder = (val: string = '') => (['desc', 'd', 'false', '1'].includes(val.toLowerCase()) ? 'desc' : 'asc')
19
+ // When true, the base text operators (eq, contains, starts, ends, like and their
20
+ // negations) are CASE-INSENSITIVE for string values; the `*s` variants force
21
+ // case-sensitive. When false, the legacy behavior is restored (base = sensitive,
22
+ // `*i` variants = insensitive). Default: true (matches the common expectation
23
+ // that `?name=mario` finds "Mario"). Override per-app via the data-layer option
24
+ // `caseInsensitiveByDefault` or the env `VOLCANIC_CASE_INSENSITIVE_DEFAULT`.
25
+ let caseInsensitiveByDefault = yn(process.env.VOLCANIC_CASE_INSENSITIVE_DEFAULT, true)
26
+
27
+ export const configureCaseInsensitiveDefault = (flag: boolean) => {
28
+ caseInsensitiveByDefault = !!flag
29
+ log.info(`Volcanic-TypeORM: caseInsensitiveByDefault = ${caseInsensitiveByDefault}`)
30
+ }
33
31
 
34
- const escapeRegExp = (str: string) => {
35
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ // Upper bound for how many rows a single Magic Query can return. Prevents a caller
33
+ // from requesting `?take=10000000` and exhausting memory/DB (OWASP API4). Default
34
+ // 100; override per-app via the data-layer option `maxPageSize` or the env
35
+ // `VOLCANIC_MAX_PAGE_SIZE`. A value <= 0 disables the cap (not recommended).
36
+ let maxPageSize = Math.floor(Number(process.env.VOLCANIC_MAX_PAGE_SIZE) || 100)
37
+
38
+ export const configureMaxPageSize = (size: number) => {
39
+ if (typeof size === 'number' && Number.isFinite(size)) {
40
+ maxPageSize = Math.floor(size)
41
+ log.info(`Volcanic-TypeORM: maxPageSize = ${maxPageSize}`)
42
+ }
36
43
  }
37
44
 
45
+ const evalOrder = (val: string = '') => (['desc', 'd', 'false', '1'].includes(val.toLowerCase()) ? 'desc' : 'asc')
46
+
38
47
  const isValidIdentifier = (str: string) => /^[a-zA-Z0-9_.]+$/.test(str)
39
48
 
40
49
  const hasProtoRisk = (str: string) => ['__proto__', 'constructor', 'prototype'].includes(str)
@@ -76,63 +85,26 @@ export const useOrder = (order: string[] = []) => {
76
85
  return result
77
86
  }
78
87
 
79
- const typecastValue = (value: any) => {
80
- if (typeof value !== 'string') return value
81
- const lowerValue = value.toLowerCase()
82
- if (lowerValue === 'true') return true
83
- if (lowerValue === 'false') return false
84
- if (lowerValue === 'null') return null
85
- return value
86
- }
87
-
88
88
  export const useWhere = (where: any, repo?: any) => {
89
89
  const aliasMap = new Map<string, any>()
90
90
  const isTargetMongo = isMongo(repo)
91
- const val = (v) => v || 'notFound'
92
-
93
- const reservedOperators = {
94
- ':null': (v) => (typecastValue(v) === false ? Not(IsNull()) : IsNull()),
95
- ':notNull': (v) => (typecastValue(v) === true ? Not(IsNull()) : IsNull()),
96
- ':in': (v) => In(val(v).split(',').map(typecastValue)),
97
- ':nin': (v) => Not(In(val(v).split(',').map(typecastValue))),
98
- ':likei': (v) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`%${val(v)}%`)),
99
- ':containsi': (v) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`%${val(v)}%`)),
100
- ':ncontainsi': (v) => (isTargetMongo ? Not(new RegExp(escapeRegExp(val(v)), 'i')) : Not(ILike(`%${val(v)}%`))),
101
- ':startsi': (v) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}`, 'i') : ILike(`${val(v)}%`)),
102
- ':endsi': (v) => (isTargetMongo ? new RegExp(`${escapeRegExp(val(v))}$`, 'i') : ILike(`%${val(v)}`)),
103
- ':eqi': (v) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}$`, 'i') : ILike(v)),
104
- ':neqi': (v) => (isTargetMongo ? Not(new RegExp(`^${escapeRegExp(val(v))}$`, 'i')) : Not(ILike(v))),
105
- ':like': (v) => Like(`${val(v)}`),
106
- ':contains': (v) => Like(`%${val(v)}%`),
107
- ':ncontains': (v) => Not(Like(`%${val(v)}%`)),
108
- ':starts': (v) => Like(`${val(v)}%`),
109
- ':ends': (v) => Like(`%${val(v)}`),
110
- ':eq': (v) => {
111
- const typedValue = typecastValue(v)
112
- if (typedValue === null) return IsNull()
113
- return isTargetMongo ? typedValue : Equal(typedValue)
114
- },
115
- ':neq': (v) => Not(Equal(typecastValue(v))),
116
- ':gt': (v) => MoreThan(v),
117
- ':ge': (v) => MoreThanOrEqual(v),
118
- ':lt': (v) => LessThan(v),
119
- ':le': (v) => LessThanOrEqual(v),
120
- ':between': (v) => {
121
- const s = v?.split(':')
122
- return s?.length == 2 ? Between(s[0], s[1]) : v
123
- },
124
- ':overlap': (v) => {
125
- const values = val(v).split(',').map(typecastValue)
126
- if (isTargetMongo) {
127
- return { $in: values }
128
- }
129
- return Raw((alias) => `${alias} && ARRAY[:...overlapValues]::text[]`, { overlapValues: values })
130
- }
131
- }
132
91
 
133
- if (yn(process.env.VOLCANIC_CUSTOM_QUERY_OPERATORS, false)) {
92
+ const allowRaw = yn(process.env.VOLCANIC_CUSTOM_QUERY_OPERATORS, false)
93
+ if (allowRaw) {
134
94
  log.warn('Volcanic-TypeORM: Custom query operators (:raw) enabled. SECURITY RISK!')
135
- reservedOperators[':raw'] = (v) => Raw((alias) => `${alias} ${v}`)
95
+ }
96
+
97
+ const reservedOperators = buildReservedOperators({
98
+ isTargetMongo,
99
+ caseInsensitive: caseInsensitiveByDefault,
100
+ allowRaw
101
+ })
102
+
103
+ // Operator NAMES are case-insensitive: `:isEmpty`, `:isempty`, `:ISEMPTY` all
104
+ // resolve to the same handler via this lowercased lookup.
105
+ const operatorLookup: Record<string, (v: any) => any> = {}
106
+ for (const k of Object.keys(reservedOperators)) {
107
+ operatorLookup[k.toLowerCase()] = reservedOperators[k]
136
108
  }
137
109
 
138
110
  const reservedWords = Object.keys(reservedOperators).join('|')
@@ -158,6 +130,7 @@ export const useWhere = (where: any, repo?: any) => {
158
130
  const m = key.match(new RegExp(`(${reservedWords})\\b`, 'ig'))
159
131
  const operator = m?.length ? m[0] : ':eq'
160
132
  const fullPath = key.replace(operator, '')
133
+ const handler = operatorLookup[operator.toLowerCase()]
161
134
 
162
135
  if (!isValidIdentifier(fullPath)) {
163
136
  log.warn(`Volcanic-TypeORM: Invalid filter field skipped: ${fullPath}`)
@@ -171,8 +144,8 @@ export const useWhere = (where: any, repo?: any) => {
171
144
  }
172
145
 
173
146
  let value = where[rawKey]
174
- if (operator && reservedOperators[operator]) {
175
- value = reservedOperators[operator](value)
147
+ if (handler) {
148
+ value = handler(value)
176
149
  }
177
150
 
178
151
  let condition = {}
@@ -200,7 +173,9 @@ export const useWhere = (where: any, repo?: any) => {
200
173
  export function applyQuery(data, extraWhere, repo) {
201
174
  const { page: p = 1, pageSize = 25, skip: sk = 0, take: tk = 0, sort: s, _logic, ...where } = data
202
175
  const page: number = (p < 1 ? 1 : p) - 1
203
- const take: number = tk || pageSize
176
+ // Clamp the requested page size to maxPageSize (resource-consumption guard).
177
+ const requestedTake: number = tk || pageSize
178
+ const take: number = maxPageSize > 0 ? Math.min(requestedTake, maxPageSize) : requestedTake
204
179
  const skip: number = sk || page * pageSize
205
180
  const order: string[] = !Array.isArray(s) ? [s] : s
206
181
 
@@ -1,51 +1,113 @@
1
1
  import * as crypto from 'crypto'
2
2
 
3
- const ALGORITHM_NEW = 'aes-256-gcm'
4
- const ALGORITHM_OLD = 'aes-256-cbc'
3
+ // Authenticated cipher used for all NEW data.
4
+ const ALGORITHM_GCM = 'aes-256-gcm'
5
+ // Legacy, unauthenticated cipher: only ever READ for backward compatibility, never written.
6
+ const ALGORITHM_CBC_LEGACY = 'aes-256-cbc'
7
+
5
8
  const SECRET_KEY = process.env.MFA_DB_SECRET || process.env.JWT_SECRET
6
- const IV_LENGTH_NEW = 12
7
9
 
8
- function getKey() {
10
+ // Current (v2) format parameters.
11
+ const VERSION = 'v2'
12
+ const IV_LENGTH = 12 // GCM standard nonce length
13
+ const SALT_LENGTH = 16 // per-record random salt for the KDF
14
+ const KEY_LENGTH = 32 // AES-256
15
+ // scrypt cost parameters (N must be a power of 2). N=2^15 keeps derivation well under ~50ms.
16
+ const SCRYPT_N = 32768
17
+ const SCRYPT_r = 8
18
+ const SCRYPT_p = 1
19
+ // scrypt memory cost is ~128*N*r bytes (~33.5MB here), above Node's 32MB default → raise maxmem.
20
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024
21
+
22
+ function getSecret(): string {
9
23
  if (!SECRET_KEY) {
10
24
  throw new Error('Secret key is not defined in environment variables.')
11
25
  }
12
- return crypto.createHash('sha256').update(String(SECRET_KEY)).digest('base64').substring(0, 32)
26
+ return String(SECRET_KEY)
27
+ }
28
+
29
+ /**
30
+ * Robust per-record key derivation: scrypt(secret, random salt). Each record carries its own salt,
31
+ * so identical plaintexts never share a key and the derivation follows a memory-hard standard.
32
+ */
33
+ function deriveKey(salt: Buffer): Buffer {
34
+ return crypto.scryptSync(getSecret(), salt, KEY_LENGTH, {
35
+ N: SCRYPT_N,
36
+ r: SCRYPT_r,
37
+ p: SCRYPT_p,
38
+ maxmem: SCRYPT_MAXMEM
39
+ })
13
40
  }
14
41
 
42
+ /**
43
+ * Legacy key derivation (weak, no salt): sha256(secret) -> base64 -> first 32 chars used as raw key bytes.
44
+ * Kept ONLY to decrypt records written before the v2 format. Never used to encrypt new data.
45
+ */
46
+ function legacyKey(): Buffer {
47
+ return Buffer.from(crypto.createHash('sha256').update(getSecret()).digest('base64').substring(0, KEY_LENGTH))
48
+ }
49
+
50
+ /**
51
+ * Encrypts a plaintext into the authenticated, versioned format: `v2:salt:iv:authTag:ciphertext` (hex).
52
+ */
15
53
  export function encrypt(text: string): string {
16
54
  if (!text) return text
17
- const iv = crypto.randomBytes(IV_LENGTH_NEW)
18
- const cipher = crypto.createCipheriv(ALGORITHM_NEW, Buffer.from(getKey()), iv)
19
- let encrypted = cipher.update(text, 'utf8')
20
- encrypted = Buffer.concat([encrypted, cipher.final()])
55
+
56
+ const salt = crypto.randomBytes(SALT_LENGTH)
57
+ const iv = crypto.randomBytes(IV_LENGTH)
58
+ const key = deriveKey(salt)
59
+
60
+ const cipher = crypto.createCipheriv(ALGORITHM_GCM, key, iv)
61
+ const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
21
62
  const authTag = cipher.getAuthTag()
22
- return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
63
+
64
+ return [VERSION, salt.toString('hex'), iv.toString('hex'), authTag.toString('hex'), encrypted.toString('hex')].join(
65
+ ':'
66
+ )
23
67
  }
24
68
 
69
+ /**
70
+ * Decrypts a value produced by any supported format:
71
+ * - v2 (current): `v2:salt:iv:authTag:ciphertext` -> scrypt key + GCM
72
+ * - legacy GCM: `iv:authTag:ciphertext` -> legacy key + GCM
73
+ * - legacy CBC: `iv:ciphertext` -> legacy key + CBC (unauthenticated, read-only)
74
+ * Tampering with a GCM/v2 record throws (auth tag mismatch).
75
+ */
25
76
  export function decrypt(text: string): string {
26
77
  if (!text) return text
27
- const textParts = text.split(':')
28
-
29
- if (textParts.length === 2) {
30
- const iv = Buffer.from(textParts[0], 'hex')
31
- const encryptedText = Buffer.from(textParts[1], 'hex')
32
- const decipher = crypto.createDecipheriv(ALGORITHM_OLD, Buffer.from(getKey()), iv)
33
- let decrypted = decipher.update(encryptedText)
34
- decrypted = Buffer.concat([decrypted, decipher.final()])
35
- return decrypted.toString()
78
+
79
+ const parts = text.split(':')
80
+
81
+ // Current versioned format.
82
+ if (parts.length === 5 && parts[0] === VERSION) {
83
+ const salt = Buffer.from(parts[1], 'hex')
84
+ const iv = Buffer.from(parts[2], 'hex')
85
+ const authTag = Buffer.from(parts[3], 'hex')
86
+ const encryptedText = Buffer.from(parts[4], 'hex')
87
+
88
+ const decipher = crypto.createDecipheriv(ALGORITHM_GCM, deriveKey(salt), iv)
89
+ decipher.setAuthTag(authTag)
90
+ return decipher.update(encryptedText, undefined, 'utf8') + decipher.final('utf8')
36
91
  }
37
92
 
38
- if (textParts.length === 3) {
39
- const iv = Buffer.from(textParts[0], 'hex')
40
- const authTag = Buffer.from(textParts[1], 'hex')
41
- const encryptedText = Buffer.from(textParts[2], 'hex')
93
+ // Legacy authenticated GCM (pre-v2): iv:authTag:ciphertext with the old key derivation.
94
+ if (parts.length === 3) {
95
+ const iv = Buffer.from(parts[0], 'hex')
96
+ const authTag = Buffer.from(parts[1], 'hex')
97
+ const encryptedText = Buffer.from(parts[2], 'hex')
42
98
 
43
- const decipher = crypto.createDecipheriv(ALGORITHM_NEW, Buffer.from(getKey()), iv)
99
+ const decipher = crypto.createDecipheriv(ALGORITHM_GCM, legacyKey(), iv)
44
100
  decipher.setAuthTag(authTag)
101
+ return decipher.update(encryptedText, undefined, 'utf8') + decipher.final('utf8')
102
+ }
103
+
104
+ // Legacy unauthenticated CBC (oldest records): iv:ciphertext with the old key derivation. Read-only.
105
+ if (parts.length === 2) {
106
+ const iv = Buffer.from(parts[0], 'hex')
107
+ const encryptedText = Buffer.from(parts[1], 'hex')
45
108
 
46
- let decrypted = decipher.update(encryptedText, undefined, 'utf8')
47
- decrypted += decipher.final('utf8')
48
- return decrypted
109
+ const decipher = crypto.createDecipheriv(ALGORITHM_CBC_LEGACY, legacyKey(), iv)
110
+ return Buffer.concat([decipher.update(encryptedText), decipher.final()]).toString()
49
111
  }
50
112
 
51
113
  return text
@@ -182,7 +182,7 @@ export const defaultMfaManager: MfaManagement = {
182
182
  generateSetup(_appName: string, _email: string) {
183
183
  throw new Error('Not implemented.')
184
184
  },
185
- verify(_token: string, _secret: string) {
185
+ verify(_token: string, _secret: string): number | null {
186
186
  throw new Error('Not implemented.')
187
187
  }
188
188
  }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { getParams, getData } from '../util/common.js'
3
+ import { httpError } from '../util/httpError.js'
3
4
  import type { AuthenticatedUser, AuthenticatedToken, Role, TransferManagement } from '../../types/global.js'
4
5
 
5
6
  const MFA_SETUP_WHITELIST = ['/auth/mfa/setup', '/auth/mfa/enable', '/auth/mfa/verify', '/auth/logout']
@@ -78,18 +79,10 @@ export default async (req, reply) => {
78
79
  const { multi_tenant } = global.config?.options || {}
79
80
  if (multi_tenant?.enabled && cfg.tenantContext !== false) {
80
81
  if (!req.tenant || !tokenData.tid) {
81
- return reply.status(403).send({
82
- statusCode: 403,
83
- code: 'TENANT_NOT_FOUND',
84
- message: 'Token does not belong to this tenant'
85
- })
82
+ return reply.status(403).send(httpError(403, 'Token does not belong to this tenant', 'TENANT_NOT_FOUND'))
86
83
  }
87
84
  if (tokenData.tid !== req.tenant.id) {
88
- return reply.status(403).send({
89
- statusCode: 403,
90
- code: 'TENANT_MISMATCH',
91
- message: 'Token does not belong to this tenant'
92
- })
85
+ return reply.status(403).send(httpError(403, 'Token does not belong to this tenant', 'TENANT_MISMATCH'))
93
86
  }
94
87
  }
95
88
 
@@ -100,11 +93,9 @@ export default async (req, reply) => {
100
93
 
101
94
  if (!isAllowed) {
102
95
  if (log.w) log.warn(`Security Block: User attempted to access ${currentUrl} with pre-auth MFA token`)
103
- return reply.status(403).send({
104
- statusCode: 403,
105
- code: 'MFA_REQUIRED',
106
- message: 'MFA verification or setup required to access this resource'
107
- })
96
+ return reply
97
+ .status(403)
98
+ .send(httpError(403, 'MFA verification or setup required to access this resource', 'MFA_REQUIRED'))
108
99
  }
109
100
  }
110
101
 
@@ -122,9 +113,7 @@ export default async (req, reply) => {
122
113
  if (user) {
123
114
  const isValid = await req.server['userManager'].isValidUser(user)
124
115
  if (!isValid) {
125
- return reply
126
- .status(403)
127
- .send({ statusCode: 403, code: 'USER_NOT_VALID', message: 'User is not valid or blocked' })
116
+ return reply.status(403).send(httpError(403, 'User is not valid or blocked', 'USER_NOT_VALID'))
128
117
  }
129
118
  req.user = user
130
119
  }
@@ -135,16 +124,14 @@ export default async (req, reply) => {
135
124
  if (token) {
136
125
  const isValid = await req.server['tokenManager'].isValidToken(token)
137
126
  if (!isValid) {
138
- return reply
139
- .status(403)
140
- .send({ statusCode: 403, code: 'TOKEN_NOT_VALID', message: 'Token is not valid or blocked' })
127
+ return reply.status(403).send(httpError(403, 'Token is not valid or blocked', 'TOKEN_NOT_VALID'))
141
128
  }
142
129
  req.token = token
143
130
  }
144
131
  }
145
132
 
146
133
  if (!req.user && !req.token) {
147
- return reply.status(404).send({ statusCode: 404, code: 'SUBJECT_NOT_FOUND', message: 'Subject not found' })
134
+ return reply.status(404).send(httpError(404, 'Subject not found', 'SUBJECT_NOT_FOUND'))
148
135
  }
149
136
 
150
137
  const freshNormalizedRoles = normalizeRoles(req.user?.roles || req.token?.roles)
@@ -152,11 +139,7 @@ export default async (req, reply) => {
152
139
  } catch (error) {
153
140
  const isRoutePublic = (cfg.requiredRoles || []).some((role: Role) => role.code === roles.public.code)
154
141
  if (!isRoutePublic) {
155
- return reply.status(401).send({
156
- statusCode: 401,
157
- code: 'UNAUTHORIZED',
158
- message: (error as any)?.message || 'Invalid or expired token'
159
- })
142
+ return reply.status(401).send(httpError(401, (error as any)?.message || 'Invalid or expired token', 'UNAUTHORIZED'))
160
143
  }
161
144
  }
162
145
  }
@@ -167,8 +150,13 @@ export default async (req, reply) => {
167
150
  const hasPermission = requiredRoles.some((r) => authorizedRoles.includes(r.code))
168
151
 
169
152
  if (!hasPermission) {
170
- if (log.w) log.warn(`Forbidden: ${req.user?.email || 'anonymous'} cannot call ${method.toUpperCase()} ${url}`)
171
- return reply.status(403).send({ statusCode: 403, code: 'FORBIDDEN', message: 'Authorization denied' })
153
+ // 401 when there is no authenticated subject (must log in first); 403 when
154
+ // authenticated but lacking the required role.
155
+ const anonymous = !req.user && !req.token
156
+ if (log.w) log.warn(`Denied: ${req.user?.email || 'anonymous'} cannot call ${method.toUpperCase()} ${url}`)
157
+ return anonymous
158
+ ? reply.status(401).send(httpError(401, 'Authentication required', 'UNAUTHORIZED'))
159
+ : reply.status(403).send(httpError(403, 'Authorization denied', 'FORBIDDEN'))
172
160
  }
173
161
  }
174
162
  }
@@ -65,7 +65,7 @@ async function loadMiddlewares(base: string, middlewares: string[] = []) {
65
65
  }
66
66
 
67
67
 
68
- function processRoute(
68
+ export function processRoute(
69
69
  route: Route,
70
70
  index: number,
71
71
  file: string,
@@ -90,6 +90,9 @@ function processRoute(
90
90
  let requiredRoles: Role[] = []
91
91
 
92
92
  try {
93
+ // `admin` is a global superuser: it is appended to EVERY route's allowed roles,
94
+ // so an admin can access any endpoint (even ones restricted to e.g. backoffice).
95
+ // This is intentional. Remove this append if admin should NOT be a universal role.
93
96
  requiredRoles = rsp.some((r) => r.code === roles.admin.code) ? rsp : [...rsp, roles.admin]
94
97
  } catch (err) {
95
98
  if (log.e) log.error(`Error in loading roles for ${methodCase} ${pathName} (${handler})`)
@@ -132,7 +135,10 @@ function processRoute(
132
135
  if (!methods.includes(method)) errors.push(`Error in [${file}] bad method [${method}] at route n. ${num}`)
133
136
  if (handlerParts.length !== 2) errors.push(`Error in [${file}] bad handler [${handler}] at route n. ${num}`)
134
137
 
135
- const key = method + endpoint + version
138
+ // Stored routes keep a leading slash in `path` ('/' + endpoint), so the key
139
+ // must include it too — otherwise 'GETusers' never equals 'GET/users' and the
140
+ // duplicate check silently never fires.
141
+ const key = `${method}/${endpoint}${version}`
136
142
  if (validRoutes.some((r) => `${r.method}${r.path}${r.doc?.version}` === key)) {
137
143
  errors.push(`Error in [${file}] duplicated path [${pathName}] at route n. ${num}`)
138
144
  }
@@ -25,7 +25,16 @@ export async function load() {
25
25
 
26
26
  if (enableAll) {
27
27
  changes.forEach((change) => {
28
- const tc: TrackChanges = { primaryKey: primaryKey, changeEntity: changeEntity, ...change } as TrackChanges
28
+ // `enable: true` default must be PERSISTED: the runtime gate
29
+ // (getTrackingConfigIfEnabled) checks `tc.enable`, so a change entry
30
+ // without an explicit `enable` would otherwise never track despite
31
+ // isValid() treating it as enabled.
32
+ const tc: TrackChanges = {
33
+ primaryKey: primaryKey,
34
+ changeEntity: changeEntity,
35
+ enable: true,
36
+ ...change
37
+ } as TrackChanges
29
38
  const code = getCodeBy(tc.method, tc.path)
30
39
 
31
40
  if (code in trackChangesList) {
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { FastifyReply, FastifyRequest } from 'fastify'
3
+ import { httpError } from '../util/httpError.js'
3
4
 
4
5
  export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
5
6
  try {
@@ -10,6 +11,7 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
10
11
  throw new Error('User without this privilege')
11
12
  } catch (err) {
12
13
  if (log.e) log.error(`Upps, something just happened ${err}`)
13
- res.code(403).send(new Error('User without this privilege'))
14
+ // Structured body so the 403 status is preserved (see isAuthenticated note).
15
+ res.code(403).send(httpError(403, 'User without this privilege', 'FORBIDDEN'))
14
16
  }
15
17
  }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { FastifyReply, FastifyRequest } from 'fastify'
3
+ import { httpError } from '../util/httpError.js'
3
4
 
4
5
  export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
5
6
  try {
@@ -10,6 +11,8 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
10
11
  throw new Error('Unauthorized')
11
12
  } catch (err) {
12
13
  if (log.e) log.error(`Upps, something just happened ${err}`)
13
- return res.code(401).send(new Error('Unauthorized')) // must be authorized first
14
+ // Send a structured body (not a raw Error): `reply.code(x).send(new Error())`
15
+ // loses the status in Fastify's async error path and collapses to 500/403.
16
+ return res.code(401).send(httpError(401, 'Unauthorized', 'UNAUTHORIZED')) // must be authorized first
14
17
  }
15
18
  }
@@ -2,8 +2,15 @@ export const currentUserBodySchema = {
2
2
  $id: 'currentUserBodySchema',
3
3
  type: 'object',
4
4
  nullable: true,
5
+ // Self-service profile edit only. `additionalProperties: false` rejects any other
6
+ // field (roles, blocked, confirmed, password, externalId, mfa*) so a user cannot
7
+ // mass-assign their way to privilege escalation. The controller also whitelists
8
+ // these same fields as the real security boundary (defense in depth).
9
+ additionalProperties: false,
5
10
  properties: {
6
- username: { type: 'string' }
11
+ username: { type: 'string' },
12
+ firstName: { type: 'string' },
13
+ lastName: { type: 'string' }
7
14
  }
8
15
  }
9
16
 
@@ -11,9 +18,18 @@ export const userBodySchema = {
11
18
  $id: 'userBodySchema',
12
19
  type: 'object',
13
20
  nullable: true,
21
+ // Admin create/update surface. `additionalProperties: false` drops anything not
22
+ // listed (e.g. externalId, mfaSecret, passwordChangedAt). `password` is allowed
23
+ // for CREATE (createUser hashes it); on UPDATE it is dropped in updateUserById so
24
+ // it can never be stored in plaintext — credential changes go through the
25
+ // dedicated hashed flows (change-password / reset-password).
26
+ additionalProperties: false,
14
27
  properties: {
15
28
  username: { type: 'string' },
16
29
  email: { type: 'string' },
30
+ password: { type: 'string' },
31
+ firstName: { type: 'string' },
32
+ lastName: { type: 'string' },
17
33
  blocked: { type: 'boolean' },
18
34
  blockedReason: { type: 'string' },
19
35
  blockedAt: { type: 'string' },
@@ -12,8 +12,10 @@ export class TranslatedError extends Error {
12
12
  this.locale = locale
13
13
  this.status = status
14
14
  this.translationCode = translationCode
15
- this.translatedMessage = global.t.__({ phrase: translationCode || defaultMessage, locale: locale || 'en' }, data)
16
- this.message = this.translatedMessage || defaultMessage || 'generic error'
15
+ const phrase = translationCode || defaultMessage
16
+ this.translatedMessage = phrase ? global.t.__({ phrase, locale: locale || 'en' }, data) : null
17
+ // fallback: tradotto -> code grezzo -> defaultMessage -> catch-all
18
+ this.message = this.translatedMessage || translationCode || defaultMessage || 'generic error'
17
19
  this.data = data
18
20
 
19
21
  Error.captureStackTrace(this, this.constructor || TranslatedError)
@@ -0,0 +1,37 @@
1
+ // Standard error response body for the whole framework.
2
+ //
3
+ // { statusCode, error, code?, message? }
4
+ //
5
+ // statusCode : HTTP status
6
+ // error : HTTP reason phrase ('Bad Request', 'Unauthorized', ...)
7
+ // code : optional machine-readable code ('TENANT_MISMATCH', 'MFA_REQUIRED', ...)
8
+ // message : optional human-readable message
9
+ //
10
+ // Use this instead of `reply.send(new Error(...))`, which loses the status in
11
+ // Fastify's async error path (collapses to 500/403).
12
+ const REASON: Record<number, string> = {
13
+ 400: 'Bad Request',
14
+ 401: 'Unauthorized',
15
+ 403: 'Forbidden',
16
+ 404: 'Not Found',
17
+ 409: 'Conflict',
18
+ 422: 'Unprocessable Entity',
19
+ 429: 'Too Many Requests',
20
+ 500: 'Internal Server Error'
21
+ }
22
+
23
+ export interface HttpErrorBody {
24
+ statusCode: number
25
+ error: string
26
+ code?: string
27
+ message?: string
28
+ }
29
+
30
+ export function httpError(statusCode: number, message?: string, code?: string): HttpErrorBody {
31
+ return {
32
+ statusCode,
33
+ error: REASON[statusCode] || 'Error',
34
+ ...(code ? { code } : {}),
35
+ ...(message ? { message } : {})
36
+ }
37
+ }