@volcanicminds/backend 3.6.2 → 4.0.1

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 (60) hide show
  1. package/README.md +56 -5
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/api/admin/routes.d.ts +1 -1
  6. package/dist/lib/api/admin/routes.d.ts.map +1 -1
  7. package/dist/lib/api/admin/routes.js +1 -1
  8. package/dist/lib/api/admin/routes.js.map +1 -1
  9. package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
  10. package/dist/lib/api/auth/controller/auth.js +5 -8
  11. package/dist/lib/api/auth/controller/auth.js.map +1 -1
  12. package/dist/lib/api/token/controller/token.d.ts +2 -2
  13. package/dist/lib/api/token/controller/token.d.ts.map +1 -1
  14. package/dist/lib/api/token/controller/token.js +13 -10
  15. package/dist/lib/api/token/controller/token.js.map +1 -1
  16. package/dist/lib/api/token/routes.d.ts +7 -7
  17. package/dist/lib/api/token/routes.js +8 -8
  18. package/dist/lib/api/token/routes.js.map +1 -1
  19. package/dist/lib/api/users/controller/user.d.ts.map +1 -1
  20. package/dist/lib/api/users/controller/user.js +65 -18
  21. package/dist/lib/api/users/controller/user.js.map +1 -1
  22. package/dist/lib/api/users/routes.d.ts +45 -8
  23. package/dist/lib/api/users/routes.d.ts.map +1 -1
  24. package/dist/lib/api/users/routes.js +10 -10
  25. package/dist/lib/api/users/routes.js.map +1 -1
  26. package/dist/lib/config/roles.d.ts.map +1 -1
  27. package/dist/lib/config/roles.js +0 -5
  28. package/dist/lib/config/roles.js.map +1 -1
  29. package/dist/lib/loader/genesis.d.ts +6 -0
  30. package/dist/lib/loader/genesis.d.ts.map +1 -0
  31. package/dist/lib/loader/genesis.js +51 -0
  32. package/dist/lib/loader/genesis.js.map +1 -0
  33. package/dist/lib/loader/roles.d.ts +3 -1
  34. package/dist/lib/loader/roles.d.ts.map +1 -1
  35. package/dist/lib/loader/roles.js +23 -4
  36. package/dist/lib/loader/roles.js.map +1 -1
  37. package/dist/lib/loader/router.d.ts +1 -1
  38. package/dist/lib/loader/router.d.ts.map +1 -1
  39. package/dist/lib/loader/router.js +50 -14
  40. package/dist/lib/loader/router.js.map +1 -1
  41. package/dist/lib/util/authz.d.ts +4 -0
  42. package/dist/lib/util/authz.d.ts.map +1 -0
  43. package/dist/lib/util/authz.js +16 -0
  44. package/dist/lib/util/authz.js.map +1 -0
  45. package/dist/types/global.d.ts +10 -1
  46. package/lib/api/admin/controller/manifest.ts +2 -2
  47. package/lib/api/admin/routes.ts +3 -4
  48. package/lib/api/auth/controller/auth.ts +7 -10
  49. package/lib/api/token/controller/token.ts +20 -12
  50. package/lib/api/token/routes.ts +8 -8
  51. package/lib/api/users/controller/user.ts +101 -22
  52. package/lib/api/users/routes.ts +10 -10
  53. package/lib/config/general.ts +1 -1
  54. package/lib/config/roles.ts +0 -5
  55. package/lib/hooks/onRequest.ts +2 -2
  56. package/lib/loader/genesis.ts +76 -0
  57. package/lib/loader/roles.ts +34 -5
  58. package/lib/loader/router.ts +75 -15
  59. package/lib/util/authz.ts +26 -0
  60. package/package.json +1 -1
