@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.
- package/README.md +128 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -9
- package/dist/index.js.map +1 -1
- 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/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/router.d.ts +2 -0
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +7 -3
- 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/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 +4 -1
- 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/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/router.ts +8 -2
- package/lib/loader/tracking.ts +10 -1
- 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 -3
|
@@ -8,17 +8,17 @@ export async function getRoles(_req: FastifyRequest, reply: FastifyReply) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export async function count(req: FastifyRequest, _reply: FastifyReply) {
|
|
11
|
-
return req.server['userManager'].countQuery(req.data())
|
|
11
|
+
return req.server['userManager'].countQuery(req.data(), req.runner)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export async function find(req: FastifyRequest, reply: FastifyReply) {
|
|
15
|
-
const { headers, records } = await req.server['userManager'].findQuery(req.data())
|
|
15
|
+
const { headers, records } = await req.server['userManager'].findQuery(req.data(), req.runner)
|
|
16
16
|
return reply.type('application/json').headers(headers).send(records)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function findOne(req: FastifyRequest, reply: FastifyReply) {
|
|
20
20
|
const { id } = req.parameters()
|
|
21
|
-
const user = id ? await req.server['userManager'].retrieveUserById(id) : null
|
|
21
|
+
const user = id ? await req.server['userManager'].retrieveUserById(id, req.runner) : null
|
|
22
22
|
return user || reply.status(404).send()
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -35,8 +35,12 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// Pass req.runner so creation lands in the resolved tenant schema (multi-tenant);
|
|
39
|
+
// createUser already persists and returns the saved entity, so do NOT re-save via
|
|
40
|
+
// entity.User.save() — that active-record call always targets the global/public
|
|
41
|
+
// connection and would both double-write and break tenant isolation.
|
|
42
|
+
const user = await req.server['userManager'].createUser(data, req.runner)
|
|
43
|
+
return user || reply.status(400).send(Error('User not creatable'))
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
export async function update(req: FastifyRequest, reply: FastifyReply) {
|
|
@@ -46,7 +50,7 @@ export async function update(req: FastifyRequest, reply: FastifyReply) {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
const { id: _id, ...userData } = req.data()
|
|
49
|
-
return await req.server['userManager'].updateUserById(id, userData)
|
|
53
|
+
return await req.server['userManager'].updateUserById(id, userData, req.runner)
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export async function remove(req: FastifyRequest, reply: FastifyReply) {
|
|
@@ -54,7 +58,7 @@ export async function remove(req: FastifyRequest, reply: FastifyReply) {
|
|
|
54
58
|
if (!id) {
|
|
55
59
|
return reply.status(404).send()
|
|
56
60
|
}
|
|
57
|
-
return await req.server['userManager'].deleteUser(id)
|
|
61
|
+
return await req.server['userManager'].deleteUser(id, req.runner)
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
export async function getCurrentUser(req: FastifyRequest, reply: FastifyReply) {
|
|
@@ -74,6 +78,11 @@ export async function getCurrentUser(req: FastifyRequest, reply: FastifyReply) {
|
|
|
74
78
|
)
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
// Fields a user is allowed to change on themselves. Everything else (roles,
|
|
82
|
+
// blocked, confirmed, password, externalId, mfa*, ...) is off-limits: spreading the
|
|
83
|
+
// raw body here would let a normal user mass-assign roles:['admin'] and escalate.
|
|
84
|
+
const SELF_EDITABLE_FIELDS = ['username', 'firstName', 'lastName']
|
|
85
|
+
|
|
77
86
|
export async function updateCurrentUser(req: FastifyRequest, reply: FastifyReply) {
|
|
78
87
|
const user: AuthenticatedUser | undefined = req.user
|
|
79
88
|
const id = user?.getId()
|
|
@@ -81,8 +90,12 @@ export async function updateCurrentUser(req: FastifyRequest, reply: FastifyReply
|
|
|
81
90
|
return reply.status(403).send('Cannot update current user')
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
const
|
|
85
|
-
|
|
93
|
+
const incoming = req.data() || {}
|
|
94
|
+
const userData: any = {}
|
|
95
|
+
for (const f of SELF_EDITABLE_FIELDS) {
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(incoming, f)) userData[f] = incoming[f]
|
|
97
|
+
}
|
|
98
|
+
return await req.server['userManager'].updateUserById(id, userData, req.runner)
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
export async function isAdmin(req: FastifyRequest, reply: FastifyReply) {
|
|
@@ -127,11 +140,11 @@ export async function resetMfaByAdmin(req: FastifyRequest, reply: FastifyReply)
|
|
|
127
140
|
const { id } = req.parameters()
|
|
128
141
|
|
|
129
142
|
if (!req.hasRole(roles.admin)) {
|
|
130
|
-
return reply.status(403).send(
|
|
143
|
+
return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Only admins can reset MFA' })
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
if (!id) {
|
|
134
|
-
return reply.status(400).send(
|
|
147
|
+
return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing user id' })
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
try {
|
|
@@ -139,7 +152,7 @@ export async function resetMfaByAdmin(req: FastifyRequest, reply: FastifyReply)
|
|
|
139
152
|
return { ok: true }
|
|
140
153
|
} catch (error) {
|
|
141
154
|
req.log.error(error)
|
|
142
|
-
return reply.status(500).send(
|
|
155
|
+
return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to reset MFA' })
|
|
143
156
|
}
|
|
144
157
|
}
|
|
145
158
|
|
|
@@ -150,29 +163,29 @@ export async function resetPasswordByAdmin(req: FastifyRequest, reply: FastifyRe
|
|
|
150
163
|
}
|
|
151
164
|
|
|
152
165
|
if (!req.hasRole(roles.admin)) {
|
|
153
|
-
return reply.status(403).send(
|
|
166
|
+
return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Only admins can reset user passwords' })
|
|
154
167
|
}
|
|
155
168
|
|
|
156
169
|
const { id } = req.parameters()
|
|
157
170
|
if (!id) {
|
|
158
|
-
return reply.status(400).send(
|
|
171
|
+
return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing user id' })
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
const { password } = req.data()
|
|
162
175
|
if (!password) {
|
|
163
|
-
return reply.status(400).send(
|
|
176
|
+
return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Missing password in request body' })
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
try {
|
|
167
|
-
const user = await req.server['userManager'].retrieveUserById(id)
|
|
180
|
+
const user = await req.server['userManager'].retrieveUserById(id, req.runner)
|
|
168
181
|
if (!user) {
|
|
169
|
-
return reply.status(404).send(
|
|
182
|
+
return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'User not found' })
|
|
170
183
|
}
|
|
171
184
|
|
|
172
|
-
await req.server['userManager'].resetPassword(user, password)
|
|
185
|
+
await req.server['userManager'].resetPassword(user, password, req.runner)
|
|
173
186
|
return { ok: true }
|
|
174
187
|
} catch (error) {
|
|
175
188
|
req.log.error(error)
|
|
176
|
-
return reply.status(500).send(
|
|
189
|
+
return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to reset password' })
|
|
177
190
|
}
|
|
178
191
|
}
|
package/lib/api/users/routes.ts
CHANGED
|
@@ -46,6 +46,9 @@ export default {
|
|
|
46
46
|
{
|
|
47
47
|
method: 'GET',
|
|
48
48
|
path: '/roles',
|
|
49
|
+
// Authenticated-only BY DESIGN (any logged-in user). `roles: []` + the
|
|
50
|
+
// isAuthenticated middleware => 401 anonymous, 200 authenticated. Do NOT make
|
|
51
|
+
// this public: it exposes the role taxonomy, which anonymous callers don't need.
|
|
49
52
|
roles: [],
|
|
50
53
|
handler: 'user.getRoles',
|
|
51
54
|
middlewares: ['global.isAuthenticated'],
|
package/lib/config/plugins.ts
CHANGED
|
@@ -39,8 +39,11 @@ export default [
|
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
name: 'rateLimit',
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Registered with `global: false` so it limits ONLY routes that opt in via `config.rateLimit`
|
|
43
|
+
// (currently the MFA endpoints, see S11). Turning on global throttling + per-route limits on
|
|
44
|
+
// login/forgot/reset is tracked separately under S5.
|
|
45
|
+
enable: true,
|
|
46
|
+
options: { global: false }
|
|
44
47
|
},
|
|
45
48
|
{
|
|
46
49
|
name: 'helmet',
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
/**
|
|
3
|
+
* Embedded database (PGlite) wiring for TypeORM.
|
|
4
|
+
*
|
|
5
|
+
* PGlite is a WASM build of Postgres that runs in-process — no external server,
|
|
6
|
+
* no Docker. It is the "plug & play" engine for development, tests, demos and
|
|
7
|
+
* CLIs. Production keeps using a real Postgres server (`type: 'postgres'`).
|
|
8
|
+
*
|
|
9
|
+
* The whole feature is OPT-IN and the heavy WASM dependencies are OPTIONAL:
|
|
10
|
+
* they are only `import()`-ed when the consumer sets `type: 'pglite'`, so a
|
|
11
|
+
* pure-Postgres deployment never pays for them.
|
|
12
|
+
*
|
|
13
|
+
* Consumer config (database.default):
|
|
14
|
+
* {
|
|
15
|
+
* type: 'pglite',
|
|
16
|
+
* dataDir: './.pglite', // omit -> in-memory (ephemeral); a path -> persisted on disk
|
|
17
|
+
* vector: true, // or { } — enable pgvector (embeddings / similarity search)
|
|
18
|
+
* extensions: ['pgcrypto'], // extra PGlite contrib extensions to enable
|
|
19
|
+
* relaxedDurability: true, // faster writes, weaker fsync guarantees (great for tests)
|
|
20
|
+
* synchronize: true,
|
|
21
|
+
* ...standard TypeORM options (entities, namingStrategy, ...)
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
import * as log from './util/logger.js'
|
|
25
|
+
|
|
26
|
+
// PGlite needs `CREATE EXTENSION "<sql-name>"`, whose name often differs from the
|
|
27
|
+
// JS import identifier (e.g. import `uuid_ossp` -> SQL `uuid-ossp`).
|
|
28
|
+
const EXTENSION_SQL_NAME: Record<string, string> = {
|
|
29
|
+
uuid_ossp: 'uuid-ossp',
|
|
30
|
+
vector: 'vector'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `uuid-ossp` is always enabled: the framework entities (User/Tenant/Token/Change)
|
|
34
|
+
// use `@PrimaryGeneratedColumn('uuid')` which calls uuid_generate_v4() at synchronize.
|
|
35
|
+
const ALWAYS_ON_EXTENSIONS = ['uuid_ossp']
|
|
36
|
+
|
|
37
|
+
export interface EmbeddedState {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
vector: boolean
|
|
40
|
+
dataDir?: string
|
|
41
|
+
persisted: boolean
|
|
42
|
+
extensions: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sqlName(ext: string): string {
|
|
46
|
+
return EXTENSION_SQL_NAME[ext] || ext
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function importExtension(name: string): Promise<any> {
|
|
50
|
+
// pgvector ships as a separate package (peer-pinned to the PGlite version);
|
|
51
|
+
// every other extension is a standard PGlite contrib module.
|
|
52
|
+
if (name === 'vector') {
|
|
53
|
+
const mod: any = await import('@electric-sql/pglite-pgvector')
|
|
54
|
+
return mod.vector
|
|
55
|
+
}
|
|
56
|
+
const mod: any = await import(`@electric-sql/pglite/contrib/${name}`)
|
|
57
|
+
return mod[name] ?? mod.default ?? mod
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detects whether the given options ask for the embedded engine.
|
|
62
|
+
*/
|
|
63
|
+
export function isEmbedded(options: any): boolean {
|
|
64
|
+
return options?.type === 'pglite'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Rewrites `options` in place so TypeORM treats them as a standard Postgres
|
|
69
|
+
* DataSource backed by a PGlite driver, registers the required extensions and
|
|
70
|
+
* creates them BEFORE any schema synchronization runs.
|
|
71
|
+
*
|
|
72
|
+
* Returns the resolved embedded state (also published on `global.embeddedDatabase`).
|
|
73
|
+
*/
|
|
74
|
+
export async function setupEmbedded(options: any): Promise<EmbeddedState> {
|
|
75
|
+
let PGliteDriver: any
|
|
76
|
+
let getPGliteInstance: any
|
|
77
|
+
try {
|
|
78
|
+
;({ PGliteDriver, getPGliteInstance } = await import('typeorm-pglite'))
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"[Volcanic-Embedded] type 'pglite' requires optional dependencies. Install them with:\n" +
|
|
82
|
+
' npm install typeorm-pglite @electric-sql/pglite' +
|
|
83
|
+
" @electric-sql/pglite-pgvector\n(the last one only if you use 'vector: true')."
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Resolve config knobs and strip them from the TypeORM options ---
|
|
88
|
+
const wantVector = !!options.vector
|
|
89
|
+
const extraExtensions: string[] = Array.isArray(options.extensions) ? options.extensions : []
|
|
90
|
+
const dataDir: string | undefined = options.dataDir
|
|
91
|
+
const relaxedDurability: boolean = options.relaxedDurability ?? !dataDir // default relaxed for in-memory
|
|
92
|
+
|
|
93
|
+
const extensionNames = Array.from(
|
|
94
|
+
new Set([...ALWAYS_ON_EXTENSIONS, ...extraExtensions, ...(wantVector ? ['vector'] : [])])
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
delete options.vector
|
|
98
|
+
delete options.extensions
|
|
99
|
+
delete options.dataDir
|
|
100
|
+
delete options.relaxedDurability
|
|
101
|
+
|
|
102
|
+
// --- Load extension modules and build the PGlite `extensions` map ---
|
|
103
|
+
const extensions: Record<string, any> = {}
|
|
104
|
+
for (const name of extensionNames) {
|
|
105
|
+
try {
|
|
106
|
+
extensions[name] = await importExtension(name)
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
if (name === 'vector') {
|
|
109
|
+
throw new Error(
|
|
110
|
+
"[Volcanic-Embedded] 'vector: true' requires '@electric-sql/pglite-pgvector'. " +
|
|
111
|
+
'Install it with: npm install @electric-sql/pglite-pgvector'
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`[Volcanic-Embedded] Unknown PGlite extension '${name}': ${err?.message || err}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Build the driver. The constructor passes these options to PGlite.create() ---
|
|
119
|
+
const pgliteOptions: any = { extensions, relaxedDurability }
|
|
120
|
+
if (dataDir) pgliteOptions.dataDir = dataDir
|
|
121
|
+
|
|
122
|
+
const driver = new PGliteDriver(pgliteOptions).driver
|
|
123
|
+
|
|
124
|
+
// Make TypeORM use the Postgres dialect over the PGlite-backed pool.
|
|
125
|
+
options.type = 'postgres'
|
|
126
|
+
options.driver = driver
|
|
127
|
+
|
|
128
|
+
// --- Create extensions BEFORE synchronize() (which may run during initialize()) ---
|
|
129
|
+
// getPGliteInstance() eagerly creates the singleton with the options set above.
|
|
130
|
+
const pg = await getPGliteInstance()
|
|
131
|
+
for (const name of extensionNames) {
|
|
132
|
+
await pg.query(`CREATE EXTENSION IF NOT EXISTS "${sqlName(name)}"`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const state: EmbeddedState = {
|
|
136
|
+
enabled: true,
|
|
137
|
+
vector: wantVector,
|
|
138
|
+
dataDir,
|
|
139
|
+
persisted: !!dataDir,
|
|
140
|
+
extensions: extensionNames
|
|
141
|
+
}
|
|
142
|
+
;(global as any).embeddedDatabase = state
|
|
143
|
+
|
|
144
|
+
log.warn(
|
|
145
|
+
`[Volcanic-Embedded] PGlite engine active (${state.persisted ? `persisted @ ${dataDir}` : 'in-memory'})` +
|
|
146
|
+
`, extensions: ${extensionNames.join(', ')}`
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return state
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* True when the current process is running on the embedded PGlite engine.
|
|
154
|
+
* Used by the tenant manager to avoid destroying the shared PGlite singleton.
|
|
155
|
+
*/
|
|
156
|
+
export function isEmbeddedActive(): boolean {
|
|
157
|
+
return !!(global as any).embeddedDatabase?.enabled
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gracefully closes the shared PGlite instance (no-op on real Postgres).
|
|
162
|
+
* Useful at the end of a test suite to release the WASM instance.
|
|
163
|
+
*/
|
|
164
|
+
export async function closeEmbedded(): Promise<void> {
|
|
165
|
+
if (!isEmbeddedActive()) return
|
|
166
|
+
try {
|
|
167
|
+
const { PGliteInstance }: any = await import('typeorm-pglite')
|
|
168
|
+
await PGliteInstance.close()
|
|
169
|
+
} catch {
|
|
170
|
+
/* optional dep absent — nothing to close */
|
|
171
|
+
}
|
|
172
|
+
;(global as any).embeddedDatabase = undefined
|
|
173
|
+
}
|
|
@@ -27,6 +27,8 @@ export abstract class User extends BaseEntity {
|
|
|
27
27
|
abstract mfaSecret: string
|
|
28
28
|
abstract mfaType: string
|
|
29
29
|
abstract mfaRecoveryCodes: string[]
|
|
30
|
+
// Absolute TOTP time-step last consumed, used to reject replayed codes within their validity window.
|
|
31
|
+
abstract mfaLastUsedCounter: number
|
|
30
32
|
|
|
31
33
|
abstract setId(id: any): void
|
|
32
34
|
abstract getId(): any
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { DataSource, EntityManager } from 'typeorm'
|
|
3
3
|
import { Tenant } from '../entities/tenant.js'
|
|
4
|
+
import { isEmbeddedActive } from '../embedded.js'
|
|
4
5
|
|
|
5
6
|
export class TenantManager {
|
|
6
7
|
constructor(private dataSource: DataSource) {}
|
|
@@ -146,7 +147,13 @@ export class TenantManager {
|
|
|
146
147
|
name: `sync_${safeSchema}_${Date.now()}`
|
|
147
148
|
})
|
|
148
149
|
await tenantDs.initialize()
|
|
149
|
-
|
|
150
|
+
// On the embedded PGlite engine every DataSource shares ONE WASM instance
|
|
151
|
+
// (PGlitePool.end() -> PGliteInstance.close()), so destroying this ephemeral
|
|
152
|
+
// one would close the PRIMARY connection too. Leave it alive: it only holds a
|
|
153
|
+
// reference to the shared singleton, which the app keeps using.
|
|
154
|
+
if (!isEmbeddedActive()) {
|
|
155
|
+
await tenantDs.destroy()
|
|
156
|
+
}
|
|
150
157
|
|
|
151
158
|
if ((global as any).log?.i)
|
|
152
159
|
(global as any).log.info(`[TenantManager] Schema ${safeSchema} synchronized successfully`)
|
|
@@ -138,7 +138,12 @@ export async function updateUserById(id: string, user: typeof global.entity.User
|
|
|
138
138
|
if (!userEx) {
|
|
139
139
|
throw new ServiceError('User not found', 404)
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
// `password` must NEVER be set through the generic update path: it would be
|
|
142
|
+
// stored in plaintext, bypassing bcrypt (and breaking login). Credential
|
|
143
|
+
// changes go through changePassword / resetPassword, which hash. Drop it here
|
|
144
|
+
// for ALL callers (admin PUT /users/:id included).
|
|
145
|
+
const { password: _ignorePassword, ...safe } = (user as any) || {}
|
|
146
|
+
const merged = repo.merge(userEx, safe)
|
|
142
147
|
return repo.save(merged)
|
|
143
148
|
} catch (error) {
|
|
144
149
|
throw error
|
|
@@ -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
|
+
}
|