@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.
- package/README.md +128 -27
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/dist/lib/api/admin/controller/manifest.d.ts +3 -0
- package/dist/lib/api/admin/controller/manifest.d.ts.map +1 -0
- package/dist/lib/api/admin/controller/manifest.js +6 -0
- package/dist/lib/api/admin/controller/manifest.js.map +1 -0
- package/dist/lib/api/admin/routes.d.ts +23 -0
- package/dist/lib/api/admin/routes.d.ts.map +1 -0
- package/dist/lib/api/admin/routes.js +26 -0
- package/dist/lib/api/admin/routes.js.map +1 -0
- package/dist/lib/api/auth/controller/auth.d.ts +1 -1
- package/dist/lib/api/auth/controller/auth.d.ts.map +1 -1
- package/dist/lib/api/auth/controller/auth.js +90 -66
- package/dist/lib/api/auth/controller/auth.js.map +1 -1
- package/dist/lib/api/auth/routes.d.ts +29 -0
- package/dist/lib/api/auth/routes.d.ts.map +1 -1
- package/dist/lib/api/auth/routes.js +10 -0
- package/dist/lib/api/auth/routes.js.map +1 -1
- package/dist/lib/api/token/controller/token.js +17 -17
- package/dist/lib/api/token/controller/token.js.map +1 -1
- package/dist/lib/api/users/controller/user.d.ts.map +1 -1
- package/dist/lib/api/users/controller/user.js +25 -19
- package/dist/lib/api/users/controller/user.js.map +1 -1
- package/dist/lib/api/users/routes.d.ts.map +1 -1
- package/dist/lib/api/users/routes.js.map +1 -1
- package/dist/lib/config/general.d.ts +3 -0
- package/dist/lib/config/general.d.ts.map +1 -1
- package/dist/lib/config/general.js +3 -0
- package/dist/lib/config/general.js.map +1 -1
- package/dist/lib/config/plugins.d.ts +17 -0
- package/dist/lib/config/plugins.d.ts.map +1 -1
- package/dist/lib/config/plugins.js +2 -2
- package/dist/lib/config/plugins.js.map +1 -1
- package/dist/lib/database/typeorm/embedded.d.ts +12 -0
- package/dist/lib/database/typeorm/embedded.d.ts.map +1 -0
- package/dist/lib/database/typeorm/embedded.js +92 -0
- package/dist/lib/database/typeorm/embedded.js.map +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts +1 -0
- package/dist/lib/database/typeorm/entities/user.d.ts.map +1 -1
- package/dist/lib/database/typeorm/entities/user.js.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js +4 -1
- package/dist/lib/database/typeorm/loader/tenantManager.js.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.d.ts.map +1 -1
- package/dist/lib/database/typeorm/loader/userManager.js +2 -1
- package/dist/lib/database/typeorm/loader/userManager.js.map +1 -1
- package/dist/lib/database/typeorm/query/operators.d.ts +10 -0
- package/dist/lib/database/typeorm/query/operators.d.ts.map +1 -0
- package/dist/lib/database/typeorm/query/operators.js +122 -0
- package/dist/lib/database/typeorm/query/operators.js.map +1 -0
- package/dist/lib/database/typeorm/query.d.ts +2 -0
- package/dist/lib/database/typeorm/query.d.ts.map +1 -1
- package/dist/lib/database/typeorm/query.js +29 -62
- package/dist/lib/database/typeorm/query.js.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.d.ts.map +1 -1
- package/dist/lib/database/typeorm/util/crypto.js +50 -26
- package/dist/lib/database/typeorm/util/crypto.js.map +1 -1
- package/dist/lib/hooks/onRequest.d.ts.map +1 -1
- package/dist/lib/hooks/onRequest.js +15 -29
- package/dist/lib/hooks/onRequest.js.map +1 -1
- package/dist/lib/loader/general.d.ts.map +1 -1
- package/dist/lib/loader/general.js +3 -0
- package/dist/lib/loader/general.js.map +1 -1
- package/dist/lib/loader/router.d.ts +2 -0
- package/dist/lib/loader/router.d.ts.map +1 -1
- package/dist/lib/loader/router.js +11 -4
- package/dist/lib/loader/router.js.map +1 -1
- package/dist/lib/loader/tracking.d.ts.map +1 -1
- package/dist/lib/loader/tracking.js +6 -1
- package/dist/lib/loader/tracking.js.map +1 -1
- package/dist/lib/manifest/generator.d.ts +90 -0
- package/dist/lib/manifest/generator.d.ts.map +1 -0
- package/dist/lib/manifest/generator.js +274 -0
- package/dist/lib/manifest/generator.js.map +1 -0
- package/dist/lib/middleware/isAdmin.d.ts.map +1 -1
- package/dist/lib/middleware/isAdmin.js +2 -1
- package/dist/lib/middleware/isAdmin.js.map +1 -1
- package/dist/lib/middleware/isAuthenticated.d.ts.map +1 -1
- package/dist/lib/middleware/isAuthenticated.js +2 -1
- package/dist/lib/middleware/isAuthenticated.js.map +1 -1
- package/dist/lib/schemas/user.d.ts +17 -0
- package/dist/lib/schemas/user.d.ts.map +1 -1
- package/dist/lib/schemas/user.js +8 -1
- package/dist/lib/schemas/user.js.map +1 -1
- package/dist/lib/util/errors.d.ts.map +1 -1
- package/dist/lib/util/errors.js +3 -2
- package/dist/lib/util/errors.js.map +1 -1
- package/dist/lib/util/httpError.d.ts +8 -0
- package/dist/lib/util/httpError.d.ts.map +1 -0
- package/dist/lib/util/httpError.js +19 -0
- package/dist/lib/util/httpError.js.map +1 -0
- package/dist/typeorm.d.ts +3 -2
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +14 -2
- package/dist/typeorm.js.map +1 -1
- package/dist/types/global.d.ts +22 -1
- package/lib/api/admin/controller/manifest.ts +9 -0
- package/lib/api/admin/routes.ts +26 -0
- package/lib/api/auth/controller/auth.ts +113 -67
- package/lib/api/auth/routes.ts +17 -0
- package/lib/api/token/controller/token.ts +17 -17
- package/lib/api/users/controller/user.ts +32 -19
- package/lib/api/users/routes.ts +3 -0
- package/lib/config/general.ts +3 -0
- package/lib/config/plugins.ts +5 -2
- package/lib/database/typeorm/embedded.ts +173 -0
- package/lib/database/typeorm/entities/user.ts +2 -0
- package/lib/database/typeorm/loader/tenantManager.ts +8 -1
- package/lib/database/typeorm/loader/userManager.ts +6 -1
- package/lib/database/typeorm/query/operators.ts +187 -0
- package/lib/database/typeorm/query.ts +48 -73
- package/lib/database/typeorm/util/crypto.ts +89 -27
- package/lib/defaults/managers.ts +1 -1
- package/lib/hooks/onRequest.ts +17 -29
- package/lib/loader/general.ts +3 -0
- package/lib/loader/router.ts +16 -3
- package/lib/loader/tracking.ts +10 -1
- package/lib/manifest/generator.ts +345 -0
- package/lib/middleware/isAdmin.ts +3 -1
- package/lib/middleware/isAuthenticated.ts +4 -1
- package/lib/schemas/user.ts +17 -1
- package/lib/util/errors.ts +4 -2
- package/lib/util/httpError.ts +37 -0
- package/package.json +29 -4
package/lib/loader/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/loader/tracking.ts
CHANGED
|
@@ -25,7 +25,16 @@ export async function load() {
|
|
|
25
25
|
|
|
26
26
|
if (enableAll) {
|
|
27
27
|
changes.forEach((change) => {
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/schemas/user.ts
CHANGED
|
@@ -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' },
|
package/lib/util/errors.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
this.
|
|
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
|
|
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
|
},
|