@volcanicminds/backend 3.0.1 → 3.2.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 (127) hide show
  1. package/README.md +128 -27
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +34 -9
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/api/admin/controller/manifest.d.ts +3 -0
  7. package/dist/lib/api/admin/controller/manifest.d.ts.map +1 -0
  8. package/dist/lib/api/admin/controller/manifest.js +6 -0
  9. package/dist/lib/api/admin/controller/manifest.js.map +1 -0
  10. package/dist/lib/api/admin/routes.d.ts +23 -0
  11. package/dist/lib/api/admin/routes.d.ts.map +1 -0
  12. package/dist/lib/api/admin/routes.js +26 -0
  13. package/dist/lib/api/admin/routes.js.map +1 -0
  14. package/dist/lib/api/auth/controller/auth.d.ts +1 -1
  15. package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
  16. package/dist/lib/api/auth/controller/auth.js +90 -66
  17. package/dist/lib/api/auth/controller/auth.js.map +1 -1
  18. package/dist/lib/api/auth/routes.d.ts +29 -0
  19. package/dist/lib/api/auth/routes.d.ts.map +1 -1
  20. package/dist/lib/api/auth/routes.js +10 -0
  21. package/dist/lib/api/auth/routes.js.map +1 -1
  22. package/dist/lib/api/token/controller/token.js +17 -17
  23. package/dist/lib/api/token/controller/token.js.map +1 -1
  24. package/dist/lib/api/users/controller/user.d.ts.map +1 -1
  25. package/dist/lib/api/users/controller/user.js +25 -19
  26. package/dist/lib/api/users/controller/user.js.map +1 -1
  27. package/dist/lib/api/users/routes.d.ts.map +1 -1
  28. package/dist/lib/api/users/routes.js.map +1 -1
  29. package/dist/lib/config/general.d.ts +3 -0
  30. package/dist/lib/config/general.d.ts.map +1 -1
  31. package/dist/lib/config/general.js +3 -0
  32. package/dist/lib/config/general.js.map +1 -1
  33. package/dist/lib/config/plugins.d.ts +17 -0
  34. package/dist/lib/config/plugins.d.ts.map +1 -1
  35. package/dist/lib/config/plugins.js +2 -2
  36. package/dist/lib/config/plugins.js.map +1 -1
  37. package/dist/lib/database/typeorm/embedded.d.ts +12 -0
  38. package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
  39. package/dist/lib/database/typeorm/embedded.js +92 -0
  40. package/dist/lib/database/typeorm/embedded.js.map +1 -0
  41. package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
  42. package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
  43. package/dist/lib/database/typeorm/entities/user.js.map +1 -1
  44. package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
  45. package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
  46. package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
  47. package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
  48. package/dist/lib/database/typeorm/loader/userManager.js +2 -1
  49. package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
  50. package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
  51. package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
  52. package/dist/lib/database/typeorm/query/operators.js +122 -0
  53. package/dist/lib/database/typeorm/query/operators.js.map +1 -0
  54. package/dist/lib/database/typeorm/query.d.ts +2 -0
  55. package/dist/lib/database/typeorm/query.d.ts.map +1 -1
  56. package/dist/lib/database/typeorm/query.js +29 -62
  57. package/dist/lib/database/typeorm/query.js.map +1 -1
  58. package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
  59. package/dist/lib/database/typeorm/util/crypto.js +50 -26
  60. package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
  61. package/dist/lib/hooks/onRequest.d.ts.map +1 -1
  62. package/dist/lib/hooks/onRequest.js +15 -29
  63. package/dist/lib/hooks/onRequest.js.map +1 -1
  64. package/dist/lib/loader/general.d.ts.map +1 -1
  65. package/dist/lib/loader/general.js +3 -0
  66. package/dist/lib/loader/general.js.map +1 -1
  67. package/dist/lib/loader/router.d.ts +2 -0
  68. package/dist/lib/loader/router.d.ts.map +1 -1
  69. package/dist/lib/loader/router.js +11 -4
  70. package/dist/lib/loader/router.js.map +1 -1
  71. package/dist/lib/loader/tracking.d.ts.map +1 -1
  72. package/dist/lib/loader/tracking.js +6 -1
  73. package/dist/lib/loader/tracking.js.map +1 -1
  74. package/dist/lib/manifest/generator.d.ts +90 -0
  75. package/dist/lib/manifest/generator.d.ts.map +1 -0
  76. package/dist/lib/manifest/generator.js +274 -0
  77. package/dist/lib/manifest/generator.js.map +1 -0
  78. package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
  79. package/dist/lib/middleware/isAdmin.js +2 -1
  80. package/dist/lib/middleware/isAdmin.js.map +1 -1
  81. package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
  82. package/dist/lib/middleware/isAuthenticated.js +2 -1
  83. package/dist/lib/middleware/isAuthenticated.js.map +1 -1
  84. package/dist/lib/schemas/user.d.ts +17 -0
  85. package/dist/lib/schemas/user.d.ts.map +1 -1
  86. package/dist/lib/schemas/user.js +8 -1
  87. package/dist/lib/schemas/user.js.map +1 -1
  88. package/dist/lib/util/errors.d.ts.map +1 -1
  89. package/dist/lib/util/errors.js +3 -2
  90. package/dist/lib/util/errors.js.map +1 -1
  91. package/dist/lib/util/httpError.d.ts +8 -0
  92. package/dist/lib/util/httpError.d.ts.map +1 -0
  93. package/dist/lib/util/httpError.js +19 -0
  94. package/dist/lib/util/httpError.js.map +1 -0
  95. package/dist/typeorm.d.ts +3 -2
  96. package/dist/typeorm.d.ts.map +1 -1
  97. package/dist/typeorm.js +14 -2
  98. package/dist/typeorm.js.map +1 -1
  99. package/dist/types/global.d.ts +22 -1
  100. package/lib/api/admin/controller/manifest.ts +9 -0
  101. package/lib/api/admin/routes.ts +26 -0
  102. package/lib/api/auth/controller/auth.ts +113 -67
  103. package/lib/api/auth/routes.ts +17 -0
  104. package/lib/api/token/controller/token.ts +17 -17
  105. package/lib/api/users/controller/user.ts +32 -19
  106. package/lib/api/users/routes.ts +3 -0
  107. package/lib/config/general.ts +3 -0
  108. package/lib/config/plugins.ts +5 -2
  109. package/lib/database/typeorm/embedded.ts +173 -0
  110. package/lib/database/typeorm/entities/user.ts +2 -0
  111. package/lib/database/typeorm/loader/tenantManager.ts +8 -1
  112. package/lib/database/typeorm/loader/userManager.ts +6 -1
  113. package/lib/database/typeorm/query/operators.ts +187 -0
  114. package/lib/database/typeorm/query.ts +48 -73
  115. package/lib/database/typeorm/util/crypto.ts +89 -27
  116. package/lib/defaults/managers.ts +1 -1
  117. package/lib/hooks/onRequest.ts +17 -29
  118. package/lib/loader/general.ts +3 -0
  119. package/lib/loader/router.ts +16 -3
  120. package/lib/loader/tracking.ts +10 -1
  121. package/lib/manifest/generator.ts +345 -0
  122. package/lib/middleware/isAdmin.ts +3 -1
  123. package/lib/middleware/isAuthenticated.ts +4 -1
  124. package/lib/schemas/user.ts +17 -1
  125. package/lib/util/errors.ts +4 -2
  126. package/lib/util/httpError.ts +37 -0
  127. package/package.json +29 -4
