@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
@@ -65,7 +65,7 @@ async function loadMiddlewares(base: string, middlewares: string[] = []) {
65
65
  }
66
66
 
67
67
 
68
- function processRoute(
68
+ export function processRoute(
69
69
  route: Route,
70
70
  index: number,
71
71
  file: string,
@@ -90,6 +90,9 @@ function processRoute(
90
90
  let requiredRoles: Role[] = []
91
91
 
92
92
  try {
93
+ // `admin` is a global superuser: it is appended to EVERY route's allowed roles,
94
+ // so an admin can access any endpoint (even ones restricted to e.g. backoffice).
95
+ // This is intentional. Remove this append if admin should NOT be a universal role.
93
96
  requiredRoles = rsp.some((r) => r.code === roles.admin.code) ? rsp : [...rsp, roles.admin]
94
97
  } catch (err) {
95
98
  if (log.e) log.error(`Error in loading roles for ${methodCase} ${pathName} (${handler})`)
@@ -132,7 +135,10 @@ function processRoute(
132
135
  if (!methods.includes(method)) errors.push(`Error in [${file}] bad method [${method}] at route n. ${num}`)
133
136
  if (handlerParts.length !== 2) errors.push(`Error in [${file}] bad handler [${handler}] at route n. ${num}`)
134
137
 
135
- const key = method + endpoint + version
138
+ // Stored routes keep a leading slash in `path` ('/' + endpoint), so the key
139
+ // must include it too — otherwise 'GETusers' never equals 'GET/users' and the
140
+ // duplicate check silently never fires.
141
+ const key = `${method}/${endpoint}${version}`
136
142
  if (validRoutes.some((r) => `${r.method}${r.path}${r.doc?.version}` === key)) {
137
143
  errors.push(`Error in [${file}] duplicated path [${pathName}] at route n. ${num}`)
138
144
  }
@@ -183,7 +189,11 @@ function processRoute(
183
189
  base,
184
190
  file: path.join(base, defaultConfig.controller || 'controller', handlerParts[0]),
185
191
  func: handlerParts[1],
186
- doc: doc
192
+ doc: doc,
193
+ // Structural hints (manifest L1): resource-level, taken from the file-level
194
+ // `config` (defaultConfig) with optional per-route override.
195
+ group: config?.group ?? defaultConfig?.group,
196
+ resource: config?.resource ?? defaultConfig?.resource
187
197
  }
188
198
  }
189
199
 
@@ -287,5 +297,8 @@ async function applyRoutes(server: any, routes: ConfiguredRoute[]): Promise<void
287
297
 
288
298
  export async function apply(server: any): Promise<void> {
289
299
  const routes = await load()
300
+ // Expose the mounted routes (enabled + valid) for introspection — e.g. the admin
301
+ // manifest generator. Read-only by convention; rebuilt on every apply().
302
+ global.routes = routes
290
303
  return await applyRoutes(server, routes)
291
304
  }
@@ -25,7 +25,16 @@ export async function load() {
25
25
 
26
26
  if (enableAll) {
27
27
  changes.forEach((change) => {
28
- const tc: TrackChanges = { primaryKey: primaryKey, changeEntity: changeEntity, ...change } as TrackChanges
28
+ // `enable: true` default must be PERSISTED: the runtime gate
29
+ // (getTrackingConfigIfEnabled) checks `tc.enable`, so a change entry
30
+ // without an explicit `enable` would otherwise never track despite
31
+ // isValid() treating it as enabled.
32
+ const tc: TrackChanges = {
33
+ primaryKey: primaryKey,
34
+ changeEntity: changeEntity,
35
+ enable: true,
36
+ ...change
37
+ } as TrackChanges
29
38
  const code = getCodeBy(tc.method, tc.path)
30
39
 
31
40
  if (code in trackChangesList) {
@@ -0,0 +1,345 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /**
3
+ * Admin Manifest generator (v2) — schema-only, core, no data-layer access.
4
+ *
5
+ * Composes a Manifest from `global.routes` (BE-1/BE-2) + the registered JSON Schemas
6
+ * (`server.getSchemas()`). Contract: `manifest.v2.schema.json` in @volcanicminds/admin
7
+ * (see MANIFEST_DESIGN.md §2/§3). Relations are emitted "magre" (no kind/foreignKey:
8
+ * enriched via admin overrides). First cut — type breadth/relation detection is hardened
9
+ * by BE-7 tests.
10
+ */
11
+ import type { ConfiguredRoute, ResourceHints } from '../../types/global.js'
12
+
13
+ // ── Output types (mirror the v2 JSON Schema; the engine owns the canonical TS type) ──
14
+ type CapabilityKind = 'list' | 'read' | 'create' | 'update' | 'delete' | 'action'
15
+ type FieldType =
16
+ | 'string' | 'text' | 'richtext' | 'integer' | 'number' | 'boolean' | 'date' | 'datetime'
17
+ | 'enum' | 'relation' | 'email' | 'url' | 'uuid' | 'json' | 'image' | 'file'
18
+
19
+ export interface CapabilitySpec {
20
+ name: string
21
+ kind: CapabilityKind
22
+ method: string
23
+ path: string
24
+ roles: string[]
25
+ label?: string
26
+ target?: ('row' | 'bulk' | 'collection')[]
27
+ }
28
+ export interface FieldSpec {
29
+ name: string
30
+ type: FieldType
31
+ required?: boolean
32
+ readOnly?: boolean
33
+ enum?: { value: string; label: string }[]
34
+ validation?: Record<string, any>
35
+ }
36
+ export interface ResourceSpec {
37
+ name: string
38
+ path: string
39
+ label: { singular: string; plural: string }
40
+ group?: string
41
+ titleField?: string | string[]
42
+ subtitleField?: string | string[]
43
+ capabilities: CapabilitySpec[]
44
+ search?: { fields: string[]; operator?: string }
45
+ fields: FieldSpec[]
46
+ }
47
+ export interface Manifest {
48
+ version: 2
49
+ generatedAt: string
50
+ i18n: { defaultLocale: string; locales: string[] }
51
+ auth: { mode: 'cookie' | 'bearer'; endpoints: { login: string; refresh: string; logout: string; [k: string]: string } }
52
+ tenancy: { mode: 'single' | 'multi'; switchable?: boolean; header?: string; listEndpoint?: string }
53
+ groups: { name: string; label: string }[]
54
+ enums: Record<string, { value: string; label: string }[]>
55
+ resources: ResourceSpec[]
56
+ capabilities?: CapabilitySpec[]
57
+ }
58
+
59
+ // Sensitive fields: never exposed vs write-only (form only, never read/list).
60
+ const SENSITIVE_ALWAYS = ['token', 'externalId', 'mfaSecret', 'refreshToken', 'resetPasswordToken', 'confirmationToken']
61
+ const SENSITIVE_WRITE_ONLY = ['password']
62
+
63
+ export interface BuildOptions {
64
+ authMode?: 'cookie' | 'bearer'
65
+ tenancy?: Manifest['tenancy']
66
+ i18n?: Manifest['i18n']
67
+ authEndpoints?: Manifest['auth']['endpoints']
68
+ generatedAt?: string
69
+ sensitiveAlways?: string[]
70
+ sensitiveWriteOnly?: string[]
71
+ }
72
+
73
+ function segments(p: string): string[] {
74
+ return (p || '').split('/').filter(Boolean)
75
+ }
76
+
77
+ function rolesOf(route: ConfiguredRoute): string[] {
78
+ const rs = (route.roles || []) as any[]
79
+ return [...new Set(rs.map((r) => (r && typeof r === 'object' ? r.code : String(r))).filter(Boolean))]
80
+ }
81
+
82
+ /** Resolve a (possibly $ref) schema against the registered schema map. */
83
+ function deref(schema: any, schemas: Record<string, any>, depth = 0): any {
84
+ if (!schema || depth > 10) return schema || null
85
+ if (typeof schema === 'object' && schema.$ref) {
86
+ const id = String(schema.$ref).replace(/#.*$/, '')
87
+ const found =
88
+ schemas[id] || schemas[`${id}#`] || Object.values(schemas).find((s: any) => s && (s.$id === id || s.$id === `${id}#`))
89
+ return found ? deref(found, schemas, depth + 1) : null
90
+ }
91
+ // list responses: unwrap arrays to their item schema
92
+ if (schema.type === 'array' && schema.items) return deref(schema.items, schemas, depth + 1)
93
+ return schema
94
+ }
95
+
96
+ function mapType(ps: any): FieldType {
97
+ if (!ps) return 'string'
98
+ if (Array.isArray(ps.enum) && ps.enum.length) return 'enum'
99
+ switch (ps.type) {
100
+ case 'integer':
101
+ return 'integer'
102
+ case 'number':
103
+ return 'number'
104
+ case 'boolean':
105
+ return 'boolean'
106
+ case 'array':
107
+ case 'object':
108
+ return 'json'
109
+ case 'string':
110
+ switch (ps.format) {
111
+ case 'email':
112
+ return 'email'
113
+ case 'uri':
114
+ case 'url':
115
+ return 'url'
116
+ case 'uuid':
117
+ return 'uuid'
118
+ case 'date-time':
119
+ return 'datetime'
120
+ case 'date':
121
+ return 'date'
122
+ default:
123
+ return 'string'
124
+ }
125
+ default:
126
+ return 'string'
127
+ }
128
+ }
129
+
130
+ function validationOf(ps: any): Record<string, any> | undefined {
131
+ const v: Record<string, any> = {}
132
+ if (typeof ps.minimum === 'number') v.min = ps.minimum
133
+ if (typeof ps.maximum === 'number') v.max = ps.maximum
134
+ if (typeof ps.minLength === 'number') v.minLength = ps.minLength
135
+ if (typeof ps.maxLength === 'number') v.maxLength = ps.maxLength
136
+ if (typeof ps.pattern === 'string') v.pattern = ps.pattern
137
+ return Object.keys(v).length ? v : undefined
138
+ }
139
+
140
+ interface FieldAcc extends FieldSpec {
141
+ _read?: boolean
142
+ _write?: boolean
143
+ }
144
+
145
+ /** Map a CRUD verb (or null for a custom action) from method + relative path. */
146
+ function crudKind(method: string, rest: string[]): CapabilityKind | null {
147
+ if (rest.length === 0) {
148
+ if (method === 'GET') return 'list'
149
+ if (method === 'POST') return 'create'
150
+ if (method === 'DELETE') return 'delete'
151
+ return null
152
+ }
153
+ if (rest.length === 1 && rest[0].startsWith(':')) {
154
+ if (method === 'GET') return 'read'
155
+ if (method === 'PUT' || method === 'PATCH') return 'update'
156
+ if (method === 'DELETE') return 'delete'
157
+ return null
158
+ }
159
+ return null
160
+ }
161
+
162
+ export function buildManifest(input: {
163
+ routes: ConfiguredRoute[]
164
+ schemas: Record<string, any>
165
+ options?: BuildOptions
166
+ }): Manifest {
167
+ const { routes = [], schemas = {}, options = {} } = input
168
+ const sensitiveAlways = options.sensitiveAlways ?? SENSITIVE_ALWAYS
169
+ const sensitiveWriteOnly = options.sensitiveWriteOnly ?? SENSITIVE_WRITE_ONLY
170
+
171
+ // group routes by URL base segment
172
+ const bySegment = new Map<string, ConfiguredRoute[]>()
173
+ for (const r of routes) {
174
+ const segs = segments(r.path)
175
+ if (!segs.length) continue
176
+ const base = segs[0]
177
+ if (!bySegment.has(base)) bySegment.set(base, [])
178
+ bySegment.get(base)!.push(r)
179
+ }
180
+
181
+ const resources: ResourceSpec[] = []
182
+ const topCapabilities: CapabilitySpec[] = []
183
+ const groupNames = new Set<string>()
184
+
185
+ for (const [base, segRoutes] of bySegment) {
186
+ const hint: ResourceHints | undefined = segRoutes.find((r) => r.resource)?.resource
187
+ const name = hint?.name || base
188
+ const group = segRoutes.find((r) => r.group)?.group
189
+ if (group) groupNames.add(group)
190
+
191
+ // capabilities (CRUD deduped by kind + custom actions)
192
+ const crud = new Map<CapabilityKind, CapabilitySpec>()
193
+ const actions: CapabilitySpec[] = []
194
+ const usedNames = new Set<string>()
195
+ let hasItemDelete = false
196
+ let hasCollDelete = false
197
+ for (const r of segRoutes) {
198
+ const rest = segments(r.path).slice(1)
199
+ if (rest[rest.length - 1] === 'count') continue // internal pagination helper
200
+ const kind = crudKind(r.method, rest)
201
+ if (kind) {
202
+ if (kind === 'delete') {
203
+ if (rest.length === 0) hasCollDelete = true
204
+ else hasItemDelete = true
205
+ }
206
+ // prefer the item path (/:id) for the delete capability binding
207
+ if (!crud.has(kind) || (kind === 'delete' && rest.length === 1)) {
208
+ crud.set(kind, { name: kind, kind, method: r.method, path: r.path, roles: rolesOf(r) })
209
+ }
210
+ } else {
211
+ const lastNamed = [...rest].reverse().find((s) => !s.startsWith(':'))
212
+ let actName = lastNamed || r.method.toLowerCase()
213
+ while (usedNames.has(actName)) actName = `${actName}_${r.method.toLowerCase()}`
214
+ usedNames.add(actName)
215
+ actions.push({
216
+ name: actName,
217
+ kind: 'action',
218
+ method: r.method,
219
+ path: r.path,
220
+ roles: rolesOf(r),
221
+ label: `action.${name}.${actName}`,
222
+ target: rest.some((s) => s.startsWith(':')) ? ['row'] : ['collection']
223
+ })
224
+ }
225
+ }
226
+ const del = crud.get('delete')
227
+ if (del) {
228
+ const t: ('row' | 'bulk')[] = []
229
+ if (hasItemDelete) t.push('row')
230
+ if (hasCollDelete) t.push('bulk')
231
+ if (t.length) del.target = t
232
+ }
233
+ const caps: CapabilitySpec[] = [...crud.values(), ...actions]
234
+
235
+ const isResource = crud.size > 0
236
+ if (!isResource) {
237
+ topCapabilities.push(...caps)
238
+ continue
239
+ }
240
+
241
+ // fields — collapse body (writable) + response (readable) onto (resource, field)
242
+ const fields = collectFields(segRoutes, schemas, sensitiveAlways, sensitiveWriteOnly)
243
+
244
+ const resource: ResourceSpec = {
245
+ name,
246
+ path: base,
247
+ label: { singular: `res.${name}.singular`, plural: `res.${name}.plural` },
248
+ capabilities: caps,
249
+ fields
250
+ }
251
+ if (group) resource.group = group
252
+ if (hint?.titleField) resource.titleField = hint.titleField
253
+ if (hint?.subtitleField) resource.subtitleField = hint.subtitleField
254
+ if (hint?.globalSearch?.length) resource.search = { fields: hint.globalSearch, operator: 'containsi' }
255
+ resources.push(resource)
256
+ }
257
+
258
+ const manifest: Manifest = {
259
+ version: 2,
260
+ generatedAt: options.generatedAt || '1970-01-01T00:00:00.000Z',
261
+ i18n: options.i18n || { defaultLocale: 'en', locales: ['en'] },
262
+ auth: {
263
+ mode: options.authMode || 'bearer',
264
+ endpoints: options.authEndpoints || { login: '/auth/login', refresh: '/auth/refresh-token', logout: '/auth/logout' }
265
+ },
266
+ tenancy: options.tenancy || { mode: 'single' },
267
+ groups: [...groupNames].map((n) => ({ name: n, label: `group.${n}` })),
268
+ enums: {},
269
+ resources
270
+ }
271
+ if (topCapabilities.length) manifest.capabilities = topCapabilities
272
+ return manifest
273
+ }
274
+
275
+ function collectFields(
276
+ segRoutes: ConfiguredRoute[],
277
+ schemas: Record<string, any>,
278
+ sensitiveAlways: string[],
279
+ sensitiveWriteOnly: string[]
280
+ ): FieldSpec[] {
281
+ const byName = new Map<string, FieldAcc>()
282
+
283
+ const addSide = (schema: any, side: 'read' | 'write') => {
284
+ const s = deref(schema, schemas)
285
+ const props = s?.properties
286
+ if (!props) return
287
+ const required: string[] = Array.isArray(s.required) ? s.required : []
288
+ for (const [fname, ps] of Object.entries<any>(props)) {
289
+ if (sensitiveAlways.includes(fname)) continue
290
+ if (side === 'read' && sensitiveWriteOnly.includes(fname)) continue // never readable
291
+ let f = byName.get(fname)
292
+ if (!f) {
293
+ f = { name: fname, type: mapType(ps) }
294
+ byName.set(fname, f)
295
+ }
296
+ // a typed side wins over a bare 'string' fallback; merge validation/enum from either side
297
+ if (f.type === 'string' && mapType(ps) !== 'string') f.type = mapType(ps)
298
+ if (!f.validation) {
299
+ const val = validationOf(ps)
300
+ if (val) f.validation = val
301
+ }
302
+ if (!f.enum && Array.isArray(ps.enum) && ps.enum.length) {
303
+ f.enum = ps.enum.map((v: any) => ({ value: String(v), label: `enum.${fname}.${v}` }))
304
+ }
305
+ if (required.includes(fname)) f.required = true
306
+ if (side === 'read') f._read = true
307
+ if (side === 'write') f._write = true
308
+ }
309
+ }
310
+
311
+ for (const r of segRoutes) {
312
+ const rest = segments(r.path).slice(1)
313
+ const kind = crudKind(r.method, rest)
314
+ if (kind === 'create' || kind === 'update') addSide(r.doc?.body, 'write')
315
+ if (kind === 'read' || kind === 'list') {
316
+ const resp = r.doc?.response
317
+ const schema = resp && typeof resp === 'object' && !resp.type && !resp.$ref ? resp['200'] || resp[200] || Object.values(resp)[0] : resp
318
+ addSide(schema, 'read')
319
+ }
320
+ }
321
+
322
+ const out: FieldSpec[] = []
323
+ for (const f of byName.values()) {
324
+ const { _read, _write, ...rest } = f
325
+ if (_read && !_write) rest.readOnly = true
326
+ out.push(rest)
327
+ }
328
+ return out
329
+ }
330
+
331
+ /** Build the manifest from the live server (`global.routes` + registered schemas). */
332
+ export function generateManifest(server: any, options: BuildOptions = {}): Manifest {
333
+ const routes: ConfiguredRoute[] = ((global as any).routes as ConfiguredRoute[]) || []
334
+ const schemas: Record<string, any> = typeof server?.getSchemas === 'function' ? server.getSchemas() : {}
335
+ const authMode: 'cookie' | 'bearer' = process.env.AUTH_MODE === 'COOKIE' ? 'cookie' : 'bearer'
336
+ const mt = (global as any).config?.options?.multi_tenant
337
+ const tenancy: Manifest['tenancy'] = mt?.enabled
338
+ ? { mode: 'multi', switchable: true, header: mt.header_key || 'x-tenant-id', listEndpoint: '/tenants' }
339
+ : { mode: 'single' }
340
+ return buildManifest({
341
+ routes,
342
+ schemas,
343
+ options: { authMode, tenancy, generatedAt: new Date().toISOString(), ...options }
344
+ })
345
+ }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { FastifyReply, FastifyRequest } from 'fastify'
3
+ import { httpError } from '../util/httpError.js'
3
4
 
4
5
  export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
5
6
  try {
@@ -10,6 +11,7 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
10
11
  throw new Error('User without this privilege')
11
12
  } catch (err) {
12
13
  if (log.e) log.error(`Upps, something just happened ${err}`)
13
- res.code(403).send(new Error('User without this privilege'))
14
+ // Structured body so the 403 status is preserved (see isAuthenticated note).
15
+ res.code(403).send(httpError(403, 'User without this privilege', 'FORBIDDEN'))
14
16
  }
15
17
  }
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { FastifyReply, FastifyRequest } from 'fastify'
3
+ import { httpError } from '../util/httpError.js'
3
4
 
4
5
  export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
5
6
  try {
@@ -10,6 +11,8 @@ export function preHandler(req: FastifyRequest, res: FastifyReply, done: any) {
10
11
  throw new Error('Unauthorized')
11
12
  } catch (err) {
12
13
  if (log.e) log.error(`Upps, something just happened ${err}`)
13
- return res.code(401).send(new Error('Unauthorized')) // must be authorized first
14
+ // Send a structured body (not a raw Error): `reply.code(x).send(new Error())`
15
+ // loses the status in Fastify's async error path and collapses to 500/403.
16
+ return res.code(401).send(httpError(401, 'Unauthorized', 'UNAUTHORIZED')) // must be authorized first
14
17
  }
15
18
  }
@@ -2,8 +2,15 @@ export const currentUserBodySchema = {
2
2
  $id: 'currentUserBodySchema',
3
3
  type: 'object',
4
4
  nullable: true,
5
+ // Self-service profile edit only. `additionalProperties: false` rejects any other
6
+ // field (roles, blocked, confirmed, password, externalId, mfa*) so a user cannot
7
+ // mass-assign their way to privilege escalation. The controller also whitelists
8
+ // these same fields as the real security boundary (defense in depth).
9
+ additionalProperties: false,
5
10
  properties: {
6
- username: { type: 'string' }
11
+ username: { type: 'string' },
12
+ firstName: { type: 'string' },
13
+ lastName: { type: 'string' }
7
14
  }
8
15
  }
9
16
 
@@ -11,9 +18,18 @@ export const userBodySchema = {
11
18
  $id: 'userBodySchema',
12
19
  type: 'object',
13
20
  nullable: true,
21
+ // Admin create/update surface. `additionalProperties: false` drops anything not
22
+ // listed (e.g. externalId, mfaSecret, passwordChangedAt). `password` is allowed
23
+ // for CREATE (createUser hashes it); on UPDATE it is dropped in updateUserById so
24
+ // it can never be stored in plaintext — credential changes go through the
25
+ // dedicated hashed flows (change-password / reset-password).
26
+ additionalProperties: false,
14
27
  properties: {
15
28
  username: { type: 'string' },
16
29
  email: { type: 'string' },
30
+ password: { type: 'string' },
31
+ firstName: { type: 'string' },
32
+ lastName: { type: 'string' },
17
33
  blocked: { type: 'boolean' },
18
34
  blockedReason: { type: 'string' },
19
35
  blockedAt: { type: 'string' },
@@ -12,8 +12,10 @@ export class TranslatedError extends Error {
12
12
  this.locale = locale
13
13
  this.status = status
14
14
  this.translationCode = translationCode
15
- this.translatedMessage = global.t.__({ phrase: translationCode || defaultMessage, locale: locale || 'en' }, data)
16
- this.message = this.translatedMessage || defaultMessage || 'generic error'
15
+ const phrase = translationCode || defaultMessage
16
+ this.translatedMessage = phrase ? global.t.__({ phrase, locale: locale || 'en' }, data) : null
17
+ // fallback: tradotto -> code grezzo -> defaultMessage -> catch-all
18
+ this.message = this.translatedMessage || translationCode || defaultMessage || 'generic error'
17
19
  this.data = data
18
20
 
19
21
  Error.captureStackTrace(this, this.constructor || TranslatedError)
@@ -0,0 +1,37 @@
1
+ // Standard error response body for the whole framework.
2
+ //
3
+ // { statusCode, error, code?, message? }
4
+ //
5
+ // statusCode : HTTP status
6
+ // error : HTTP reason phrase ('Bad Request', 'Unauthorized', ...)
7
+ // code : optional machine-readable code ('TENANT_MISMATCH', 'MFA_REQUIRED', ...)
8
+ // message : optional human-readable message
9
+ //
10
+ // Use this instead of `reply.send(new Error(...))`, which loses the status in
11
+ // Fastify's async error path (collapses to 500/403).
12
+ const REASON: Record<number, string> = {
13
+ 400: 'Bad Request',
14
+ 401: 'Unauthorized',
15
+ 403: 'Forbidden',
16
+ 404: 'Not Found',
17
+ 409: 'Conflict',
18
+ 422: 'Unprocessable Entity',
19
+ 429: 'Too Many Requests',
20
+ 500: 'Internal Server Error'
21
+ }
22
+
23
+ export interface HttpErrorBody {
24
+ statusCode: number
25
+ error: string
26
+ code?: string
27
+ message?: string
28
+ }
29
+
30
+ export function httpError(statusCode: number, message?: string, code?: string): HttpErrorBody {
31
+ return {
32
+ statusCode,
33
+ error: REASON[statusCode] || 'Error',
34
+ ...(code ? { code } : {}),
35
+ ...(message ? { message } : {})
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volcanicminds/backend",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "codename": "rome",
6
6
  "license": "MIT",
@@ -59,8 +59,18 @@
59
59
  "start": "tsx --env-file .env server.ts",
60
60
  "dev": "tsx watch --env-file .env server.ts",
61
61
  "test": "npm run test:core && npm run test:typeorm",
62
- "test:core": "cross-env PORT=2231 NODE_ENV=memory BROWSER=false mocha --import=tsx ./test/index.spec.ts -t 100000 --exit",
62
+ "test:core": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2231 NODE_ENV=memory BROWSER=false JWT_SECRET=tEst0nly_k7Qx2mNp9vLrT4wYbE6sH1jD8fG3aZ5c JWT_REFRESH_SECRET=tEst0nly_R3frsh9vLrT4wYbE6sH1jD8fG3aZ5cK7 mocha --import=tsx ./test/index.spec.ts -t 100000 --exit",
63
63
  "test:typeorm": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/typeorm/**/*.spec.ts -t 20000 --exit",
64
+ "test:pglite": "cross-env NODE_ENV=memory MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/pglite/**/*.spec.ts -t 60000 --exit",
65
+ "test:perf": "cross-env PORT=2233 NODE_ENV=memory LOG_LEVEL=error LOG_FASTIFY=false MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/perf/**/*.spec.ts -t 120000 --exit",
66
+ "test:e2e:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2234 NODE_ENV=memory LOG_LEVEL=silent JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e/**/*.e2e.spec.ts -t 60000 --exit",
67
+ "test:e2e:mt:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2235 NODE_ENV=memory LOG_LEVEL=silent JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-mt/**/*.e2e.spec.ts -t 60000 --exit",
68
+ "test:e2e:cookie:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2236 NODE_ENV=memory LOG_LEVEL=silent AUTH_MODE=COOKIE COOKIE_SECRET=e2e-cookie-secret-please-change-32xyz JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-cookie/**/*.e2e.spec.ts -t 60000 --exit",
69
+ "test:e2e:norefresh:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2237 NODE_ENV=memory LOG_LEVEL=silent JWT_REFRESH=false JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-norefresh/**/*.e2e.spec.ts -t 60000 --exit",
70
+ "test:e2e:mfa:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2238 NODE_ENV=memory LOG_LEVEL=silent JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-mfa/**/*.e2e.spec.ts -t 60000 --exit",
71
+ "test:e2e:ratelimit:pglite": "cross-env AUTH_RATELIMIT_MAX=3 AUTH_RATELIMIT_WINDOW=60000 PORT=2239 NODE_ENV=memory LOG_LEVEL=silent JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-ratelimit/**/*.e2e.spec.ts -t 60000 --exit",
72
+ "test:e2e:fixture:pglite": "cross-env AUTH_RATELIMIT_MAX=100000 PORT=2240 NODE_ENV=memory LOG_LEVEL=silent JWT_SECRET=e2e-test-secret-please-change-32-chars MFA_DB_SECRET=unit-test-secret-please-change-32xyz mocha --import=tsx ./test/e2e-fixture/**/*.e2e.spec.ts -t 60000 --exit",
73
+ "test:lib": "cross-env NODE_ENV=memory mocha --import=tsx ./test/lib/**/*.spec.ts -t 20000 --exit",
64
74
  "test:full": "npm run test:core",
65
75
  "reset": "npm install && npm update && npm run build",
66
76
  "upgrade-deps": "npx npm-check-updates -u",
@@ -98,16 +108,28 @@
98
108
  "toad-scheduler": "^3.1.0"
99
109
  },
100
110
  "peerDependencies": {
111
+ "@electric-sql/pglite": "0.5.3",
112
+ "@electric-sql/pglite-pgvector": "^0.0.4",
101
113
  "bcrypt": "^6.0.0",
102
114
  "pg": "^8.13.0",
103
115
  "pluralize": "^8.0.0",
104
116
  "reflect-metadata": "^0.2.2",
105
- "typeorm": "^0.3.28"
117
+ "typeorm": "^0.3.28",
118
+ "typeorm-pglite": "^0.3.4"
106
119
  },
107
120
  "peerDependenciesMeta": {
108
121
  "typeorm": {
109
122
  "optional": true
110
123
  },
124
+ "typeorm-pglite": {
125
+ "optional": true
126
+ },
127
+ "@electric-sql/pglite": {
128
+ "optional": true
129
+ },
130
+ "@electric-sql/pglite-pgvector": {
131
+ "optional": true
132
+ },
111
133
  "bcrypt": {
112
134
  "optional": true
113
135
  },
@@ -122,6 +144,8 @@
122
144
  }
123
145
  },
124
146
  "devDependencies": {
147
+ "@electric-sql/pglite": "0.5.3",
148
+ "@electric-sql/pglite-pgvector": "^0.0.4",
125
149
  "@eslint/js": "^9.39.2",
126
150
  "@types/bcrypt": "^6.0.0",
127
151
  "@types/mocha": "^10.0.10",
@@ -129,6 +153,7 @@
129
153
  "@types/pg": "^8.11.10",
130
154
  "@types/pluralize": "^0.0.33",
131
155
  "@types/semver": "^7.7.1",
156
+ "autocannon": "^8.0.0",
132
157
  "bcrypt": "^6.0.0",
133
158
  "cross-env": "^10.1.0",
134
159
  "dependency-cruiser": "^18.0.0",
@@ -136,13 +161,13 @@
136
161
  "expect": "^30.2.0",
137
162
  "globals": "^16.5.0",
138
163
  "mocha": "^8.1.3",
139
- "npm-upgrade": "^0.3.0",
140
164
  "pg": "^8.13.0",
141
165
  "pluralize": "^8.0.0",
142
166
  "reflect-metadata": "^0.2.2",
143
167
  "ts-node": "^10.9.2",
144
168
  "tsx": "^4.21.0",
145
169
  "typeorm": "^0.3.28",
170
+ "typeorm-pglite": "^0.3.4",
146
171
  "typescript": "^5.9.3",
147
172
  "typescript-eslint": "^8.50.0"
148
173
  },