@volcanicminds/backend 3.6.2 → 4.0.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 +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/api/admin/routes.d.ts +1 -1
- package/dist/lib/api/admin/routes.d.ts.map +1 -1
- package/dist/lib/api/admin/routes.js +1 -1
- package/dist/lib/api/admin/routes.js.map +1 -1
- package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +5 -8
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/token/controller/token.d.ts +2 -2
- package/dist/lib/api/token/controller/token.d.ts.map +1 -1
- package/dist/lib/api/token/controller/token.js +13 -10
- package/dist/lib/api/token/controller/token.js.map +1 -1
- package/dist/lib/api/token/routes.d.ts +7 -7
- package/dist/lib/api/token/routes.js +8 -8
- package/dist/lib/api/token/routes.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +65 -18
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/api/users/routes.d.ts +45 -8
- package/dist/lib/api/users/routes.d.ts.map +1 -1
- package/dist/lib/api/users/routes.js +10 -10
- package/dist/lib/api/users/routes.js.map +1 -1
- package/dist/lib/config/roles.d.ts.map +1 -1
- package/dist/lib/config/roles.js +0 -5
- package/dist/lib/config/roles.js.map +1 -1
- package/dist/lib/loader/genesis.d.ts +6 -0
- package/dist/lib/loader/genesis.d.ts.map +1 -0
- package/dist/lib/loader/genesis.js +51 -0
- package/dist/lib/loader/genesis.js.map +1 -0
- package/dist/lib/loader/roles.d.ts +3 -1
- package/dist/lib/loader/roles.d.ts.map +1 -1
- package/dist/lib/loader/roles.js +23 -4
- package/dist/lib/loader/roles.js.map +1 -1
- package/dist/lib/loader/router.d.ts +1 -1
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +50 -14
- package/dist/lib/loader/router.js.map +1 -1
- package/dist/lib/util/authz.d.ts +4 -0
- package/dist/lib/util/authz.d.ts.map +1 -0
- package/dist/lib/util/authz.js +16 -0
- package/dist/lib/util/authz.js.map +1 -0
- package/dist/types/global.d.ts +10 -1
- package/lib/api/admin/controller/manifest.ts +2 -2
- package/lib/api/admin/routes.ts +3 -4
- package/lib/api/auth/controller/auth.ts +7 -10
- package/lib/api/token/controller/token.ts +20 -12
- package/lib/api/token/routes.ts +8 -8
- package/lib/api/users/controller/user.ts +101 -22
- package/lib/api/users/routes.ts +10 -10
- package/lib/config/general.ts +1 -1
- package/lib/config/roles.ts +0 -5
- package/lib/hooks/onRequest.ts +2 -2
- package/lib/loader/genesis.ts +76 -0
- package/lib/loader/roles.ts +34 -5
- package/lib/loader/router.ts +75 -15
- package/lib/util/authz.ts +26 -0
- package/package.json +1 -1
package/lib/api/users/routes.ts
CHANGED
|
@@ -14,7 +14,7 @@ export default {
|
|
|
14
14
|
{
|
|
15
15
|
method: 'GET',
|
|
16
16
|
path: '/',
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
requireCapability: 'users',
|
|
240
240
|
handler: 'user.resetPasswordByAdmin',
|
|
241
241
|
middlewares: ['global.isAuthenticated'],
|
|
242
242
|
config: {
|
package/lib/config/general.ts
CHANGED
|
@@ -19,7 +19,7 @@ export default {
|
|
|
19
19
|
query_key: 'tid'
|
|
20
20
|
},
|
|
21
21
|
manifest: {
|
|
22
|
-
// opt-in: exposes GET /admin/manifest (
|
|
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: {
|
package/lib/config/roles.ts
CHANGED
package/lib/hooks/onRequest.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/lib/loader/roles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/lib/loader/router.ts
CHANGED
|
@@ -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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
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
|
+
}
|