millas 0.2.12-beta-2 → 0.2.13-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. package/src/admin.zip +0 -0
@@ -225,13 +225,26 @@ class MillasRequest {
225
225
  // ─── Validation ─────────────────────────────────────────────────────────────
226
226
 
227
227
  /**
228
- * Validate request input against rules. Throws 422 HttpError on failure.
229
- * Returns the validated data on success.
228
+ * Validate request input against rules.
229
+ * Throws a 422 ValidationError on failure.
230
+ * Returns the validated + type-coerced data subset on success.
230
231
  *
231
232
  * const data = await req.validate({
232
- * name: 'required|string|min:2|max:100',
233
- * email: 'required|email',
234
- * age: 'optional|number|min:0',
233
+ * name: 'required|string|min:2|max:100',
234
+ * email: 'required|email',
235
+ * password: 'required|string|min:8',
236
+ * age: 'optional|number|min:13',
237
+ * });
238
+ *
239
+ * For route-level validation (runs before the handler, result in req.validated):
240
+ *
241
+ * Route.post('/register', {
242
+ * validate: {
243
+ * email: 'required|email',
244
+ * password: 'required|string|min:8',
245
+ * },
246
+ * }, async (req) => {
247
+ * const { email, password } = req.validated;
235
248
  * });
236
249
  */
237
250
  async validate(rules) {
@@ -239,15 +252,58 @@ class MillasRequest {
239
252
  return Validator.validate(this.all(), rules);
240
253
  }
241
254
 
255
+ /**
256
+ * The validated + coerced input — populated by route-level validation middleware.
257
+ * Null if no route-level validation was declared for this route.
258
+ *
259
+ * Route.post('/login', { validate: { email: 'required|email' } }, async (req) => {
260
+ * req.validated.email // guaranteed valid email string
261
+ * });
262
+ */
263
+ get validated() {
264
+ return this._req.validated ?? null;
265
+ }
266
+
267
+ // ─── CSRF ────────────────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Get the CSRF token for the current request.
271
+ * Use this in templates to populate the hidden _csrf field.
272
+ *
273
+ * <input type="hidden" name="_csrf" value="<%= req.csrfToken() %>">
274
+ *
275
+ * Returns an empty string if CSRF middleware is not active (e.g. API routes).
276
+ */
277
+ csrfToken() {
278
+ if (typeof this._req.csrfToken === 'function') {
279
+ return this._req.csrfToken();
280
+ }
281
+ return '';
282
+ }
283
+
242
284
  // ─── Escape hatch ────────────────────────────────────────────────────────────
243
285
 
244
286
  /**
245
287
  * The raw underlying Express request.
246
- * Only use this when you genuinely need something MillasRequest doesn't expose.
288
+ *
289
+ * WARNING: Accessing req.raw bypasses all Millas security abstractions
290
+ * (validation, CSRF, sanitization). Use only when MillasRequest genuinely
291
+ * does not expose what you need, and never pass req.raw values directly
292
+ * to database queries or HTML output without manual sanitization.
247
293
  */
248
294
  get raw() {
295
+ if (process.env.NODE_ENV === 'development') {
296
+ // Help developers discover missing MillasRequest features
297
+ // rather than defaulting to raw access silently
298
+ const stack = new Error().stack?.split('\n')[2]?.trim() || '';
299
+ console.warn(
300
+ `[Millas] req.raw accessed at ${stack}. ` +
301
+ 'If MillasRequest is missing a feature you need, consider opening an issue ' +
302
+ 'rather than bypassing the abstraction layer.'
303
+ );
304
+ }
249
305
  return this._req;
250
306
  }
251
307
  }
252
308
 
253
- module.exports = MillasRequest;
309
+ module.exports = MillasRequest;
@@ -1,5 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ // Secure cookie defaults — can be overridden per-call or via config/security.js
4
+ // These are applied in cookie() below; callers can override any individual option.
5
+ let _cookieDefaults = {
6
+ httpOnly: true,
7
+ secure: process.env.NODE_ENV === 'production',
8
+ sameSite: 'Lax',
9
+ path: '/',
10
+ };
11
+
3
12
  /**
4
13
  * MillasResponse
5
14
  *
@@ -97,24 +106,53 @@ class MillasResponse {
97
106
  /**
98
107
  * Set a cookie on the response.
99
108
  *
100
- * return jsonify(data).cookie('token', value, { httpOnly: true, maxAge: 3600 })
109
+ * Secure defaults (httpOnly, secure, sameSite: Lax, path: /) are applied
110
+ * automatically. Pass explicit options to override any individual default.
111
+ *
112
+ * // Secure by default — no extra options needed:
113
+ * return jsonify(data).cookie('session', token)
114
+ *
115
+ * // Override individual options:
116
+ * return jsonify(data).cookie('token', jwt, { maxAge: 3600 })
117
+ *
118
+ * // Opt out of a default (e.g. a non-sensitive preference cookie):
119
+ * return jsonify(data).cookie('theme', 'dark', { httpOnly: false })
120
+ *
121
+ * // Cross-site cookie (e.g. OAuth callback) — must also set secure: true:
122
+ * return jsonify(data).cookie('oauth_state', state, { sameSite: 'None', secure: true })
101
123
  */
102
124
  cookie(name, value, options = {}) {
125
+ // Merge: secure defaults < caller options
126
+ // This means callers can always override, but never accidentally get insecure defaults
127
+ const merged = { ..._cookieDefaults, ...options };
103
128
  return new MillasResponse({
104
129
  type: this._type,
105
130
  body: this._body,
106
131
  status: this._status,
107
132
  headers: this._headers,
108
- cookies: { ...this._cookies, [name]: { value, options } },
133
+ cookies: { ...this._cookies, [name]: { value, options: merged } },
109
134
  });
110
135
  }
111
136
 
112
137
  /**
113
138
  * Clear a cookie.
139
+ *
114
140
  * return jsonify(data).clearCookie('session')
141
+ *
142
+ * Preserves the same path/domain options used when the cookie was set
143
+ * so the browser correctly removes it.
115
144
  */
116
145
  clearCookie(name, options = {}) {
117
- return this.cookie(name, '', { ...options, maxAge: 0, expires: new Date(0) });
146
+ // Must match the path/domain of the original cookie for the browser to delete it.
147
+ // Merge defaults so path always matches.
148
+ const clearOpts = { ..._cookieDefaults, ...options, maxAge: 0, expires: new Date(0) };
149
+ return new MillasResponse({
150
+ type: this._type,
151
+ body: this._body,
152
+ status: this._status,
153
+ headers: this._headers,
154
+ cookies: { ...this._cookies, [name]: { value: '', options: clearOpts } },
155
+ });
118
156
  }
119
157
 
120
158
  // ─── Static factories ─────────────────────────────────────────────────────
@@ -191,6 +229,34 @@ class MillasResponse {
191
229
  static isResponse(value) {
192
230
  return value instanceof MillasResponse;
193
231
  }
232
+
233
+ /**
234
+ * Override the global cookie defaults.
235
+ *
236
+ * Called by the framework bootstrap when it loads config/security.js.
237
+ * Can also be called by developers for custom defaults:
238
+ *
239
+ * MillasResponse.configureCookieDefaults({
240
+ * httpOnly: true,
241
+ * secure: true,
242
+ * sameSite: 'Strict', // stricter than default Lax
243
+ * });
244
+ *
245
+ * @param {object} defaults
246
+ */
247
+ static configureCookieDefaults(defaults = {}) {
248
+ _cookieDefaults = { ..._cookieDefaults, ...defaults };
249
+ }
250
+
251
+ /**
252
+ * Get the current cookie defaults (read-only copy).
253
+ * Useful for debugging or testing.
254
+ *
255
+ * @returns {object}
256
+ */
257
+ static getCookieDefaults() {
258
+ return { ..._cookieDefaults };
259
+ }
194
260
  }
195
261
 
196
- module.exports = MillasResponse;
262
+ module.exports = MillasResponse;
@@ -1,46 +1,40 @@
1
1
  'use strict';
2
2
 
3
- const MillasResponse = require('./MillasResponse');
3
+ const MillasResponse = require('./MillasResponse');
4
+ const { containsUnsafeHtmlPatterns } = require('./HtmlEscape');
4
5
 
5
6
  /**
6
7
  * ResponseDispatcher
7
8
  *
8
- * Kernel-side utility handles auto-wrapping plain return values into
9
- * MillasResponse objects.
10
- *
11
- * Actual dispatch to the HTTP engine (setting headers, writing the body)
12
- * lives in HttpAdapter.dispatch() — that is the only place HTTP-engine
13
- * APIs are called.
14
- *
15
- * This file has zero imports of Express or any HTTP engine.
9
+ * Auto-wraps plain route handler return values into MillasResponse objects.
10
+ * In development, warns when returned HTML strings contain patterns that
11
+ * suggest unescaped user input. Use safeHtml`` or escapeHtml() to fix.
16
12
  */
17
13
  class ResponseDispatcher {
18
14
 
19
- /**
20
- * Auto-wrap a plain JS return value into a MillasResponse.
21
- *
22
- * Called when a route handler returns something that is NOT already
23
- * a MillasResponse — e.g. a plain object, string, number, or array.
24
- *
25
- * @param {*} value
26
- * @returns {MillasResponse}
27
- */
28
15
  static autoWrap(value) {
29
16
  if (MillasResponse.isResponse(value)) return value;
30
-
31
17
  if (value instanceof Error) throw value;
32
18
 
33
19
  if (typeof value === 'string') {
34
- return value.trimStart().startsWith('<')
35
- ? MillasResponse.html(value)
36
- : MillasResponse.text(value);
20
+ const isHtml = value.trimStart().startsWith('<');
21
+
22
+ if (isHtml && process.env.NODE_ENV !== 'production') {
23
+ if (containsUnsafeHtmlPatterns(value)) {
24
+ console.warn(
25
+ '[Millas] ⚠ Potentially unsafe HTML returned from route handler.\n' +
26
+ ' The response contains unescaped patterns (<script>, onerror=, javascript:).\n' +
27
+ ' Use safeHtml`` or escapeHtml() to escape user input:\n' +
28
+ ' const { safeHtml } = require(\'millas/src/http/HtmlEscape\');\n' +
29
+ ' return safeHtml`<p>${userInput}</p>`;\n'
30
+ );
31
+ }
32
+ }
33
+
34
+ return isHtml ? MillasResponse.html(value) : MillasResponse.text(value);
37
35
  }
38
36
 
39
- if (
40
- typeof value === 'object' ||
41
- typeof value === 'number' ||
42
- typeof value === 'boolean'
43
- ) {
37
+ if (typeof value === 'object' || typeof value === 'number' || typeof value === 'boolean') {
44
38
  return MillasResponse.json(value);
45
39
  }
46
40
 
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ /**
7
+ * SafeFilePath
8
+ *
9
+ * Prevents path traversal attacks when constructing file paths from
10
+ * user-provided input.
11
+ *
12
+ * A path traversal attack lets an attacker escape a storage directory by
13
+ * injecting sequences like '../' into a filename:
14
+ *
15
+ * /storage/uploads/ + ../../etc/passwd → /etc/passwd
16
+ *
17
+ * ── Usage ─────────────────────────────────────────────────────────────────────
18
+ *
19
+ * const { resolveStoragePath } = require('millas/src/http/SafeFilePath');
20
+ *
21
+ * // Confine a user-provided filename to a directory
22
+ * const safePath = resolveStoragePath(req.param('filename'), '/storage/uploads');
23
+ * return file(safePath);
24
+ *
25
+ * // With optional existence check
26
+ * const safePath = resolveStoragePath(filename, '/storage/uploads', { mustExist: true });
27
+ *
28
+ * // AI file upload — confine to storage root
29
+ * const safePath = resolveStoragePath(userPath, process.env.STORAGE_ROOT || '/storage');
30
+ * const f = await AI.files.fromPath(safePath).put();
31
+ *
32
+ * ── What it protects against ──────────────────────────────────────────────────
33
+ *
34
+ * resolveStoragePath('../../etc/passwd', '/storage/uploads')
35
+ * // throws PathTraversalError — '../' escapes the storage root
36
+ *
37
+ * resolveStoragePath('report%2F..%2F..%2Fetc%2Fpasswd', '/storage/uploads')
38
+ * // throws PathTraversalError — URL-encoded traversal is also caught
39
+ *
40
+ * resolveStoragePath('/absolute/path/outside', '/storage/uploads')
41
+ * // throws PathTraversalError — absolute paths that escape the root are rejected
42
+ *
43
+ * resolveStoragePath('subdir/report.pdf', '/storage/uploads')
44
+ * // returns '/storage/uploads/subdir/report.pdf' ✓ safe
45
+ *
46
+ * ── Configuration (config/security.js) ───────────────────────────────────────
47
+ *
48
+ * files: {
49
+ * storageRoot: process.env.STORAGE_ROOT || path.join(process.cwd(), 'storage'),
50
+ * }
51
+ */
52
+
53
+ // ── PathTraversalError ────────────────────────────────────────────────────────
54
+
55
+ class PathTraversalError extends Error {
56
+ constructor(userInput, allowedRoot) {
57
+ super(
58
+ `Path traversal attempt blocked. ` +
59
+ `Input "${userInput}" resolves outside the allowed directory "${allowedRoot}".`
60
+ );
61
+ this.name = 'PathTraversalError';
62
+ this.status = 403;
63
+ this.code = 'EPATH_TRAVERSAL';
64
+ this.input = userInput;
65
+ this.root = allowedRoot;
66
+ }
67
+ }
68
+
69
+ // ── Core resolution ───────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Resolve a user-provided filename/path within an allowed root directory.
73
+ * Throws PathTraversalError if the resolved path escapes the root.
74
+ *
75
+ * @param {string} userInput — filename or relative path from user input
76
+ * @param {string} allowedRoot — the directory all files must stay within
77
+ * @param {object} [opts]
78
+ * @param {boolean} [opts.mustExist=false] — throw if file doesn't exist
79
+ * @param {boolean} [opts.allowSubdirs=true] — allow path separators in input
80
+ * @returns {string} — absolute, safe file path
81
+ * @throws {PathTraversalError}
82
+ */
83
+ function resolveStoragePath(userInput, allowedRoot, opts = {}) {
84
+ if (!userInput || typeof userInput !== 'string') {
85
+ throw new PathTraversalError(String(userInput), allowedRoot);
86
+ }
87
+
88
+ if (!allowedRoot || typeof allowedRoot !== 'string') {
89
+ throw new Error('[Millas SafeFilePath] allowedRoot must be a non-empty string.');
90
+ }
91
+
92
+ // ── Decode URL encoding first ───────────────────────────────────────────────
93
+ // Attackers may use %2F, %2E%2E etc. to bypass naive string checks
94
+ let decoded;
95
+ try {
96
+ decoded = decodeURIComponent(userInput);
97
+ } catch {
98
+ decoded = userInput;
99
+ }
100
+
101
+ // ── Reject null bytes ───────────────────────────────────────────────────────
102
+ // Null bytes can truncate paths in some runtimes
103
+ if (decoded.includes('\0') || userInput.includes('\0')) {
104
+ throw new PathTraversalError(userInput, allowedRoot);
105
+ }
106
+
107
+ // ── Optionally block subdirectory separators ────────────────────────────────
108
+ if (opts.allowSubdirs === false) {
109
+ if (decoded.includes('/') || decoded.includes('\\') || decoded.includes(path.sep)) {
110
+ throw new PathTraversalError(userInput, allowedRoot);
111
+ }
112
+ }
113
+
114
+ // ── Resolve both paths to absolute, normalised forms ───────────────────────
115
+ const resolvedRoot = path.resolve(allowedRoot);
116
+ const resolvedInput = path.resolve(resolvedRoot, decoded);
117
+
118
+ // ── The core check: resolved path must start with the root ─────────────────
119
+ // Add path.sep to prevent '/storage/uploads-secret' matching '/storage/uploads'
120
+ const rootWithSep = resolvedRoot.endsWith(path.sep)
121
+ ? resolvedRoot
122
+ : resolvedRoot + path.sep;
123
+
124
+ if (resolvedInput !== resolvedRoot && !resolvedInput.startsWith(rootWithSep)) {
125
+ throw new PathTraversalError(userInput, allowedRoot);
126
+ }
127
+
128
+ // ── Optional existence check ────────────────────────────────────────────────
129
+ if (opts.mustExist && !fs.existsSync(resolvedInput)) {
130
+ const err = new Error(`File not found: ${path.basename(resolvedInput)}`);
131
+ err.status = 404;
132
+ err.code = 'EFILE_NOT_FOUND';
133
+ throw err;
134
+ }
135
+
136
+ return resolvedInput;
137
+ }
138
+
139
+ /**
140
+ * Check if a path is safely within a root directory (non-throwing version).
141
+ *
142
+ * @param {string} userInput
143
+ * @param {string} allowedRoot
144
+ * @returns {boolean}
145
+ */
146
+ function isSafeFilePath(userInput, allowedRoot) {
147
+ try {
148
+ resolveStoragePath(userInput, allowedRoot);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ── SafeFilePath class ────────────────────────────────────────────────────────
156
+
157
+ class SafeFilePath {
158
+ /**
159
+ * Configure the default storage root.
160
+ * Called by SecurityBootstrap from config/security.js files.storageRoot.
161
+ *
162
+ * @param {string} root
163
+ */
164
+ static setStorageRoot(root) {
165
+ SafeFilePath._storageRoot = root;
166
+ }
167
+
168
+ /**
169
+ * Get the configured storage root (or the default).
170
+ */
171
+ static getStorageRoot() {
172
+ return SafeFilePath._storageRoot ||
173
+ process.env.STORAGE_ROOT ||
174
+ path.join(process.cwd(), 'storage');
175
+ }
176
+
177
+ /**
178
+ * Resolve a path within the configured default storage root.
179
+ * Convenience wrapper for the common case.
180
+ *
181
+ * SafeFilePath.resolve('uploads/photo.jpg')
182
+ * // → '/var/www/myapp/storage/uploads/photo.jpg'
183
+ *
184
+ * @param {string} userInput
185
+ * @param {object} [opts]
186
+ * @returns {string}
187
+ */
188
+ static resolve(userInput, opts = {}) {
189
+ return resolveStoragePath(userInput, SafeFilePath.getStorageRoot(), opts);
190
+ }
191
+ }
192
+
193
+ SafeFilePath._storageRoot = null;
194
+
195
+ module.exports = { SafeFilePath, resolveStoragePath, isSafeFilePath, PathTraversalError };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SafeRedirect — INTERNAL MODULE
5
+ *
6
+ * Not part of the developer API. Developers use redirect() from helpers.js.
7
+ * This module is configured by SecurityBootstrap from config/app.js.
8
+ */
9
+
10
+ let _allowedOrigins = [];
11
+ let _appOrigin = null;
12
+
13
+ function _parseOrigin(url) {
14
+ try { return new URL(url).origin; } catch { return null; }
15
+ }
16
+
17
+ function _getLower() {
18
+ return _allowedOrigins.map(o => _parseOrigin(o)).filter(Boolean);
19
+ }
20
+
21
+ function isSafeRedirect(url) {
22
+ if (!url || typeof url !== 'string') return false;
23
+
24
+ const trimmed = url.trim();
25
+ const lower = trimmed.toLowerCase().replace(/\s/g, '');
26
+
27
+ // Block dangerous schemes
28
+ if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) {
29
+ return false;
30
+ }
31
+
32
+ // Relative paths (not protocol-relative) are always safe
33
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true;
34
+
35
+ // Absolute URL — parse and check origin
36
+ let parsed;
37
+ try { parsed = new URL(trimmed); } catch { return false; }
38
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
39
+
40
+ const allowed = new Set([
41
+ ..._getLower(),
42
+ ...(_appOrigin ? [_appOrigin] : []),
43
+ ]);
44
+
45
+ return allowed.has(parsed.origin);
46
+ }
47
+
48
+ class SafeRedirect {
49
+ static configure(origins = []) {
50
+ _allowedOrigins = origins;
51
+ }
52
+
53
+ static setAppUrl(url) {
54
+ _appOrigin = _parseOrigin(url);
55
+ }
56
+
57
+ static isSafe(url) {
58
+ return isSafeRedirect(url);
59
+ }
60
+ }
61
+
62
+ module.exports = { SafeRedirect, isSafeRedirect };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ const SecurityHeaders = require('./middleware/SecurityHeaders');
4
+ const CsrfMiddleware = require('./middleware/CsrfMiddleware');
5
+ const { RateLimiter } = require('./middleware/RateLimiter');
6
+ const MillasResponse = require('./MillasResponse');
7
+
8
+ class SecurityBootstrap {
9
+ static apply(app, config = {}) {
10
+ const headerConfig = config.headers !== undefined ? config.headers : {};
11
+ app.use(SecurityHeaders.from(headerConfig).middleware());
12
+
13
+ if (config.cookies) {
14
+ MillasResponse.configureCookieDefaults(config.cookies);
15
+ }
16
+
17
+ const globalRateLimit = RateLimiter.from(config.rateLimit?.global);
18
+ if (globalRateLimit) {
19
+ app.use(globalRateLimit.middleware());
20
+ }
21
+
22
+ if (config.csrf !== false) {
23
+ app.use(CsrfMiddleware.from(config.csrf || {}).middleware());
24
+ }
25
+
26
+ SecurityBootstrap._registerErrorHandler(app);
27
+
28
+ if (process.env.MILLAS_DEBUG_SECURITY === 'true') {
29
+ console.log('[Millas Security] Controls applied:');
30
+ console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
31
+ console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
32
+ console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
33
+ console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' : 'enabled');
34
+ }
35
+ }
36
+
37
+ static _registerErrorHandler(app) {
38
+ // eslint-disable-next-line no-unused-vars
39
+ app.use((err, req, res, next) => {
40
+ if (err.code === 'EBADCSRFTOKEN') {
41
+ const isApi = (req.headers?.accept || '').includes('application/json') ||
42
+ (req.headers?.['content-type'] || '').includes('application/json');
43
+ res.status(403);
44
+ return isApi
45
+ ? res.json({ error: 'Invalid or missing CSRF token' })
46
+ : res.send('Forbidden: Invalid CSRF token. Please go back and try again.');
47
+ }
48
+ if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
49
+ return res.status(422).json({ message: 'Validation failed', errors: err.errors || {} });
50
+ }
51
+ next(err);
52
+ });
53
+ }
54
+ static loadConfig(configPath) {
55
+ const path = require('path');
56
+ const fs = require('fs');
57
+ // Accept either a full path to app.js or a directory (falls back to config/app.js)
58
+ const target = configPath
59
+ ? (configPath.endsWith('.js') ? configPath : configPath + '.js').replace(/\.js\.js$/, '.js')
60
+ : path.join(process.cwd(), 'config', 'app.js');
61
+ if (fs.existsSync(target)) {
62
+ try { return require(target); } catch (err) {
63
+ console.warn(`[Millas] Failed to load ${target}: ${err.message}. Using built-in defaults.`);
64
+ }
65
+ }
66
+ return {};
67
+ }
68
+ }
69
+
70
+ module.exports = SecurityBootstrap;