@vulkano/core 1.24.2 → 1.26.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/AGENTS.md ADDED
@@ -0,0 +1,648 @@
1
+ # Vulkano Core — CLAUDE.md
2
+
3
+ ## Overview
4
+
5
+ `@vulkano/core` (v1.26.0) is the engine of the Vulkano MVC framework. It bootstraps the environment, connects to the database, and auto-loads all models, controllers, services, and responses before starting the Express server. The user app only calls `require('@vulkano/core')`.
6
+
7
+ ```
8
+ /**
9
+ * app.js
10
+ *
11
+ * To start the server, run: `node app.js`.
12
+ *
13
+ */
14
+
15
+ const vulkano = require('@vulkano/core');
16
+
17
+ vulkano();
18
+ ```
19
+
20
+ ---
21
+
22
+ ## File structure
23
+
24
+ ```
25
+ core/
26
+ ├── app.js ← Entry point: full bootstrap sequence
27
+ ├── bootstrap/
28
+ │ ├── express.js ← Merges all Express configuration sources
29
+ │ ├── logger.js ← Console helpers (colors, column formatting)
30
+ │ ├── responses.js ← Auto-loads and injects response methods into res
31
+ │ ├── server.js ← Starts Express, registers middleware, routes, sockets
32
+ │ ├── services.js ← Auto-loads libs/services and injects them as globals
33
+ │ └── views.js ← Nunjucks base config (path, filters, helpers)
34
+ ├── controllers/
35
+ │ ├── controllers.js ← Auto-discovers *Controller.js files, builds route table
36
+ │ └── ScaffoldController.js ← Auto-generates CRUD methods for scaffold:true controllers
37
+ ├── database/
38
+ │ ├── mongodb.js ← Connects Mongoose, compiles models, registers them as globals
39
+ │ ├── models.js ← Loads user models, merges lifecycle callbacks and scaffold methods
40
+ │ └── scaffold.js ← Base CRUD methods: getAll, getByField, create, update, delete, subdocs
41
+ ├── libs/
42
+ │ ├── ApiClient.js ← Axios wrapper for outbound HTTP requests
43
+ │ ├── Crontab.js ← node-cron wrapper for scheduled tasks
44
+ │ ├── Download.js ← File download helper
45
+ │ ├── Encrypter.js ← AES-256-CBC encrypt/decrypt
46
+ │ ├── Filter.js ← String filter system (trim, prefix, suffix, etc.)
47
+ │ ├── Jwt.js ← JWT encode/decode + Express middleware
48
+ │ ├── Paginate.js ← Mongoose pagination, search, and filtering
49
+ │ ├── VSError.js ← Standard error class with statusCode
50
+ │ ├── i18n.js ← i18next wrapper + moment locale sync
51
+ │ └── filters/ ← Individual filter modules (trim, ltrim, rtrim, prefix, suffix, number, objectId, saveinteger)
52
+ ├── responses/
53
+ │ └── vsr.js ← Vulkano Standard Response: resolves a Promise → JSON
54
+ └── views/
55
+ ├── filters/ ← Core Nunjucks filters (vCamelCase, vLowercase)
56
+ └── errors/ ← Dev-mode HTML error templates (no_controller, no_action, no_view, exception)
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Bootstrap sequence (`app.js`)
62
+
63
+ 1. Sets path globals: `START_TIME`, `ABS_PATH`, `APP_PATH`, `PUBLIC_PATH`, `CORE_PATH`, `app`, `_`
64
+ 2. Loads `.env` via dotenv
65
+ 3. Auto-discovers all user config via `include-all` from `app/config/`
66
+ 4. Deep-merges config in order: general → settings → `env/{NODE_ENV}` → `local.js`
67
+ 5. Stores result in `app.config`
68
+ 6. Runs in sequence: `loadServices()` → `loadDatabase()` → `loadControllers()` → `loadServer()`
69
+ 7. Registers `app.routes` and `app.server`
70
+ 8. Calls the user's `bootstrap.js`, which in turn calls `app.server.start(cb)`
71
+ 9. Inside `start()`: registers middleware, routes, sockets, then calls `cb()`
72
+
73
+ ---
74
+
75
+ ## Auto-loading system
76
+
77
+ Vulkano automatically discovers, loads, and registers all application components at startup —
78
+ **no manual `require()` or imports are needed** inside controllers, models, or services.
79
+
80
+ This is the core design principle of the framework: models, controllers, libraries, and services are
81
+ all loaded and made globally accessible at boot time. This reduces boilerplate and means any file in
82
+ the app can use `User`, `Paginate`, `Jwt`, or any other component directly without importing it.
83
+
84
+ Every component type is scanned from **two locations**: the framework core and the user's `app/`
85
+ project folder. Both are merged, with the project's files taking precedence over core files when
86
+ names collide.
87
+
88
+ ---
89
+
90
+ ### 1. Libraries and services → global scope
91
+
92
+ **Loader:** `bootstrap/services.js`
93
+
94
+ Scanned directories (all merged into a single global namespace):
95
+
96
+ ```
97
+ core/libs/*.js ← built-in framework libraries
98
+ app/services/*.js ← project-level services
99
+ app/libs/*.js ← project-level utility libraries
100
+ ```
101
+
102
+ Every exported module is injected as `global[filename]`. The global name is the **filename without
103
+ extension**, PascalCase by convention:
104
+
105
+ | File | Global |
106
+ |---|---|
107
+ | `core/libs/Paginate.js` | `global.Paginate` |
108
+ | `core/libs/VSError.js` | `global.VSError` |
109
+ | `core/libs/Jwt.js` | `global.Jwt` |
110
+ | `core/libs/Encrypter.js` | `global.Encrypter` |
111
+ | `core/libs/Filter.js` | `global.Filter` |
112
+ | `core/libs/ApiClient.js` | `global.ApiClient` |
113
+ | `core/libs/Crontab.js` | `global.Crontab` |
114
+ | `core/libs/i18n.js` | `global.i18n` |
115
+
116
+ > `ActiveRecord` and `AppController` are explicitly excluded from globals even if present.
117
+
118
+ If a project file has the same name as a core lib (e.g. `app/services/Paginate.js`), the project
119
+ version wins and replaces the core one globally.
120
+
121
+ **Available in every controller, model, service, or config file — no import needed:**
122
+ ```js
123
+ VSError.reject('Not found', 404);
124
+ Paginate.get(User, query);
125
+ Jwt.encode({ userId: '123' });
126
+ Filter.get(' hello ', 'trim');
127
+ ```
128
+
129
+ ---
130
+
131
+ ### 2. Models → compiled Mongoose models in global scope
132
+
133
+ **Loader:** `database/models.js` + `database/mongodb.js`
134
+
135
+ Scanned directory:
136
+ ```
137
+ app/models/*.js ← all project models (one file per model)
138
+ ```
139
+
140
+ For each model file the loader:
141
+ 1. Reads `attributes` and separates virtual fields
142
+ 2. Adds automatic fields: `active` (Boolean, default `true`), `createdAt` (Date), `updatedAt` (Date)
143
+ 3. Sets `trim: true` on all non-Boolean attributes (disable with `trim: false`)
144
+ 4. Merges default lifecycle callbacks (`beforeSave`, `afterSave`, `beforeUpdate`, etc.)
145
+ 5. Merges base scaffold CRUD methods from `database/scaffold.js`
146
+ 6. Compiles the Mongoose schema and registers it as `global[ModelName]`
147
+
148
+ Collection name is always `modelName.toLowerCase()`:
149
+
150
+ | File | Global | MongoDB collection |
151
+ |---|---|---|
152
+ | `app/models/User.js` | `global.User` | `user` |
153
+ | `app/models/Product.js` | `global.Product` | `product` |
154
+ | `app/models/BlogPost.js` | `global.BlogPost` | `blogpost` |
155
+
156
+ **Available in any controller, service, or other model — no import needed:**
157
+ ```js
158
+ User.create(req.body);
159
+ Product.getAll(req.query);
160
+ BlogPost.getByField(req.params.id);
161
+ ```
162
+
163
+ ---
164
+
165
+ ### 3. Controllers → route table
166
+
167
+ **Loader:** `controllers/controllers.js`
168
+
169
+ Scanned directory:
170
+ ```
171
+ app/controllers/**/*Controller.js ← all controllers, including subfolders
172
+ ```
173
+
174
+ For each controller file the loader:
175
+ - Reads the exported object's method names as route definitions
176
+ - Builds a flat route map: `{ "get /user/": fn, "post /user/save": fn, ... }`
177
+ - If the controller has `scaffold: 'ModelName'` (or `scaffold: true` + `model`), injects CRUD methods before building routes — throws if the model isn't found in global scope
178
+
179
+ Subfolder = URL namespace, **any nesting depth** — ideal for grouping controllers by module/domain:
180
+ ```
181
+ app/controllers/UserController.js → /user/...
182
+ app/controllers/api/ProductController.js → /api/product/...
183
+ app/controllers/admin/ReportController.js → /admin/report/...
184
+ app/controllers/api/config/VatTypesController.js → /api/config/vat-types/...
185
+ app/controllers/api/config/billing/InvoiceController.js → /api/config/billing/invoice/...
186
+ ```
187
+
188
+ The loader walks the folder tree recursively (`processNode` in `controllers.js`): a key ending in
189
+ `Controller` is a controller file, anything else is a module/namespace folder that gets kebab-cased
190
+ and appended to the path, then recursion continues into it. Same `XxxController.js` filename at
191
+ different depths produces fully independent routes — e.g. `VatTypesController.js` can exist at the
192
+ root, under `api/`, and under `api/config/` at once, each resolving to its own namespace
193
+ (`/vat-types/`, `/api/vat-types/`, `/api/config/vat-types/`) with no collision.
194
+
195
+ **Multi-word controller names → kebab-case URL segment:**
196
+ The controller name (filename minus `Controller`) is converted with `toKebabCase()`
197
+ before becoming a URL segment — a hyphen is inserted at each PascalCase word boundary,
198
+ then the whole thing is lowercased. A single-word name is unaffected.
199
+
200
+ ```
201
+ app/controllers/MyaccountController.js → /myaccount/...
202
+ app/controllers/MyAccountController.js → /my-account/...
203
+ app/controllers/inventory/MaterialReceptionsController.js → /inventory/material-receptions/...
204
+ ```
205
+
206
+ **Route naming convention:**
207
+ ```js
208
+ // app/controllers/UserController.js
209
+ module.exports = {
210
+ get(req, res) { ... }, // GET /user/
211
+ 'get :id'(req, res) { ... }, // GET /user/:id
212
+ 'post save'(req, res) { ... }, // POST /user/save
213
+ 'delete :id'(req, res) { ... }, // DELETE /user/:id
214
+ '/absolute/path'(req, res) { ... }, // GET /absolute/path (no namespace)
215
+ 'edit :id'(req, res) { ... } // GET /user/edit/:id (no method prefix → defaults to GET)
216
+ }
217
+ ```
218
+
219
+ > **Important:** Declare specific routes **before** parameterized routes (`:id`) in the object.
220
+ > Express matches in registration order — a wildcard declared first will shadow specific paths.
221
+
222
+ > **Method-prefix fallback:** the first space-separated token is only treated as the HTTP method
223
+ > if it's one of `get`, `post`, `put`, `patch`, `delete`. Otherwise the loader defaults to `GET` and
224
+ > joins the whole key with `/` to build the path — e.g. `'edit :id'` → `GET /user/edit/:id`. This
225
+ > lets you write multi-segment action names (`'edit :id'`, `'archive :id/:reason'`) without an
226
+ > explicit method prefix.
227
+
228
+ ---
229
+
230
+ ### 4. Responses → injected into `res`
231
+
232
+ **Loader:** `bootstrap/responses.js`
233
+
234
+ Scanned directories (merged, project files override core):
235
+ ```
236
+ core/responses/*.js ← built-in response helpers
237
+ app/responses/*.js ← project-level custom responses
238
+ ```
239
+
240
+ Every exported function is attached to the Express `res` object. The method name is the
241
+ **filename without extension**:
242
+
243
+ | File | Available as |
244
+ |---|---|
245
+ | `core/responses/vsr.js` | `res.vsr(promise, statusCode?)` |
246
+ | `app/responses/render.js` | `res.render(...)` *(example)* |
247
+ | `app/responses/ok.js` | `res.ok(data)` *(example)* |
248
+
249
+ **Available in any controller handler — no import needed:**
250
+ ```js
251
+ get(req, res) {
252
+ res.vsr(User.getAll(req.query)); // standard VSR JSON response
253
+ res.vsr(User.create(req.body), 201); // with custom HTTP status
254
+ }
255
+ ```
256
+
257
+ Custom response example:
258
+ ```js
259
+ // app/responses/paginated.js
260
+ module.exports = function paginated(data) {
261
+ const { res } = this.req;
262
+ res.status(200).json({ success: true, ...data });
263
+ };
264
+ // Usage: res.paginated({ items, total })
265
+ ```
266
+
267
+ ---
268
+
269
+ ## All globals available across the app
270
+
271
+ These are set automatically — never import them manually:
272
+
273
+ | Global | Set by | What it is |
274
+ |---|---|---|
275
+ | `app` | `app.js` | Config, server, routes, pkg |
276
+ | `app.config` | `app.js` | Full merged configuration |
277
+ | `app.vulkano` | `server.js` | Express app instance |
278
+ | `app.server` | `server.js` | Node.js HTTP server |
279
+ | `app.redisClient` | `server.js` | Redis client (if enabled) |
280
+ | `app.nunjucks` | `server.js` | Nunjucks environment instance |
281
+ | `app.socket` | `server.js` | Current Socket.io socket |
282
+ | `io` | `server.js` | Global Socket.io server instance |
283
+ | `mongoose` | `mongodb.js` | Mongoose instance |
284
+ | `Virtual` | `mongodb.js` | `'Virtual'` marker string for virtual fields |
285
+ | `Mixed` | `mongodb.js` | `mongoose.Schema.Types.Mixed` |
286
+ | `[ModelName]` | `mongodb.js` | Each compiled model (e.g. `User`, `Product`) |
287
+ | `Filter` | `services.js` | String filter library |
288
+ | `Paginate` | `services.js` | Pagination and search library |
289
+ | `VSError` | `services.js` | Standard error class |
290
+ | `Jwt` | `services.js` | JWT encode/decode library |
291
+ | `Encrypter` | `services.js` | AES-256-CBC encrypt/decrypt |
292
+ | `ApiClient` | `services.js` | Outbound HTTP client |
293
+ | `Crontab` | `services.js` | Cron job scheduler |
294
+ | `i18n` | `services.js` | i18next instance |
295
+ | `_` | `app.js` | Underscore.js |
296
+
297
+ ---
298
+
299
+ ## Route system
300
+
301
+ ### Convention-based auto-routing
302
+
303
+ `controllers/controllers.js` reads all `*Controller.js` files and generates routes from method names:
304
+
305
+ ```js
306
+ // HomeController.js
307
+ module.exports = {
308
+ get(req, res) { ... }, // → GET /home/
309
+ 'post save'(req, res) { ... }, // → POST /home/save
310
+ 'get :id'(req, res) { ... }, // → GET /home/:id
311
+ 'delete :id'(req, res) { ... }, // → DELETE /home/:id
312
+ '/absolute/path'(req, res) { ... } // → GET /absolute/path
313
+ }
314
+ ```
315
+
316
+ **Parsing rules:**
317
+ - Key with space: `'METHOD action'` → `[method, pathSegment]`
318
+ - Key is only an HTTP method (`get`, `post`, `put`, `delete`) → controller root path
319
+ - Key starts with `/` → used as an absolute route, not namespaced
320
+ - Controller in subfolder (`api/UserController.js`) → path is `/{folder}/{controller}/{action}`
321
+
322
+ ### Explicit routes (`app/config/routes.js`)
323
+
324
+ ```js
325
+ module.exports = {
326
+ 'GET /': 'HomeController.get',
327
+ 'POST /api/users': 'api.UserController.create',
328
+ '/about': 'HomeController.about', // defaults to GET
329
+ '/handler': (req, res) => res.send('ok') // inline function
330
+ }
331
+ ```
332
+
333
+ ### Known routing limitations
334
+ - Only supports: `get`, `post`, `put`, `patch`, `delete`. No `head`, `options`.
335
+ - Parsing uses `split(' ')` — fragile with extra whitespace (only the first two tokens are ever considered when the first token IS a valid method; the fallback-to-GET path joins all tokens with `/`).
336
+ - Does not validate that the controller/action exist before registering (fails at runtime with `console.error`).
337
+ - Route conflicts resolved by registration order (first registered wins).
338
+
339
+ ---
340
+
341
+ ## Models
342
+
343
+ ### Definition
344
+
345
+ ```js
346
+ // app/models/User.js
347
+ module.exports = {
348
+ attributes: {
349
+ name: { type: String, required: true },
350
+ email: { type: String },
351
+ age: { type: Number },
352
+ // Virtual field — computed, not stored in MongoDB
353
+ fullLabel: { type: 'Virtual', get() { return this.name; } }
354
+ },
355
+ indexes: [{ email: 1 }],
356
+ plugins: [],
357
+ beforeSave(next) { next(); },
358
+ afterSave() {}
359
+ }
360
+ ```
361
+
362
+ **Auto-added fields** (always present, no need to declare):
363
+ - `active: Boolean` (default: `true`) — soft-delete flag
364
+ - `createdAt: Date` (default: `Date.now`)
365
+ - `updatedAt: Date`
366
+
367
+ **Auto-trim:** All non-Boolean attributes get `trim: true` by default. Disable with `{ type: String, trim: false }`.
368
+
369
+ ### Scaffold methods available on every model
370
+
371
+ > **Model scaffold vs ScaffoldController are two different things.**
372
+ > Model scaffold provides database-level CRUD methods on the model class.
373
+ > ScaffoldController generates HTTP routes on a controller. They are independent.
374
+
375
+ ```js
376
+ Model.getAll(props) // paginated list
377
+ Model.getByField(value, field?) // by _id or custom field
378
+ Model.create(data) // insert new record
379
+ Model.update(id, data) // merge-update existing record
380
+ Model.delete(id) // soft delete — sets active: false (no hard delete)
381
+ Model.createSubdoc(key, parentId, data) // push to subdocument array
382
+ Model.updateSubdoc(key, parentId, subdocId, data)
383
+ Model.removeSubdoc(key, parentId, subdocId)
384
+ Model.deleteSubdoc(...) // alias for removeSubdoc
385
+ ```
386
+
387
+ Auto-generated aliases per model (e.g. for `User`):
388
+ ```js
389
+ User.getAllUser(props) // → User.getAll(props)
390
+ User.getUser(id) // → User.getByField(id)
391
+ ```
392
+
393
+ ### Overriding scaffold methods
394
+
395
+ Any method defined in the model file overrides the scaffold default:
396
+
397
+ ```js
398
+ // app/models/Product.js
399
+ module.exports = {
400
+ attributes: { ... },
401
+
402
+ // Override getAll to enable search and custom sort
403
+ getAll(props) {
404
+ const defaultProps = {
405
+ sort: 'name|ASC',
406
+ searchBy: ['name', 'sku'], // fields to search on
407
+ filter: { active: true }
408
+ };
409
+ const query = Paginate.serializeQuery(defaultProps, props);
410
+ return Paginate.get(this, query);
411
+ }
412
+ };
413
+ ```
414
+
415
+ > **Note:** `searchBy` is `[]` in the scaffold default, so text search via `?search=` query param
416
+ > does nothing unless you override `getAll` and configure `searchBy`.
417
+
418
+ ### Auto-populate (relations)
419
+
420
+ `Model.getAll(props)` and `Model.getByField(value, field, props)` — the scaffold defaults — expand
421
+ `ref` relations on request via `?populate=`, using two helpers from `database/scaffold.js`:
422
+ `this._getSanitizedPopulate(props)` and `this._buildPopulate(props, extra?)`.
423
+
424
+ **Security gate — a relation is never populated by default.** A ref field only becomes reachable
425
+ through `?populate=` when its own attribute definition opts in:
426
+
427
+ ```js
428
+ // app/models/Product.js
429
+ module.exports = {
430
+ attributes: {
431
+ name: { type: String, required: true },
432
+ supplier: {
433
+ type: mongoose.Schema.Types.ObjectId,
434
+ ref: 'Supplier',
435
+ autopopulate: true // ← without this, ?populate=supplier is silently ignored
436
+ },
437
+ category: {
438
+ type: mongoose.Schema.Types.ObjectId,
439
+ ref: 'Category',
440
+ autopopulate: true
441
+ }
442
+ }
443
+ // getAll/getByField are inherited from the scaffold as-is — no override needed
444
+ };
445
+ ```
446
+
447
+ Add `autopopulate: true` to every `ref` field you want populatable; a model with several relations
448
+ (supplier, category, unit of measure, ...) needs no separate list to maintain — it's declared right
449
+ on the field. A `ref` field left without it stays a raw id no matter what the caller requests.
450
+
451
+ **One-off override:** a model that overrides `getAll`/`getByField` can allow a relation for that
452
+ call only, without adding `autopopulate: true` to the schema, via the second argument:
453
+
454
+ ```js
455
+ const populate = this._buildPopulate(props, ['supplier']);
456
+ ```
457
+
458
+ **Query syntax** (`?populate=...`):
459
+
460
+ ```
461
+ ?populate=supplier → full Supplier doc
462
+ ?populate=supplier,category → both, full docs
463
+ ?populate=supplier:name → only { _id, name } (Mongoose always keeps _id)
464
+ ?populate=supplier:name|address → several fields, pipe-separated
465
+ ?populate=supplier:name,category → per-relation fields only affect that relation
466
+ ```
467
+
468
+ Relation names and field lists are trimmed and lower-cased; unknown/misspelled relation names are
469
+ silently ignored (no error, no populate). The field-select syntax lives entirely inside the
470
+ `populate` value — deliberately not a separate `?supplier=name` param, since that would collide
471
+ with `supplier` used as an actual filter/query param on the same route.
472
+
473
+ ---
474
+
475
+ ## ScaffoldController
476
+
477
+ > **ScaffoldController vs model scaffold are two different things.**
478
+ > ScaffoldController generates HTTP routes on a controller.
479
+ > Model scaffold provides database-level CRUD methods on the model class. They are independent.
480
+
481
+ When a controller sets `scaffold` to a model name, the framework automatically generates all 5 HTTP routes:
482
+
483
+ ```js
484
+ // app/controllers/api/ProductController.js
485
+ module.exports = {
486
+ scaffold: 'Product', // must exist as global.Product — throws at startup otherwise
487
+ allowedMethods: ['get', 'post', 'put'] // optional: restrict to these methods only
488
+ }
489
+ ```
490
+
491
+ > The older `scaffold: true` + separate `model: 'Product'` form is still supported and behaves
492
+ > identically — `scaffold: 'Product'` is just the shorter, recommended way to write it.
493
+
494
+ Generates las siguientes rutas, cada una delegando al método correspondiente del modelo:
495
+
496
+ | Ruta | Método del modelo | Status |
497
+ |---|---|---|
498
+ | `GET /api/product/` | `Product.getAll(req.query)` | 200 |
499
+ | `GET /api/product/:id` | `Product.getByField(req.params.id)`| 200 |
500
+ | `POST /api/product/` | `Product.create(req.body)` | 201 |
501
+ | `PUT /api/product/:id` | `Product.update(id, req.body)` | 202 |
502
+ | `DELETE /api/product/:id` | `Product.delete(id)` — soft delete | 204 |
503
+
504
+ > **Important:** Scaffold endpoints have no authentication middleware by default.
505
+ > Protect them via JWT config or custom middleware.
506
+
507
+ ---
508
+
509
+ ## VSR — Vulkano Standard Response
510
+
511
+ `res.vsr(promise, statusCode?)` is the standard way to respond in all controllers.
512
+
513
+ ```js
514
+ get(req, res) {
515
+ res.vsr(User.getAll(req.query)); // 200
516
+ res.vsr(User.create(req.body), 201); // 201
517
+ }
518
+ ```
519
+
520
+ - Expects a **Promise** (returns 500 with descriptive error if not)
521
+ - On `.then()`: responds `{ success: true, statusCode, data: result }`
522
+ - On `.catch()`: responds `{ success: false, statusCode, error: { detail, errorCode, errorName } }`
523
+ - `.finally()`: always calls `res.status(code).jsonp(output)`
524
+
525
+ ---
526
+
527
+ ## VSError
528
+
529
+ Available globally — no import needed:
530
+
531
+ ```js
532
+ VSError.reject('Not allowed', 403) // Promise.reject with VSError
533
+ VSError.notFound('User') // Promise.reject with 404
534
+ new VSError('message', 500, props) // direct instantiation
535
+ ```
536
+
537
+ ---
538
+
539
+ ## Paginate
540
+
541
+ Available globally — no import needed:
542
+
543
+ ```js
544
+ Paginate.get(Model, query, populate?)
545
+ Paginate.serializeQuery(defaultProps, requestQuery)
546
+ ```
547
+
548
+ **Supported query params:** `page`, `per_page`, `search`, `searchType` (contains/startwith/endwith), `sort` (e.g. `createdAt|DESC`), `fields`
549
+
550
+ **Response shape from `_set()`:**
551
+ ```js
552
+ { items, cursor, page, perPage, next, prev, totalPages, totalItems }
553
+ ```
554
+
555
+ - `next` is `false` when `(page * perPage) >= totalItems`
556
+ - `prev` is `false` on page 1, or when current page exceeds `totalPages`
557
+ - `cursor` is the index of the first item on the current page (1-based)
558
+ - `page=all` skips pagination and returns a plain array via `Model.find()`
559
+
560
+ ---
561
+
562
+ ## JWT (`Jwt.js`)
563
+
564
+ Available globally — no import needed:
565
+
566
+ ```js
567
+ Jwt.encode(data) // AES-encrypts payload, then encodes as JWT
568
+ Jwt.decode(token) // decodes and validates expiration
569
+ Jwt.getToken(req) // extracts token from header / cookie / query param
570
+ Jwt.socket(socket) // extracts token from Socket.io handshake
571
+ Jwt.init(opts) // returns an express-jwt middleware instance
572
+ ```
573
+
574
+ Tokens require an `expiration` field. Tokens without it are rejected unless `config.jwt.expiration === false`.
575
+
576
+ ---
577
+
578
+ ## Configuration hierarchy (merge order)
579
+
580
+ ```
581
+ app/config/*.js ← general config
582
+ app/config/env/{NODE_ENV}/*.js ← environment overrides
583
+ app/config/local.js ← local overrides (gitignored)
584
+ ```
585
+
586
+ All sources are deep-merged with `deepmerge`. Final result is in `app.config`.
587
+
588
+ **Key config files:**
589
+ - `settings.js` — port, database connection, paths
590
+ - `express/cors.js`, `express/jwt.js`, `express/csp.js`, `express/cookies.js`
591
+ - `routes.js` — explicit route mappings
592
+ - `bootstrap.js` — startup hook (**required**)
593
+ - `sockets/` — Socket.io config and adapters
594
+
595
+ ---
596
+
597
+ ## Sockets (Socket.io)
598
+
599
+ Enable with `config.sockets.enabled: true`. Adapters: `memory` (default), `redis`, `mongodb`.
600
+
601
+ ```js
602
+ // app/config/sockets/events.js
603
+ module.exports = {
604
+ 'message': 'ChatController.message',
605
+ 'join': (socket, body, callback) => { ... }
606
+ }
607
+ ```
608
+
609
+ Handler signature: `({ socket, body }, callback)`.
610
+
611
+ Available globally as `io` and `app.socket`.
612
+
613
+ ---
614
+
615
+ ## Known issues / tech debt
616
+
617
+ - **`services.js`** — All libs/services are injected into `global`. Makes unit testing hard without mocking globals.
618
+ - **`bluebird`** — Still imported in a few places. Not needed in Node 18+ where `Promise` is native.
619
+ - **`ApiClient`** — `rejectUnauthorized: false` disables SSL verification by default for all outbound requests.
620
+ - **`Crontab`** — Default timezone is `America/New_York` instead of UTC.
621
+ - **`path` and `fs` npm packages** — These are Node.js built-ins and should not be in `package.json` dependencies.
622
+
623
+ ---
624
+
625
+ ## Project conventions
626
+
627
+ - Controller files: `{Name}Controller.js` (PascalCase)
628
+ - Model files: `{Name}.js` (PascalCase) — becomes `global[Name]`
629
+ - Service/lib files: `{Name}.js` (PascalCase) — becomes `global[Name]`
630
+ - Response files: `{name}.js` (camelCase) — becomes `res.name()`
631
+ - Config: camelCase, one concern per file, organized in subfolders
632
+ - All API responses go through `res.vsr(promise)`
633
+ - Deletion is always soft (`active: false`); hard delete is not available by default — **this applies only to models**, not to controllers directly
634
+ - No TypeScript; tests live in `test/integration/` and run with Jest
635
+
636
+ ## Tests
637
+
638
+ Run with:
639
+ ```bash
640
+ npm test # run all integration tests
641
+ npm run test:watch # watch mode
642
+ npm run test:coverage
643
+ ```
644
+
645
+ Requires `core/.env.test` with `TEST_DB_URI`, `TEST_PORT`, and `JWT_SECRET_KEY` (used to sign the test JWTs for socket auth). The test suite:
646
+ - Starts a full Vulkano fixture server as a child process
647
+ - Drops and rebuilds the test database on every run
648
+ - Covers: VSR response format, routing (params + query strings), scaffold CRUD, pagination, model validation, ReDoS protection, file uploads, sockets (handshake auth + event routing)
package/README.md CHANGED
@@ -112,6 +112,23 @@ GET /users/edit/1
112
112
  | `POST` | `/users/save` | `UsersController['post save']` |
113
113
  | `GET` | `/users/42/info` | `UsersController['get :id/info']` |
114
114
 
115
+ ### Nested module folders — group controllers by domain
116
+
117
+ Controllers can live in subfolders, at **any nesting depth**, to group them by module. Each folder
118
+ segment becomes a URL namespace, in order, before the controller's own resource segment:
119
+
120
+ ```
121
+ controllers/UsersController.js → /users/...
122
+ controllers/api/ProductsController.js → /api/products/...
123
+ controllers/api/config/VatTypesController.js → /api/config/vat-types/...
124
+ controllers/api/config/billing/InvoiceController.js → /api/config/billing/invoice/...
125
+ ```
126
+
127
+ The same controller filename can exist at several depths at once — `VatTypesController.js` at the
128
+ root, under `api/`, and under `api/config/` resolve to `/vat-types/`, `/api/vat-types/`, and
129
+ `/api/config/vat-types/` respectively, fully independent of each other. No config needed; it's
130
+ purely the folder path on disk.
131
+
115
132
  ### Method key convention: `'<verb>? <path tail>'`
116
133
 
117
134
  A controller method key is `<path tail>` on its own, or `'<verb> <path tail>'` when the verb isn't `GET`. The auto-router only reassigns the HTTP method when the key has a space-separated verb prefix — otherwise it defaults to **GET**.
@@ -11,20 +11,29 @@ const AllControllers = require('include-all')({
11
11
 
12
12
  const scaffoldController = require('./ScaffoldController');
13
13
 
14
+ // PascalCase controller name -> kebab-case URL segment (MyAccount -> my-account)
15
+ function toKebabCase(str) {
16
+ return str
17
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
18
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
19
+ .toLowerCase();
20
+ }
21
+
22
+ const methods = ['get', 'post', 'put', 'patch', 'delete'];
23
+
14
24
  module.exports = function loadControllersApplication() {
15
25
 
16
26
  const routes = {};
17
27
 
18
- Object.keys(AllControllers).forEach( (controller) => {
19
-
20
- const methods = ['get', 'post', 'put', 'patch', 'delete'];
21
- const current = AllControllers[controller];
28
+ // Registers all routes found in a single controller definition object,
29
+ // namespaced under the given module path segments (possibly empty).
30
+ function processController(controllerFileName, current, modulePathSegments) {
22
31
 
23
32
  const {
24
33
  scaffold,
25
34
  allowedMethods,
26
35
  model
27
- } = current;
36
+ } = current || {};
28
37
 
29
38
  // `scaffold` can be `true` + a separate `model` field, or the model
30
39
  // name given directly as the `scaffold` string (no `model` needed).
@@ -33,7 +42,7 @@ module.exports = function loadControllersApplication() {
33
42
  if (scaffold && scaffoldModel) {
34
43
 
35
44
  if (!global[scaffoldModel]) {
36
- throw new Error(`Scaffold model "${scaffoldModel}" not found in global scope for controller "${controller}". Make sure the model exists in app/models.`);
45
+ throw new Error(`Scaffold model "${scaffoldModel}" not found in global scope for controller "${controllerFileName}". Make sure the model exists in app/models.`);
37
46
  }
38
47
 
39
48
  const scaffoldingCurrent = scaffoldController(scaffoldModel, allowedMethods);
@@ -48,132 +57,77 @@ module.exports = function loadControllersApplication() {
48
57
 
49
58
  }
50
59
 
51
- let controllerName = controller.replace('Controller', '').toLowerCase();
52
-
53
- let parts = [];
54
- let method = 'get';
55
- let pathToRun = '';
56
- let moduleName = '';
60
+ const controllerName = toKebabCase(controllerFileName.replace('Controller', ''));
61
+ const namespace = modulePathSegments.join('/');
57
62
 
58
63
  Object.keys(current || []).forEach( (route) => {
59
64
 
60
- // Is a submodule (like api/TestController)
61
- if (route.split('Controller').length > 1) {
62
-
63
- moduleName = controllerName;
64
- const submodules = AllControllers[moduleName];
65
-
66
- Object.keys(submodules || []).forEach( (subcontroller) => {
67
-
68
- controllerName = subcontroller.replace('Controller', '').toLowerCase();
69
- const subcurrent = submodules[subcontroller];
70
-
71
- const {
72
- scaffold: subcurrentScaffold,
73
- allowedMethods: subAllowedMethods,
74
- model: subcurrentModel
75
- } = subcurrent || {};
76
-
77
- const subScaffoldModel = typeof subcurrentScaffold === 'string' ? subcurrentScaffold : subcurrentModel;
65
+ let method = 'get';
66
+ let pathToRun = '';
78
67
 
79
- if (subcurrentScaffold && subScaffoldModel) {
68
+ const parts = route.split(' ');
69
+ const [tmpMethod, tmpPath] = parts;
80
70
 
81
- if (!global[subScaffoldModel]) {
82
- throw new Error(`Scaffold model "${subScaffoldModel}" not found in global scope for controller "${subcontroller}". Make sure the model exists in app/models.`);
83
- }
84
-
85
- const scaffoldingSubcurrent = scaffoldController(subScaffoldModel, subAllowedMethods);
86
-
87
- Object.keys(scaffoldingSubcurrent).forEach( (m) => {
88
-
89
- if (!subcurrent[m]) {
90
- subcurrent[m] = scaffoldingSubcurrent[m];
91
- }
92
-
93
- });
71
+ if (tmpPath) {
72
+ if (methods.indexOf(tmpMethod.toLowerCase()) >= 0) {
73
+ method = tmpMethod.toLowerCase();
74
+ pathToRun = tmpPath;
75
+ } else {
76
+ // First token isn't a real HTTP method — default to GET,
77
+ // treating the whole key as the path (e.g. 'edit :id' → GET .../edit/:id)
78
+ method = 'get';
79
+ pathToRun = parts.join('/');
80
+ }
81
+ } else {
82
+ pathToRun = tmpMethod;
83
+ }
94
84
 
95
- }
85
+ const isAbsolute = (pathToRun.substring(0, 1) === '/') ? true : false;
96
86
 
97
- Object.keys(subcurrent || []).forEach( (subroute) => {
87
+ if (!isAbsolute) {
98
88
 
99
- parts = subroute.split(' ');
89
+ const base = namespace ? `/${namespace}/${controllerName}/` : `/${controllerName}/`;
100
90
 
101
- const [tmpMethod, tmpPath] = parts;
91
+ if (methods.indexOf(pathToRun.toLowerCase()) >= 0) {
92
+ method = pathToRun.toLowerCase();
93
+ pathToRun = base;
94
+ } else {
95
+ pathToRun = `${base}${pathToRun.replace(/GET|POST|DELETE|PUT|PATCH/i, '')}`;
96
+ }
102
97
 
103
- if (tmpPath) {
104
- if (methods.indexOf(tmpMethod.toLowerCase()) >= 0) {
105
- method = tmpMethod.toLowerCase();
106
- pathToRun = tmpPath;
107
- } else {
108
- // First token isn't a real HTTP method — default to GET,
109
- // treating the whole key as the path (e.g. 'edit :id' → GET .../edit/:id)
110
- method = 'get';
111
- pathToRun = parts.join('/');
112
- }
113
- } else {
114
- pathToRun = tmpMethod;
115
- }
98
+ }
116
99
 
117
- const isAbsolute = (pathToRun.substring(0, 1) === '/') ? true : false;
100
+ if (typeof current[route] === 'function') {
101
+ routes[`${method} ${pathToRun}`] = current[route];
102
+ }
118
103
 
119
- if (!isAbsolute) {
104
+ });
120
105
 
121
- if (methods.indexOf(pathToRun.toLowerCase()) >= 0) {
122
- method = pathToRun.toLowerCase();
123
- pathToRun = `/${moduleName}/${controllerName}/`;
124
- } else {
125
- pathToRun = `/${moduleName}/${controllerName}/${pathToRun.replace(/GET|POST|DELETE|PUT|PATCH/i, '')}`;
126
- }
106
+ }
127
107
 
128
- }
108
+ // Walks the (possibly nested) AllControllers tree. A key ending in
109
+ // "Controller" is a controller file; any other key is a module/namespace
110
+ // folder whose value is another node to recurse into.
111
+ function processNode(node, modulePathSegments) {
129
112
 
130
- if (typeof subcurrent[subroute] === 'function') {
131
- routes[`${method} ${pathToRun}`] = subcurrent[subroute];
132
- }
113
+ Object.keys(node || []).forEach( (key) => {
133
114
 
134
- });
135
- });
115
+ const value = node[key];
136
116
 
117
+ if (/Controller$/.test(key)) {
118
+ processController(key, value, modulePathSegments);
137
119
  } else {
138
-
139
- parts = route.split(' ');
140
- const [tmpMethod, tmpPath] = parts;
141
-
142
- if (tmpPath) {
143
- if (methods.indexOf(tmpMethod.toLowerCase()) >= 0) {
144
- method = tmpMethod.toLowerCase();
145
- pathToRun = tmpPath;
146
- } else {
147
- // First token isn't a real HTTP method — default to GET,
148
- // treating the whole key as the path (e.g. 'edit :id' → GET .../edit/:id)
149
- method = 'get';
150
- pathToRun = parts.join('/');
151
- }
152
- } else {
153
- pathToRun = tmpMethod;
154
- }
155
-
156
- const isAbsolute = (pathToRun.substring(0, 1) === '/') ? true : false;
157
-
158
- if (!isAbsolute) {
159
- if (methods.indexOf(pathToRun.toLowerCase()) >= 0) {
160
- method = pathToRun.toLowerCase();
161
- pathToRun = `/${controllerName}/`;
162
- } else {
163
- pathToRun = `/${controllerName}/${pathToRun.replace(/GET|POST|DELETE|PUT|PATCH/i, '')}`;
164
- }
165
- }
166
-
167
- if (typeof current[route] === 'function') {
168
- routes[`${method} ${pathToRun}`] = current[route];
169
- }
170
-
120
+ processNode(value, [...modulePathSegments, toKebabCase(key)]);
171
121
  }
172
122
 
173
123
  });
174
124
 
175
- });
125
+ }
126
+
127
+ processNode(AllControllers, []);
176
128
 
177
129
  return routes;
178
130
 
179
131
  };
132
+
133
+ module.exports.toKebabCase = toKebabCase;
@@ -1,67 +1,5 @@
1
- // Field names that are never exposed through populate select, whatever the
2
- // caller asks for — a coarse, name-based backstop for common secret-shaped
3
- // fields (defense in depth on top of the schema's own `select: false`, in
4
- // case a referenced model forgets to mark a sensitive field itself).
5
- const SENSITIVE_FIELD_HINTS = [
6
- 'password', 'passwd', 'secret', 'token', 'apikey', 'api_key',
7
- 'privatekey', 'private_key', 'hash', 'salt', 'ssn', 'creditcard',
8
- 'credit_card', 'cvv', 'pin'
9
- ];
10
-
11
- function isSensitiveByName(field) {
12
- const lower = field.toLowerCase();
13
- return SENSITIVE_FIELD_HINTS.some((hint) => lower.includes(hint));
14
- }
15
-
16
1
  module.exports = {
17
2
 
18
- /**
19
- * Look up the referenced model on the same connection this model was
20
- * compiled on (models here are registered via `db.model(...)`, not the
21
- * global `mongoose.model(...)` registry — see database/mongodb.js — so
22
- * resolving by connection is required for this to ever find it).
23
- *
24
- * @param {String} refModelName mongoose ref (the "ref" schema option)
25
- * @returns {Object|null}
26
- */
27
- _resolveRefModel(refModelName) {
28
-
29
- try {
30
- return (this.db && this.db.model(refModelName)) || mongoose.model(refModelName);
31
- } catch {
32
- return null;
33
- }
34
-
35
- },
36
-
37
- /**
38
- * A field is safe to expose through populate select when it's neither
39
- * name-flagged as sensitive (SENSITIVE_FIELD_HINTS) nor marked
40
- * `select: false` on the REFERENCED model's own schema. If the referenced
41
- * model can't be resolved (e.g. in isolated unit tests), only the
42
- * name-based check applies.
43
- *
44
- * @param {String} refModelName mongoose ref (the "ref" schema option)
45
- * @param {String} field
46
- * @returns {Boolean}
47
- */
48
- _isFieldSafeToExpose(refModelName, field) {
49
-
50
- if (isSensitiveByName(field)) {
51
- return false;
52
- }
53
-
54
- const RefModel = this._resolveRefModel(refModelName);
55
- const refPath = RefModel && RefModel.schema.paths[field];
56
-
57
- if (refPath && refPath.options && refPath.options.select === false) {
58
- return false;
59
- }
60
-
61
- return true;
62
-
63
- },
64
-
65
3
  /**
66
4
  * Parse the populate param into one entry per relation: `name` (trimmed,
67
5
  * lowercased) plus an optional `fields` list. Syntax: relations are
@@ -165,28 +103,7 @@ module.exports = {
165
103
  const populateProps = { path: field };
166
104
 
167
105
  if (entry.fields && entry.fields.length > 0) {
168
-
169
- // Explicit field list: keep only what's safe, as a pure inclusion
170
- // select. If everything requested turns out sensitive, fall back
171
- // to "_id" only — never to the unfiltered full document.
172
- const safeFields = entry.fields.filter((f) => this._isFieldSafeToExpose(ref, f));
173
- populateProps.select = safeFields.length > 0 ? safeFields.join(' ') : '_id';
174
-
175
- } else {
176
-
177
- // Full-doc populate: still must not leak sensitive fields, so
178
- // build an exclusion select for whatever the ref schema/name-hints
179
- // flag on that model — skipped only if nothing needs excluding.
180
- const RefModel = this._resolveRefModel(ref);
181
-
182
- const toExclude = RefModel
183
- ? Object.keys(RefModel.schema.paths).filter((f) => !this._isFieldSafeToExpose(ref, f))
184
- : [];
185
-
186
- if (toExclude.length > 0) {
187
- populateProps.select = toExclude.map((f) => `-${f}`).join(' ');
188
- }
189
-
106
+ populateProps.select = entry.fields.join(' ');
190
107
  }
191
108
 
192
109
  return populateProps;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vulkano/core",
3
- "version": "1.24.2",
3
+ "version": "1.26.0",
4
4
  "description": "A MVC framework using Express 4",
5
5
  "license": "MIT",
6
6
  "author": "Vulkano Team",