@@ -0,0 +1,187 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ //
3
+ // Magic Query operator catalog.
4
+ //
5
+ // Builds the map of `:operator -> FindOperator factory` used by `useWhere`.
6
+ // Extracted from query.ts to keep that file focused on the query-assembly logic.
7
+ //
8
+ // Naming convention (case sensitivity):
9
+ // base -> case-INsensitive by default (e.g. `eq`, `contains`)
10
+ // `*s` -> strict / case-Sensitive
11
+ // `*i` -> case-Insensitive (explicit alias)
12
+ // `eq`/`neq` stay type-aware: numbers/booleans/null match exactly (no ILIKE on
13
+ // non-text columns); only genuine strings become case-insensitive.
14
+ //
15
+ // Operator NAMES are matched case-insensitively by the caller, so `:isEmpty`,
16
+ // `:isempty` and `:ISEMPTY` are equivalent.
17
+ //
18
+ import {
19
+ Not,
20
+ Like,
21
+ ILike,
22
+ Raw,
23
+ Equal,
24
+ IsNull,
25
+ In,
26
+ Between,
27
+ MoreThan,
28
+ MoreThanOrEqual,
29
+ LessThan,
30
+ LessThanOrEqual
31
+ } from 'typeorm'
32
+
33
+ export const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
34
+
35
+ // Coerces the textual query value: 'true'/'false'/'null' -> boolean/null,
36
+ // everything else stays a string (Postgres casts numeric strings on its own).
37
+ export const typecastValue = (value: any) => {
38
+ if (typeof value !== 'string') return value
39
+ const lowerValue = value.toLowerCase()
40
+ if (lowerValue === 'true') return true
41
+ if (lowerValue === 'false') return false
42
+ if (lowerValue === 'null') return null
43
+ return value
44
+ }
45
+
46
+ const isNumericString = (v: any) => typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)
47
+ const val = (v: any) => v || 'notFound'
48
+
49
+ export interface OperatorContext {
50
+ isTargetMongo: boolean
51
+ caseInsensitive: boolean
52
+ allowRaw?: boolean
53
+ }
54
+
55
+ export type OperatorMap = Record<string, (v: any) => any>
56
+
57
+ /**
58
+ * Builds the operator catalog for the given context. Keys are ordered
59
+ * longest/most-specific first so the alternation regex in `useWhere` resolves
60
+ * correctly even with the `\b` boundary.
61
+ */
62
+ export function buildReservedOperators(ctx: OperatorContext): OperatorMap {
63
+ const { isTargetMongo, caseInsensitive: ci, allowRaw } = ctx
64
+
65
+ // case-insensitive equality, numeric/boolean/null-safe
66
+ const eqInsensitive = (v: any) => {
67
+ const t = typecastValue(v)
68
+ if (t === null) return IsNull()
69
+ if (typeof t === 'boolean') return isTargetMongo ? t : Equal(t)
70
+ if (isNumericString(t)) return isTargetMongo ? t : Equal(t)
71
+ return isTargetMongo ? new RegExp(`^${escapeRegExp(t)}$`, 'i') : ILike(t)
72
+ }
73
+ const eqSensitive = (v: any) => {
74
+ const t = typecastValue(v)
75
+ if (t === null) return IsNull()
76
+ return isTargetMongo ? t : Equal(t)
77
+ }
78
+
79
+ const containsS = (v: any) => (isTargetMongo ? new RegExp(escapeRegExp(val(v))) : Like(`%${val(v)}%`))
80
+ const containsI = (v: any) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`%${val(v)}%`))
81
+ const startsS = (v: any) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}`) : Like(`${val(v)}%`))
82
+ const startsI = (v: any) => (isTargetMongo ? new RegExp(`^${escapeRegExp(val(v))}`, 'i') : ILike(`${val(v)}%`))
83
+ const endsS = (v: any) => (isTargetMongo ? new RegExp(`${escapeRegExp(val(v))}$`) : Like(`%${val(v)}`))
84
+ const endsI = (v: any) => (isTargetMongo ? new RegExp(`${escapeRegExp(val(v))}$`, 'i') : ILike(`%${val(v)}`))
85
+ const likeS = (v: any) => (isTargetMongo ? new RegExp(escapeRegExp(val(v))) : Like(`${val(v)}`))
86
+ const likeI = (v: any) => (isTargetMongo ? new RegExp(escapeRegExp(val(v)), 'i') : ILike(`${val(v)}`))
87
+ const not = (fn: (v: any) => any) => (v: any) => Not(fn(v))
88
+
89
+ const arrayOp = (sqlOp: string, paramName: string) => (v: any) => {
90
+ const values = val(v).split(',').map(typecastValue)
91
+ if (isTargetMongo) return { $in: values }
92
+ return Raw((alias) => `${alias} ${sqlOp} ARRAY[:...${paramName}]::text[]`, { [paramName]: values })
93
+ }
94
+
95
+ // JSONB key-existence operators (Postgres only; degrade to $in on Mongo).
96
+ const jsonHasKey = (v: any) => {
97
+ if (isTargetMongo) return { $exists: true }
98
+ return Raw((alias) => `${alias} ? :jsonHasKeyValue`, { jsonHasKeyValue: val(v) })
99
+ }
100
+ const jsonHasArray = (sqlOp: string, paramName: string) => (v: any) => {
101
+ const values = val(v).split(',')
102
+ if (isTargetMongo) return { $in: values }
103
+ return Raw((alias) => `${alias} ${sqlOp} ARRAY[:...${paramName}]`, { [paramName]: values })
104
+ }
105
+
106
+ const ops: OperatorMap = {
107
+ // --- null / empty ---
108
+ ':notNull': (v) => (typecastValue(v) === true ? Not(IsNull()) : IsNull()),
109
+ ':null': (v) => (typecastValue(v) === false ? Not(IsNull()) : IsNull()),
110
+ ':isNotEmpty': () => (isTargetMongo ? { $ne: '' } : Not(Equal(''))),
111
+ ':isEmpty': () => (isTargetMongo ? '' : Equal('')),
112
+
113
+ // --- set membership (exact) ---
114
+ ':nin': (v) => Not(In(val(v).split(',').map(typecastValue))),
115
+ ':in': (v) => In(val(v).split(',').map(typecastValue)),
116
+
117
+ // --- equality (base follows caseInsensitive; *s strict, *i insensitive) ---
118
+ ':eqs': eqSensitive,
119
+ ':eqi': eqInsensitive,
120
+ ':eq': ci ? eqInsensitive : eqSensitive,
121
+ ':neqs': not(eqSensitive),
122
+ ':neqi': not(eqInsensitive),
123
+ ':neq': ci ? not(eqInsensitive) : not(eqSensitive),
124
+
125
+ // --- contains ---
126
+ ':ncontainss': not(containsS),
127
+ ':ncontainsi': not(containsI),
128
+ ':ncontains': ci ? not(containsI) : not(containsS),
129
+ ':containss': containsS,
130
+ ':containsi': containsI,
131
+ ':contains': ci ? containsI : containsS,
132
+
133
+ // --- starts ---
134
+ ':nstartss': not(startsS),
135
+ ':nstartsi': not(startsI),
136
+ ':nstarts': ci ? not(startsI) : not(startsS),
137
+ ':startss': startsS,
138
+ ':startsi': startsI,
139
+ ':starts': ci ? startsI : startsS,
140
+
141
+ // --- ends ---
142
+ ':nendss': not(endsS),
143
+ ':nendsi': not(endsI),
144
+ ':nends': ci ? not(endsI) : not(endsS),
145
+ ':endss': endsS,
146
+ ':endsi': endsI,
147
+ ':ends': ci ? endsI : endsS,
148
+
149
+ // --- like ---
150
+ ':nlikes': not(likeS),
151
+ ':nlikei': not(likeI),
152
+ ':nlike': ci ? not(likeI) : not(likeS),
153
+ ':likes': likeS,
154
+ ':likei': likeI,
155
+ ':like': ci ? likeI : likeS,
156
+
157
+ // --- comparison (numbers AND dates) ---
158
+ ':gt': (v) => MoreThan(v),
159
+ ':ge': (v) => MoreThanOrEqual(v),
160
+ ':lt': (v) => LessThan(v),
161
+ ':le': (v) => LessThanOrEqual(v),
162
+ ':nbetween': (v) => {
163
+ const s = v?.split(':')
164
+ return s?.length == 2 ? Not(Between(s[0], s[1])) : v
165
+ },
166
+ ':between': (v) => {
167
+ const s = v?.split(':')
168
+ return s?.length == 2 ? Between(s[0], s[1]) : v
169
+ },
170
+
171
+ // --- array (Postgres) ---
172
+ ':arrayContainedBy': arrayOp('<@', 'arrayContainedByValues'),
173
+ ':arrayContains': arrayOp('@>', 'arrayContainsValues'),
174
+ ':overlap': arrayOp('&&', 'overlapValues'),
175
+
176
+ // --- jsonb key existence (Postgres) ---
177
+ ':jsonHasAllKeys': jsonHasArray('?&', 'jsonHasAllKeysValues'),
178
+ ':jsonHasAnyKey': jsonHasArray('?|', 'jsonHasAnyKeyValues'),
179
+ ':jsonHasKey': jsonHasKey
180
+ }
181
+
182
+ if (allowRaw) {
183
+ ops[':raw'] = (v) => Raw((alias) => `${alias} ${v}`)
184
+ }
185
+
186
+ return ops
187
+ }
@@ -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
  }
@@ -17,6 +17,9 @@ export async function load() {
17
17
  multi_tenant: {
18
18
  enabled: false,
19
19
  query_key: 'tid'
20
+ },
21
+ manifest: {
22
+ enabled: false
20
23
  }
21
24
  }
22
25
  }