@@ -14,7 +14,7 @@ export default {
14
14
  {
15
15
  method: 'GET',
16
16
  path: '/',
17
- roles: [roles.admin],
17
+ requireCapability: 'users',
18
18
  handler: 'user.find',
19
19
  middlewares: ['global.isAuthenticated'],
20
20
  config: {
@@ -33,7 +33,7 @@ export default {
33
33
  {
34
34
  method: 'GET',
35
35
  path: '/count',
36
- roles: [roles.admin],
36
+ requireCapability: 'users',
37
37
  handler: 'user.count',
38
38
  middlewares: ['global.isAuthenticated'],
39
39
  config: {
@@ -71,7 +71,7 @@ export default {
71
71
  {
72
72
  method: 'GET',
73
73
  path: '/:id',
74
- roles: [roles.admin],
74
+ requireCapability: 'users',
75
75
  handler: 'user.findOne',
76
76
  middlewares: ['global.isAuthenticated'],
77
77
  config: {
@@ -89,7 +89,7 @@ export default {
89
89
  {
90
90
  method: 'PUT',
91
91
  path: '/:id',
92
- roles: [roles.admin],
92
+ requireCapability: 'users',
93
93
  handler: 'user.update',
94
94
  middlewares: ['global.isAuthenticated'],
95
95
  config: {
@@ -108,7 +108,7 @@ export default {
108
108
  {
109
109
  method: 'POST',
110
110
  path: '/',
111
- roles: [roles.admin],
111
+ requireCapability: 'users',
112
112
  handler: 'user.create',
113
113
  middlewares: ['global.isAuthenticated'],
114
114
  config: {
@@ -126,7 +126,7 @@ export default {
126
126
  {
127
127
  method: 'DELETE',
128
128
  path: '/:id',
129
- roles: [roles.admin],
129
+ requireCapability: 'users',
130
130
  handler: 'user.remove',
131
131
  middlewares: ['global.isAuthenticated'],
132
132
  config: {
@@ -190,7 +190,7 @@ export default {
190
190
  {
191
191
  method: 'POST',
192
192
  path: '/:id/block',
193
- roles: [roles.admin, roles.backoffice],
193
+ requireCapability: 'users',
194
194
  handler: 'user.block',
195
195
  middlewares: ['global.isAuthenticated'],
196
196
  config: {
@@ -206,7 +206,7 @@ export default {
206
206
  {
207
207
  method: 'POST',
208
208
  path: '/:id/unblock',
209
- roles: [roles.admin, roles.backoffice],
209
+ requireCapability: 'users',
210
210
  handler: 'user.unblock',
211
211
  middlewares: ['global.isAuthenticated'],
212
212
  config: {
@@ -221,7 +221,7 @@ export default {
221
221
  {
222
222
  method: 'POST',
223
223
  path: '/:id/mfa/reset',
224
- roles: [roles.admin],
224
+ requireCapability: 'users',
225
225
  handler: 'user.resetMfaByAdmin',
226
226
  middlewares: ['global.isAuthenticated'],
227
227
  config: {
@@ -236,7 +236,7 @@ export default {
236
236
  {
237
237
  method: 'POST',
238
238
  path: '/:id/password/reset',
239
- roles: [roles.admin],
239
+ requireCapability: 'users',
240
240
  handler: 'user.resetPasswordByAdmin',
241
241
  middlewares: ['global.isAuthenticated'],
242
242
  config: {
@@ -19,7 +19,7 @@ export default {
19
19
  query_key: 'tid'
20
20
  },
21
21
  manifest: {
22
- // opt-in: exposes GET /admin/manifest (admin-only) for the backoffice engine
22
+ // opt-in: exposes GET /admin/manifest (gated by the `manifest` capability) for the admin console
23
23
  enabled: false
24
24
  },
25
25
  cache: {
@@ -8,10 +8,5 @@ export default [
8
8
  code: 'admin',
9
9
  name: 'Admin',
10
10
  description: 'Admin role'
11
- },
12
- {
13
- code: 'backoffice',
14
- name: 'Backoffice',
15
- description: 'Backoffice role'
16
11
  }
17
12
  ]
@@ -150,8 +150,8 @@ export default async (req, reply) => {
150
150
  // A route open to `public` is open to EVERY caller: anonymous requests already
151
151
  // pass (they carry the `public` role), and an authenticated subject must never
152
152
  // rank below anonymous — without this, a user whose roles don't include
153
- // `public` (e.g. only `backoffice`) would get 403 on public routes such as
154
- // /users/me or /auth/change-password.
153
+ // `public` (e.g. only a custom consumer role) would get 403 on public routes
154
+ // such as /users/me or /auth/change-password.
155
155
  const isPublicRoute = requiredRoles.some((r) => r.code === roles.public.code)
156
156
  const hasPermission = isPublicRoute || requiredRoles.some((r) => authorizedRoles.includes(r.code))
157
157
 
@@ -0,0 +1,76 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import crypto from 'crypto'
3
+ import { includesRole } from '../util/authz.js'
4
+
5
+ // Random credential for a generated founder. base64url is alphanumeric; the suffix
6
+ // satisfies any upper/lower/digit/symbol policy. Printed once; rotate after first login.
7
+ function generatePassword(): string {
8
+ return crypto.randomBytes(24).toString('base64url') + 'aA1!'
9
+ }
10
+
11
+ export interface GenesisOptions {
12
+ // Called instead of process.exit(1) on the fail-fast path (injected by tests).
13
+ onFatal?: (message: string) => void
14
+ }
15
+
16
+ /**
17
+ * Ensure the instance never boots with zero admins (single-tenant only; multi-tenant
18
+ * tenant admins come from provisioning and the system founder is seeded out-of-band).
19
+ * Runs against the default connection (public schema) via the injected userManager.
20
+ *
21
+ * - `ADMIN_EMAIL` set → create it (as the sovereign founder) if missing, or promote it
22
+ * to admin if it exists without the role; no-op if it is already an admin.
23
+ * - `ADMIN_EMAIL` unset → allowed only when an admin already exists, otherwise fail-fast.
24
+ */
25
+ export async function ensureGenesisAdmin(server: FastifyInstance, opts: GenesisOptions = {}): Promise<void> {
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ const um = (server as any)?.['userManager']
28
+ if (!um?.isImplemented?.()) return
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ if (!(global as any).connection) return // no live data layer (e.g. core-only boot)
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ if ((global as any).config?.options?.multi_tenant?.enabled) return
33
+
34
+ const onFatal =
35
+ opts.onFatal ||
36
+ ((message: string) => {
37
+ if (log?.f) log.fatal(message)
38
+ process.exit(1)
39
+ })
40
+
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const adminCode = (global as any).roles?.admin?.code || 'admin'
43
+ const email = process.env.ADMIN_EMAIL?.trim()
44
+
45
+ if (!email) {
46
+ const count = Number(await um.countQuery({ 'roles:in': adminCode }))
47
+ if (count === 0) {
48
+ onFatal('Startup: no admin exists and ADMIN_EMAIL is not set to bootstrap one. Set ADMIN_EMAIL.')
49
+ }
50
+ return
51
+ }
52
+
53
+ const existing = await um.retrieveUserByEmail(email)
54
+ if (existing) {
55
+ if (!includesRole(existing.roles, adminCode)) {
56
+ await um.updateUserById(existing.getId(), { roles: [...(existing.roles || []), adminCode] })
57
+ if (log?.i) log.info(`Startup: promoted ${email} to admin (sovereign founder).`)
58
+ }
59
+ return
60
+ }
61
+
62
+ const envPassword = process.env.ADMIN_PASSWORD
63
+ const password = envPassword || generatePassword()
64
+ const created = await um.createUser({ email, username: email, password, roles: [adminCode] })
65
+ await um.userConfirmation(created)
66
+ if (!envPassword) {
67
+ // The generated secret goes to stdout only — never through the structured logger,
68
+ // which may be shipped, retained, or indexed. Set ADMIN_PASSWORD to avoid disclosure.
69
+ process.stdout.write(
70
+ `\n[genesis] Created sovereign founder ${email} with a generated password: ${password}\n[genesis] Rotate it after first login.\n\n`
71
+ )
72
+ if (log?.w) log.warn(`Startup: created sovereign founder ${email} with a generated password (printed to stdout).`)
73
+ } else if (log?.i) {
74
+ log.info(`Startup: created sovereign founder ${email}.`)
75
+ }
76
+ }
@@ -2,6 +2,39 @@ import type { Role, Roles } from '../../types/global.js'
2
2
  import { normalizePatterns } from '../util/path.js'
3
3
  import { globSync } from 'glob'
4
4
 
5
+ // `admin` and `public` are protected built-ins: a consumer's config/roles.ts may
6
+ // override only their labels (name/description); their `code` and `capabilities`
7
+ // are locked. Every other role code is added/overridden in full. Framework defaults
8
+ // are loaded first, so by the time a consumer's roles are applied the protected
9
+ // codes already exist. See docs/AUTHORIZATION_MODEL.md §2.3.
10
+ export const PROTECTED_ROLE_CODES = ['admin', 'public']
11
+
12
+ /**
13
+ * Merge one config file's roles into the accumulator with the protected-merge rule.
14
+ * Pure (its only effect is on the passed `roles` map) so it is unit-tested directly.
15
+ */
16
+ export function mergeRoles(roles: Roles, configRoles: Role[]): Roles {
17
+ const protectedCodes = new Set(PROTECTED_ROLE_CODES)
18
+
19
+ for (const role of configRoles || []) {
20
+ if (!role?.code) continue
21
+
22
+ const existing = roles[role.code]
23
+ if (existing && protectedCodes.has(role.code)) {
24
+ // Protected built-in: only labels are overridable; code + capabilities locked.
25
+ if (typeof role.name === 'string') existing.name = role.name
26
+ if (typeof role.description === 'string') existing.description = role.description
27
+ if (role.capabilities?.length && log?.w) {
28
+ log.warn(`Roles: capabilities on protected role '${role.code}' are ignored`)
29
+ }
30
+ } else {
31
+ roles[role.code] = role
32
+ }
33
+ }
34
+
35
+ return roles
36
+ }
37
+
5
38
  export async function load() {
6
39
  const roles: Roles = {}
7
40
 
@@ -13,11 +46,7 @@ export async function load() {
13
46
 
14
47
  for (const f of files) {
15
48
  const module = await import(f)
16
- const configRoles = module.default || module
17
-
18
- configRoles.forEach((role: Role) => {
19
- roles[role.code] = role
20
- })
49
+ mergeRoles(roles, (module.default || module) as Role[])
21
50
  }
22
51
  }
23
52
 
@@ -66,6 +66,52 @@ async function loadMiddlewares(base: string, middlewares: string[] = []) {
66
66
  }
67
67
 
68
68
 
69
+ // Resolve a route's declared roles (Role objects or string codes) plus its optional
70
+ // capability into the final allowed-role set. Object refs are trusted; string codes and
71
+ // undefined entries that don't resolve against the `roles` catalog are collected in
72
+ // `roleErrors` for the fail-fast at load. admin is always appended (universal superuser);
73
+ // a route with neither roles nor a capability defaults to public.
74
+ function resolveRequiredRoles(
75
+ rs: (Role | string)[],
76
+ capability: string | undefined,
77
+ where: string,
78
+ roleErrors: string[]
79
+ ): Role[] {
80
+ const declared: Role[] = []
81
+ for (const ref of rs) {
82
+ if (ref == null) {
83
+ roleErrors.push(`${where} → references an undefined role`)
84
+ } else if (typeof ref === 'string') {
85
+ const resolved = roles[ref]
86
+ if (resolved) declared.push(resolved)
87
+ else roleErrors.push(`${where} → unknown role '${ref}' (not declared in config/roles.ts)`)
88
+ } else if (ref.code) {
89
+ declared.push(ref)
90
+ } else {
91
+ roleErrors.push(`${where} → a declared role has no code`)
92
+ }
93
+ }
94
+
95
+ const capRoles: Role[] = capability
96
+ ? Object.values(roles).filter((r) => Array.isArray(r.capabilities) && r.capabilities.includes(capability))
97
+ : []
98
+ if (capability && capRoles.length === 0 && log?.w) {
99
+ log.warn(`Route ${where} requires capability '${capability}' held by no role — admin-only`)
100
+ }
101
+
102
+ const seen = new Set<string>()
103
+ const out: Role[] = []
104
+ for (const r of [...declared, ...capRoles]) {
105
+ if (r?.code && !seen.has(r.code)) {
106
+ seen.add(r.code)
107
+ out.push(r)
108
+ }
109
+ }
110
+ if (out.length === 0 && !capability) out.push(roles.public)
111
+ if (!out.some((r) => r.code === roles.admin.code)) out.push(roles.admin)
112
+ return out
113
+ }
114
+
69
115
  export function processRoute(
70
116
  route: Route,
71
117
  index: number,
@@ -74,7 +120,8 @@ export function processRoute(
74
120
  base: string,
75
121
  defaultConfig: any,
76
122
  authMiddlewares: string[],
77
- validRoutes: ConfiguredRoute[]
123
+ validRoutes: ConfiguredRoute[],
124
+ roleErrors: string[] = []
78
125
  ): ConfiguredRoute | null {
79
126
  const errors: string[] = []
80
127
  const {
@@ -82,25 +129,19 @@ export function processRoute(
82
129
  path: pathName = '/',
83
130
  handler,
84
131
  roles: rs = [],
132
+ requireCapability,
85
133
  config = {} as RouteConfig,
86
134
  middlewares = [],
87
135
  rateLimit,
88
136
  cache: cacheInput
89
137
  } = route
90
138
 
91
- const rsp = !rs.length ? [roles.public] : rs
92
- let requiredRoles: Role[] = []
93
-
94
- try {
95
- // `admin` is a global superuser: it is appended to EVERY route's allowed roles,
96
- // so an admin can access any endpoint (even ones restricted to e.g. backoffice).
97
- // This is intentional. Remove this append if admin should NOT be a universal role.
98
- requiredRoles = rsp.some((r) => r.code === roles.admin.code) ? rsp : [...rsp, roles.admin]
99
- } catch (err) {
100
- if (log.e) log.error(`Error in loading roles for ${methodCase} ${pathName} (${handler})`)
101
- if (log.t) log.trace(err)
102
- config.enable = false
103
- }
139
+ const requiredRoles = resolveRequiredRoles(
140
+ rs,
141
+ requireCapability,
142
+ `${methodCase} ${pathName} (${handler})`,
143
+ roleErrors
144
+ )
104
145
 
105
146
  const reqAuth: boolean =
106
147
  middlewares.some((m) => authMiddlewares.includes(m)) ||
@@ -207,6 +248,7 @@ export function processRoute(
207
248
 
208
249
  async function load(): Promise<ConfiguredRoute[]> {
209
250
  const validRoutes: ConfiguredRoute[] = []
251
+ const roleErrors: string[] = []
210
252
  const patterns = normalizePatterns(['..', 'api', '**', 'routes.{ts,js}'], ['src', 'api', '**', 'routes.{ts,js}'])
211
253
  const authMiddlewares = ['global.isAuthenticated', 'global.isAdmin']
212
254
 
@@ -228,7 +270,17 @@ async function load(): Promise<ConfiguredRoute[]> {
228
270
  if (log.t) log.trace(`* Add ${routes.length} routes from ${file}`)
229
271
 
230
272
  routes.forEach((route: Route, index: number) => {
231
- const configuredRoute = processRoute(route, index, file, dir, base, defaultConfig, authMiddlewares, validRoutes)
273
+ const configuredRoute = processRoute(
274
+ route,
275
+ index,
276
+ file,
277
+ dir,
278
+ base,
279
+ defaultConfig,
280
+ authMiddlewares,
281
+ validRoutes,
282
+ roleErrors
283
+ )
232
284
  if (configuredRoute) {
233
285
  validRoutes.push(configuredRoute)
234
286
  }
@@ -236,6 +288,14 @@ async function load(): Promise<ConfiguredRoute[]> {
236
288
  }
237
289
  }
238
290
 
291
+ if (roleErrors.length) {
292
+ const message = `Route/role integrity check failed — every route role must be declared in config/roles.ts:\n - ${roleErrors.join(
293
+ '\n - '
294
+ )}`
295
+ if (log?.f) log.fatal(message)
296
+ throw new Error(message)
297
+ }
298
+
239
299
  return validRoutes
240
300
  }
241
301
 
@@ -0,0 +1,26 @@
1
+ // Helpers for the admin-apex guards in the users and token controllers. Pure, so they
2
+ // unit-test without a request context. Role values may arrive as string codes
3
+ // (`['admin']`) or as `{ code }` objects; both are normalized here.
4
+
5
+ /** De-duped list of role codes from a roles value (accepts strings or `{ code }`). */
6
+ export function roleCodes(value: unknown): string[] {
7
+ if (!Array.isArray(value)) return []
8
+ const codes = value
9
+ .map((r) => (typeof r === 'string' ? r : (r as { code?: string } | null)?.code))
10
+ .filter((c): c is string => !!c)
11
+ return [...new Set(codes)]
12
+ }
13
+
14
+ /** Whether a roles value grants the given role code. */
15
+ export function includesRole(value: unknown, code: string): boolean {
16
+ return roleCodes(value).includes(code)
17
+ }
18
+
19
+ /**
20
+ * Whether an email identifies the sovereign founder (env `ADMIN_EMAIL`, case-insensitive).
21
+ * Returns false when `ADMIN_EMAIL` is unset, so founder guards stay inert without it.
22
+ */
23
+ export function isFounderEmail(email: unknown): boolean {
24
+ const founder = process.env.ADMIN_EMAIL?.trim().toLowerCase()
25
+ return !!founder && typeof email === 'string' && email.trim().toLowerCase() === founder
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volcanicminds/backend",
3
- "version": "3.6.2",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "codename": "rome",
6
6
  "license": "MIT",