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