@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
@@ -1,18 +1,18 @@
1
1
  import { FastifyReply, FastifyRequest } from 'fastify'
2
2
 
3
3
  export async function count(req: FastifyRequest, _reply: FastifyReply) {
4
- return await req.server['tokenManager'].countQuery(req.data())
4
+ return await req.server['tokenManager'].countQuery(req.data(), req.runner)
5
5
  }
6
6
 
7
7
  export async function find(req: FastifyRequest, reply: FastifyReply) {
8
- const { headers, records } = await req.server['tokenManager'].findQuery(req.data())
8
+ const { headers, records } = await req.server['tokenManager'].findQuery(req.data(), req.runner)
9
9
  return reply.type('application/json').headers(headers).send(records)
10
10
  }
11
11
 
12
12
  export async function findOne(req: FastifyRequest, reply: FastifyReply) {
13
13
  const { id } = req.parameters()
14
14
 
15
- const token = await req.server['tokenManager'].retrieveTokenById(id)
15
+ const token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
16
16
  return token || reply.status(404).send()
17
17
  }
18
18
 
@@ -20,7 +20,7 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
20
20
  const data = req.data()
21
21
 
22
22
  if (!data.name) {
23
- return reply.status(404).send(new Error('Token name not valid'))
23
+ return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'Token name not valid' })
24
24
  }
25
25
 
26
26
  // public is the default
@@ -30,9 +30,9 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
30
30
  data.roles.push(publicRole)
31
31
  }
32
32
 
33
- let token = await req.server['tokenManager'].createToken(data)
33
+ let token = await req.server['tokenManager'].createToken(data, req.runner)
34
34
  if (!token || !token.getId() || !token.externalId) {
35
- return reply.status(400).send(new Error('Token not registered'))
35
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Token not registered' })
36
36
  }
37
37
 
38
38
  const bearerToken = await reply.jwtSign(
@@ -42,10 +42,10 @@ export async function create(req: FastifyRequest, reply: FastifyReply) {
42
42
  }
43
43
  )
44
44
  if (!bearerToken) {
45
- return reply.status(400).send(new Error('Token not signed'))
45
+ return reply.status(400).send({ statusCode: 400, error: 'Bad Request', message: 'Token not signed' })
46
46
  }
47
47
 
48
- token = await req.server['tokenManager'].updateTokenById(token.getId(), { token: bearerToken })
48
+ token = await req.server['tokenManager'].updateTokenById(token.getId(), { token: bearerToken }, req.runner)
49
49
  return token
50
50
  }
51
51
 
@@ -55,12 +55,12 @@ export async function remove(req: FastifyRequest, reply: FastifyReply) {
55
55
  return reply.status(404).send()
56
56
  }
57
57
 
58
- let token = await req.server['tokenManager'].retrieveTokenById(id)
58
+ let token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
59
59
  if (!token) {
60
- return reply.status(403).send(new Error('Token not found'))
60
+ return reply.status(403).send({ statusCode: 403, error: 'Forbidden', message: 'Token not found' })
61
61
  }
62
62
 
63
- token = await req.server['tokenManager'].removeTokenById(id)
63
+ token = await req.server['tokenManager'].removeTokenById(id, req.runner)
64
64
  return { ok: true }
65
65
  }
66
66
 
@@ -70,13 +70,13 @@ export async function update(req: FastifyRequest, reply: FastifyReply) {
70
70
  return reply.status(404).send()
71
71
  }
72
72
 
73
- const token = await req.server['tokenManager'].retrieveTokenById(id)
73
+ const token = await req.server['tokenManager'].retrieveTokenById(id, req.runner)
74
74
  if (!token || !token.getId()) {
75
75
  return reply.status(404).send()
76
76
  }
77
77
 
78
78
  const data = req.data() || {}
79
- return req.server['tokenManager'].updateTokenById(token.getId(), data)
79
+ return req.server['tokenManager'].updateTokenById(token.getId(), data, req.runner)
80
80
  }
81
81
 
82
82
  export async function block(req: FastifyRequest, reply: FastifyReply) {
@@ -87,8 +87,8 @@ export async function block(req: FastifyRequest, reply: FastifyReply) {
87
87
  const { id: userId } = req.parameters()
88
88
  const { reason } = req.data()
89
89
 
90
- await req.server['tokenManager'].blockTokenById(userId, reason)
91
- const token = await req.server['tokenManager'].retrieveTokenById(userId)
90
+ await req.server['tokenManager'].blockTokenById(userId, reason, req.runner)
91
+ const token = await req.server['tokenManager'].retrieveTokenById(userId, req.runner)
92
92
  return { ok: !!token.getId() }
93
93
  }
94
94
 
@@ -100,7 +100,7 @@ export async function unblock(req: FastifyRequest, reply: FastifyReply) {
100
100
  }
101
101
 
102
102
  const { id: userId } = req.parameters()
103
- await req.server['tokenManager'].unblockTokenById(userId)
104
- const token = await req.server['tokenManager'].retrieveTokenById(userId)
103
+ await req.server['tokenManager'].unblockTokenById(userId, req.runner)
104
+ const token = await req.server['tokenManager'].retrieveTokenById(userId, req.runner)
105
105
  return { ok: !!token.getId() }
106
106
  }
@@ -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
- const user = await req.server['userManager'].createUser(data)
39
- return user ? entity.User.save(user) : reply.status(400).send(Error('User not creatable'))
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 { id: _id, ...userData } = req.data()
85
- return await req.server['userManager'].updateUserById(id, userData)
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(new Error('Only admins can reset MFA'))
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(new Error('Missing user id'))
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(new Error('Failed to reset MFA'))
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(new Error('Only admins can reset user passwords'))
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(new Error('Missing user id'))
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(new Error('Missing password in request body'))
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(new Error('User not found'))
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(new Error('Failed to reset password'))
189
+ return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to reset password' })
177
190
  }
178
191
  }
@@ -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'],
@@ -15,6 +15,9 @@ export default {
15
15
  resolver: 'subdomain', // subdomain, header, query
16
16
  header_key: 'x-tenant-id',
17
17
  query_key: 'tid'
18
+ },
19
+ manifest: {
20
+ enabled: false // opt-in: exposes GET /admin/manifest (admin-only) for the backoffice engine
18
21
  }
19
22
  }
20
23
  }
@@ -39,8 +39,11 @@ export default [
39
39
  },
40
40
  {
41
41
  name: 'rateLimit',
42
- enable: false,
43
- options: {}
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
- await tenantDs.destroy()
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
- const merged = repo.merge(userEx, user)
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