@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.
- package/README.md +128 -27
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/dist/lib/api/admin/controller/manifest.d.ts +3 -0
- package/dist/lib/api/admin/controller/manifest.d.ts.map +1 -0
- package/dist/lib/api/admin/controller/manifest.js +6 -0
- package/dist/lib/api/admin/controller/manifest.js.map +1 -0
- package/dist/lib/api/admin/routes.d.ts +23 -0
- package/dist/lib/api/admin/routes.d.ts.map +1 -0
- package/dist/lib/api/admin/routes.js +26 -0
- package/dist/lib/api/admin/routes.js.map +1 -0
- package/dist/lib/api/auth/controller/auth.d.ts +1 -1
- package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +90 -66
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/auth/routes.d.ts +29 -0
- package/dist/lib/api/auth/routes.d.ts.map +1 -1
- package/dist/lib/api/auth/routes.js +10 -0
- package/dist/lib/api/auth/routes.js.map +1 -1
- package/dist/lib/api/token/controller/token.js +17 -17
- package/dist/lib/api/token/controller/token.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +25 -19
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/api/users/routes.d.ts.map +1 -1
- package/dist/lib/api/users/routes.js.map +1 -1
- package/dist/lib/config/general.d.ts +3 -0
- package/dist/lib/config/general.d.ts.map +1 -1
- package/dist/lib/config/general.js +3 -0
- package/dist/lib/config/general.js.map +1 -1
- package/dist/lib/config/plugins.d.ts +17 -0
- package/dist/lib/config/plugins.d.ts.map +1 -1
- package/dist/lib/config/plugins.js +2 -2
- package/dist/lib/config/plugins.js.map +1 -1
- package/dist/lib/database/typeorm/embedded.d.ts +12 -0
- package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
- package/dist/lib/database/typeorm/embedded.js +92 -0
- package/dist/lib/database/typeorm/embedded.js.map +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
- package/dist/lib/database/typeorm/entities/user.js.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.js +2 -1
- package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
- package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
- package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
- package/dist/lib/database/typeorm/query/operators.js +122 -0
- package/dist/lib/database/typeorm/query/operators.js.map +1 -0
- package/dist/lib/database/typeorm/query.d.ts +2 -0
- package/dist/lib/database/typeorm/query.d.ts.map +1 -1
- package/dist/lib/database/typeorm/query.js +29 -62
- package/dist/lib/database/typeorm/query.js.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.js +50 -26
- package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
- package/dist/lib/hooks/onRequest.d.ts.map +1 -1
- package/dist/lib/hooks/onRequest.js +15 -29
- package/dist/lib/hooks/onRequest.js.map +1 -1
- package/dist/lib/loader/general.d.ts.map +1 -1
- package/dist/lib/loader/general.js +3 -0
- package/dist/lib/loader/general.js.map +1 -1
- package/dist/lib/loader/router.d.ts +2 -0
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +11 -4
- package/dist/lib/loader/router.js.map +1 -1
- package/dist/lib/loader/tracking.d.ts.map +1 -1
- package/dist/lib/loader/tracking.js +6 -1
- package/dist/lib/loader/tracking.js.map +1 -1
- package/dist/lib/manifest/generator.d.ts +90 -0
- package/dist/lib/manifest/generator.d.ts.map +1 -0
- package/dist/lib/manifest/generator.js +274 -0
- package/dist/lib/manifest/generator.js.map +1 -0
- package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
- package/dist/lib/middleware/isAdmin.js +2 -1
- package/dist/lib/middleware/isAdmin.js.map +1 -1
- package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
- package/dist/lib/middleware/isAuthenticated.js +2 -1
- package/dist/lib/middleware/isAuthenticated.js.map +1 -1
- package/dist/lib/schemas/user.d.ts +17 -0
- package/dist/lib/schemas/user.d.ts.map +1 -1
- package/dist/lib/schemas/user.js +8 -1
- package/dist/lib/schemas/user.js.map +1 -1
- package/dist/lib/util/errors.d.ts.map +1 -1
- package/dist/lib/util/errors.js +3 -2
- package/dist/lib/util/errors.js.map +1 -1
- package/dist/lib/util/httpError.d.ts +8 -0
- package/dist/lib/util/httpError.d.ts.map +1 -0
- package/dist/lib/util/httpError.js +19 -0
- package/dist/lib/util/httpError.js.map +1 -0
- package/dist/typeorm.d.ts +3 -2
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +14 -2
- package/dist/typeorm.js.map +1 -1
- package/dist/types/global.d.ts +22 -1
- package/lib/api/admin/controller/manifest.ts +9 -0
- package/lib/api/admin/routes.ts +26 -0
- package/lib/api/auth/controller/auth.ts +113 -67
- package/lib/api/auth/routes.ts +17 -0
- package/lib/api/token/controller/token.ts +17 -17
- package/lib/api/users/controller/user.ts +32 -19
- package/lib/api/users/routes.ts +3 -0
- package/lib/config/general.ts +3 -0
- package/lib/config/plugins.ts +5 -2
- package/lib/database/typeorm/embedded.ts +173 -0
- package/lib/database/typeorm/entities/user.ts +2 -0
- package/lib/database/typeorm/loader/tenantManager.ts +8 -1
- package/lib/database/typeorm/loader/userManager.ts +6 -1
- package/lib/database/typeorm/query/operators.ts +187 -0
- package/lib/database/typeorm/query.ts +48 -73
- package/lib/database/typeorm/util/crypto.ts +89 -27
- package/lib/defaults/managers.ts +1 -1
- package/lib/hooks/onRequest.ts +17 -29
- package/lib/loader/general.ts +3 -0
- package/lib/loader/router.ts +16 -3
- package/lib/loader/tracking.ts +10 -1
- package/lib/manifest/generator.ts +345 -0
- package/lib/middleware/isAdmin.ts +3 -1
- package/lib/middleware/isAuthenticated.ts +4 -1
- package/lib/schemas/user.ts +17 -1
- package/lib/util/errors.ts +4 -2
- package/lib/util/httpError.ts +37 -0
- 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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
175
|
-
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
|
-
|
|
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
|
-
|
|
4
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
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
|
package/lib/defaults/managers.ts
CHANGED
|
@@ -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
|
}
|
package/lib/hooks/onRequest.ts
CHANGED
|
@@ -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
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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
|
-
|
|
171
|
-
|
|
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
|
}
|