millas 0.2.12-beta-1 → 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 (120) hide show
  1. package/package.json +3 -2
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +516 -199
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +318 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +393 -97
  12. package/src/admin/static/admin.css +1422 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +87 -1046
  19. package/src/admin/views/pages/detail.njk +56 -21
  20. package/src/admin/views/pages/error.njk +65 -0
  21. package/src/admin/views/pages/form.njk +47 -599
  22. package/src/admin/views/pages/list.njk +270 -62
  23. package/src/admin/views/partials/form-field.njk +53 -0
  24. package/src/admin/views/partials/form-footer.njk +28 -0
  25. package/src/admin/views/partials/form-readonly.njk +114 -0
  26. package/src/admin/views/partials/form-scripts.njk +480 -0
  27. package/src/admin/views/partials/form-widget.njk +297 -0
  28. package/src/admin/views/partials/icons.njk +64 -0
  29. package/src/admin/views/partials/json-dialog.njk +80 -0
  30. package/src/admin/views/partials/json-editor.njk +37 -0
  31. package/src/ai/AIManager.js +954 -0
  32. package/src/ai/AITokenBudget.js +250 -0
  33. package/src/ai/PromptGuard.js +216 -0
  34. package/src/ai/agents.js +218 -0
  35. package/src/ai/conversation.js +213 -0
  36. package/src/ai/drivers.js +734 -0
  37. package/src/ai/files.js +249 -0
  38. package/src/ai/media.js +303 -0
  39. package/src/ai/pricing.js +152 -0
  40. package/src/ai/provider_tools.js +114 -0
  41. package/src/ai/types.js +356 -0
  42. package/src/auth/Auth.js +18 -2
  43. package/src/auth/AuthUser.js +65 -44
  44. package/src/cli.js +3 -1
  45. package/src/commands/createsuperuser.js +267 -0
  46. package/src/commands/lang.js +589 -0
  47. package/src/commands/migrate.js +154 -81
  48. package/src/commands/serve.js +3 -4
  49. package/src/container/AppInitializer.js +101 -20
  50. package/src/container/Application.js +31 -1
  51. package/src/container/MillasApp.js +10 -3
  52. package/src/container/MillasConfig.js +35 -6
  53. package/src/core/admin.js +5 -0
  54. package/src/core/db.js +2 -1
  55. package/src/core/foundation.js +2 -10
  56. package/src/core/lang.js +1 -0
  57. package/src/errors/HttpError.js +32 -16
  58. package/src/facades/AI.js +411 -0
  59. package/src/facades/Hash.js +67 -0
  60. package/src/facades/Process.js +144 -0
  61. package/src/hashing/Hash.js +262 -0
  62. package/src/http/HtmlEscape.js +162 -0
  63. package/src/http/MillasRequest.js +63 -7
  64. package/src/http/MillasResponse.js +70 -4
  65. package/src/http/ResponseDispatcher.js +21 -27
  66. package/src/http/SafeFilePath.js +195 -0
  67. package/src/http/SafeRedirect.js +62 -0
  68. package/src/http/SecurityBootstrap.js +70 -0
  69. package/src/http/helpers.js +40 -125
  70. package/src/http/index.js +10 -1
  71. package/src/http/middleware/CsrfMiddleware.js +258 -0
  72. package/src/http/middleware/RateLimiter.js +314 -0
  73. package/src/http/middleware/SecurityHeaders.js +281 -0
  74. package/src/i18n/I18nServiceProvider.js +91 -0
  75. package/src/i18n/Translator.js +643 -0
  76. package/src/i18n/defaults.js +122 -0
  77. package/src/i18n/index.js +164 -0
  78. package/src/i18n/locales/en.js +55 -0
  79. package/src/i18n/locales/sw.js +48 -0
  80. package/src/logger/LogRedactor.js +247 -0
  81. package/src/logger/Logger.js +1 -1
  82. package/src/logger/formatters/JsonFormatter.js +11 -4
  83. package/src/logger/formatters/PrettyFormatter.js +103 -65
  84. package/src/logger/formatters/SimpleFormatter.js +14 -3
  85. package/src/middleware/ThrottleMiddleware.js +27 -4
  86. package/src/migrations/system/0001_users.js +21 -0
  87. package/src/migrations/system/0002_admin_log.js +25 -0
  88. package/src/migrations/system/0003_sessions.js +23 -0
  89. package/src/orm/fields/index.js +210 -188
  90. package/src/orm/migration/DefaultValueParser.js +325 -0
  91. package/src/orm/migration/InteractiveResolver.js +191 -0
  92. package/src/orm/migration/Makemigrations.js +312 -0
  93. package/src/orm/migration/MigrationGraph.js +227 -0
  94. package/src/orm/migration/MigrationRunner.js +202 -108
  95. package/src/orm/migration/MigrationWriter.js +463 -0
  96. package/src/orm/migration/ModelInspector.js +143 -74
  97. package/src/orm/migration/ModelScanner.js +225 -0
  98. package/src/orm/migration/ProjectState.js +213 -0
  99. package/src/orm/migration/RenameDetector.js +175 -0
  100. package/src/orm/migration/SchemaBuilder.js +8 -81
  101. package/src/orm/migration/operations/base.js +57 -0
  102. package/src/orm/migration/operations/column.js +191 -0
  103. package/src/orm/migration/operations/fields.js +252 -0
  104. package/src/orm/migration/operations/index.js +55 -0
  105. package/src/orm/migration/operations/models.js +152 -0
  106. package/src/orm/migration/operations/registry.js +131 -0
  107. package/src/orm/migration/operations/special.js +51 -0
  108. package/src/orm/migration/utils.js +208 -0
  109. package/src/orm/model/Model.js +81 -13
  110. package/src/process/Process.js +333 -0
  111. package/src/providers/AdminServiceProvider.js +66 -9
  112. package/src/providers/AuthServiceProvider.js +40 -5
  113. package/src/providers/CacheStorageServiceProvider.js +2 -2
  114. package/src/providers/DatabaseServiceProvider.js +3 -2
  115. package/src/providers/LogServiceProvider.js +4 -1
  116. package/src/providers/MailServiceProvider.js +1 -1
  117. package/src/providers/QueueServiceProvider.js +1 -1
  118. package/src/router/MiddlewareRegistry.js +27 -2
  119. package/src/scaffold/templates.js +80 -21
  120. package/src/validation/Validator.js +348 -607
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+
3
+ const { createFacade } = require('./Facade');
4
+ const ProcessService = require('../process/Process');
5
+ const { ProcessManager, PendingProcess,
6
+ ProcessResult, ProcessPool,
7
+ ProcessFailedException } = require('../process/Process');
8
+
9
+ /**
10
+ * Process facade — Laravel-style process runner.
11
+ *
12
+ * Resolved from the DI container as 'process'.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────────────
15
+ * QUICK START
16
+ * ─────────────────────────────────────────────────────────────────────────────
17
+ *
18
+ * const { Process } = require('millas/facades/Process');
19
+ *
20
+ * // Run a command and get a ProcessResult
21
+ * const result = await Process.run('node --version');
22
+ * console.log(result.output()); // 'v20.1.0\n'
23
+ * console.log(result.successful); // true
24
+ * console.log(result.exitCode()); // 0
25
+ *
26
+ * ─────────────────────────────────────────────────────────────────────────────
27
+ * BUILDER — chain options before running
28
+ * ─────────────────────────────────────────────────────────────────────────────
29
+ *
30
+ * const result = await Process
31
+ * .path('/var/www/app') // working directory
32
+ * .timeout(120) // kill after 120 seconds
33
+ * .env({ NODE_ENV: 'ci' }) // merge into parent env
34
+ * .quietly() // suppress passthrough to console
35
+ * .throwOnFailure() // throw if exit code != 0
36
+ * .run('npm ci');
37
+ *
38
+ * ─────────────────────────────────────────────────────────────────────────────
39
+ * SHELL MODE — pipes, &&, ||, globs
40
+ * ─────────────────────────────────────────────────────────────────────────────
41
+ *
42
+ * const result = await Process.shell().run('cat package.json | grep version');
43
+ * const result = await Process.shell().run('npm run build && npm test');
44
+ *
45
+ * ─────────────────────────────────────────────────────────────────────────────
46
+ * STDIN INPUT
47
+ * ─────────────────────────────────────────────────────────────────────────────
48
+ *
49
+ * const result = await Process.input('hello world').run('cat');
50
+ * console.log(result.output()); // 'hello world'
51
+ *
52
+ * ─────────────────────────────────────────────────────────────────────────────
53
+ * STREAMING OUTPUT — line-by-line callbacks
54
+ * ─────────────────────────────────────────────────────────────────────────────
55
+ *
56
+ * await Process.pipe('npm install', {
57
+ * stdout: line => io.emit('deploy:log', line),
58
+ * stderr: line => io.emit('deploy:err', line),
59
+ * });
60
+ *
61
+ * ─────────────────────────────────────────────────────────────────────────────
62
+ * SYNCHRONOUS
63
+ * ─────────────────────────────────────────────────────────────────────────────
64
+ *
65
+ * const result = Process.quietly().runSync('git rev-parse HEAD');
66
+ * const sha = result.output().trim();
67
+ *
68
+ * ─────────────────────────────────────────────────────────────────────────────
69
+ * ERROR HANDLING
70
+ * ─────────────────────────────────────────────────────────────────────────────
71
+ *
72
+ * // Manual check
73
+ * const result = await Process.quietly().run('npm test');
74
+ * if (result.failed) {
75
+ * console.error(result.errorOutput());
76
+ * }
77
+ *
78
+ * // Throw on failure
79
+ * const result = await Process.quietly().run('npm test');
80
+ * result.throw(); // throws ProcessFailedException if failed
81
+ *
82
+ * // Auto-throw
83
+ * try {
84
+ * await Process.throwOnFailure().run('npm test');
85
+ * } catch (err) {
86
+ * console.log(err.result.errorOutput()); // ProcessFailedException
87
+ * }
88
+ *
89
+ * ─────────────────────────────────────────────────────────────────────────────
90
+ * CONCURRENT POOL
91
+ * ─────────────────────────────────────────────────────────────────────────────
92
+ *
93
+ * const [lint, test, build] = await Process.pool(pool => [
94
+ * pool.quietly().run('npm run lint'),
95
+ * pool.quietly().run('npm test'),
96
+ * pool.quietly().run('npm run build'),
97
+ * ]);
98
+ *
99
+ * if (lint.failed) console.error('Lint failed:', lint.errorOutput());
100
+ * if (test.failed) console.error('Tests failed:', test.errorOutput());
101
+ * if (build.failed) console.error('Build failed:', build.errorOutput());
102
+ *
103
+ * ─────────────────────────────────────────────────────────────────────────────
104
+ * PROCESSRESULT API
105
+ * ─────────────────────────────────────────────────────────────────────────────
106
+ *
107
+ * result.output() // stdout as string
108
+ * result.errorOutput() // stderr as string
109
+ * result.exitCode() // number
110
+ * result.successful // true if exitCode === 0
111
+ * result.failed // true if exitCode !== 0
112
+ * result.throw() // throws ProcessFailedException if failed
113
+ * result.throwIfFailed() // alias
114
+ * String(result) // same as result.output()
115
+ *
116
+ * ─────────────────────────────────────────────────────────────────────────────
117
+ * ENVIRONMENT
118
+ * ─────────────────────────────────────────────────────────────────────────────
119
+ *
120
+ * // Merge extra vars into parent env (default)
121
+ * Process.env({ DEBUG: '1', PORT: '4000' }).run('node server.js')
122
+ *
123
+ * // Use ONLY these vars — don't inherit parent env
124
+ * Process.env({ PATH: '/usr/bin' }, false).run('node server.js')
125
+ *
126
+ * ─────────────────────────────────────────────────────────────────────────────
127
+ * TESTING
128
+ * ─────────────────────────────────────────────────────────────────────────────
129
+ *
130
+ * Process.swap({
131
+ * run: async () => new ProcessResult({ exitCode: 0, stdout: 'mocked', stderr: '' }),
132
+ * });
133
+ *
134
+ * // ... run test ...
135
+ *
136
+ * Process.restore();
137
+ *
138
+ * ─────────────────────────────────────────────────────────────────────────────
139
+ *
140
+ * @see src/process/Process.js
141
+ */
142
+ class Process extends createFacade('process') {}
143
+
144
+ module.exports = { Process, ProcessManager, PendingProcess, ProcessResult, ProcessPool, ProcessFailedException };
@@ -0,0 +1,262 @@
1
+ 'use strict';
2
+
3
+ const bcrypt = require('bcryptjs');
4
+
5
+ // ── BcryptDriver ──────────────────────────────────────────────────────────────
6
+
7
+ class BcryptDriver {
8
+ /**
9
+ * @param {object} config
10
+ * @param {number} config.rounds — cost factor (default 12)
11
+ */
12
+ constructor(config = {}) {
13
+ this.rounds = config.rounds ?? 12;
14
+ }
15
+
16
+ /**
17
+ * Hash a plain-text value.
18
+ *
19
+ * await Hash.make('secret');
20
+ * await Hash.make('secret', { rounds: 14 });
21
+ *
22
+ * @param {string} value
23
+ * @param {object} options
24
+ * @returns {Promise<string>}
25
+ */
26
+ async make(value, options = {}) {
27
+ const rounds = options.rounds ?? this.rounds;
28
+ return bcrypt.hash(String(value), rounds);
29
+ }
30
+
31
+ /**
32
+ * Verify a plain-text value against a stored hash.
33
+ *
34
+ * const ok = await Hash.check('secret', storedHash);
35
+ *
36
+ * @param {string} value
37
+ * @param {string} hashedValue
38
+ * @returns {Promise<boolean>}
39
+ */
40
+ async check(value, hashedValue) {
41
+ if (!value || !hashedValue) return false;
42
+ return bcrypt.compare(String(value), String(hashedValue));
43
+ }
44
+
45
+ /**
46
+ * Determine if a hash needs to be rehashed.
47
+ * Returns true if the hash was made with fewer rounds than currently configured.
48
+ *
49
+ * if (Hash.needsRehash(user.password)) {
50
+ * user.password = await Hash.make(plaintext);
51
+ * await user.save();
52
+ * }
53
+ *
54
+ * @param {string} hashedValue
55
+ * @returns {boolean}
56
+ */
57
+ needsRehash(hashedValue) {
58
+ try {
59
+ return bcrypt.getRounds(hashedValue) !== this.rounds;
60
+ } catch {
61
+ return true;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Return info about a hash: algorithm, rounds.
67
+ *
68
+ * Hash.info(hash)
69
+ * // → { alg: 'bcrypt', rounds: 12 }
70
+ *
71
+ * @param {string} hashedValue
72
+ * @returns {{ alg: string, rounds: number }}
73
+ */
74
+ info(hashedValue) {
75
+ try {
76
+ return { alg: 'bcrypt', rounds: bcrypt.getRounds(hashedValue) };
77
+ } catch {
78
+ return { alg: 'unknown', rounds: null };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Check whether a value is already hashed (not plain-text).
84
+ * Detects bcrypt hash format: $2a$, $2b$, $2y$ prefixes.
85
+ *
86
+ * @param {string} value
87
+ * @returns {boolean}
88
+ */
89
+ isHashed(value) {
90
+ return /^\$2[aby]\$\d{2}\$/.test(String(value));
91
+ }
92
+ }
93
+
94
+ // ── HashManager ───────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * HashManager
98
+ *
99
+ * Laravel-style hashing service. Manages multiple drivers (currently bcrypt)
100
+ * and delegates all calls to the active driver.
101
+ *
102
+ * ── Usage ─────────────────────────────────────────────────────────────────────
103
+ *
104
+ * const { Hash } = require('millas/facades/Hash');
105
+ *
106
+ * // Hash a password
107
+ * const hashed = await Hash.make('my-password');
108
+ *
109
+ * // Verify
110
+ * const ok = await Hash.check('my-password', hashed); // true
111
+ *
112
+ * // Rehash check (e.g. rounds changed in config)
113
+ * if (Hash.needsRehash(user.password)) {
114
+ * user.password = await Hash.make(plainPassword);
115
+ * await user.save();
116
+ * }
117
+ *
118
+ * // Info
119
+ * Hash.info(hashed); // { alg: 'bcrypt', rounds: 12 }
120
+ *
121
+ * // Guard against double-hashing
122
+ * if (!Hash.isHashed(value)) {
123
+ * value = await Hash.make(value);
124
+ * }
125
+ *
126
+ * // Use a different driver temporarily
127
+ * Hash.driver('bcrypt').make('secret', { rounds: 14 });
128
+ *
129
+ * // Adjust rounds globally (e.g. in tests)
130
+ * Hash.setRounds(4);
131
+ *
132
+ * ── Service provider registration ─────────────────────────────────────────────
133
+ *
134
+ * container.singleton('hash', () => new HashManager({ default: 'bcrypt', bcrypt: { rounds: 12 } }));
135
+ * container.alias('Hash', 'hash');
136
+ */
137
+ class HashManager {
138
+ /**
139
+ * @param {object} config
140
+ * @param {string} config.default — driver name (default: 'bcrypt')
141
+ * @param {object} config.bcrypt — bcrypt driver config
142
+ * @param {number} config.bcrypt.rounds
143
+ */
144
+ constructor(config = {}) {
145
+ this._config = config;
146
+ this._default = config.default || 'bcrypt';
147
+ this._drivers = new Map();
148
+ }
149
+
150
+ // ── Driver resolution ──────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Get a named driver instance (cached).
154
+ *
155
+ * @param {string} [name]
156
+ * @returns {BcryptDriver}
157
+ */
158
+ driver(name) {
159
+ const driverName = name || this._default;
160
+ if (!this._drivers.has(driverName)) {
161
+ this._drivers.set(driverName, this._createDriver(driverName));
162
+ }
163
+ return this._drivers.get(driverName);
164
+ }
165
+
166
+ _createDriver(name) {
167
+ if (name === 'bcrypt') {
168
+ return new BcryptDriver(this._config.bcrypt || {});
169
+ }
170
+ throw new Error(`[Hash] Unknown driver: "${name}". Only 'bcrypt' is supported.`);
171
+ }
172
+
173
+ // ── Configuration helpers ──────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Change the bcrypt rounds at runtime.
177
+ * Useful for lowering cost in test environments:
178
+ *
179
+ * Hash.setRounds(4); // fast in tests
180
+ *
181
+ * @param {number} rounds
182
+ * @returns {this}
183
+ */
184
+ setRounds(rounds) {
185
+ this._config.bcrypt = this._config.bcrypt || {};
186
+ this._config.bcrypt.rounds = rounds;
187
+ // Bust the cached driver so next call picks up the new config
188
+ this._drivers.delete('bcrypt');
189
+ return this;
190
+ }
191
+
192
+ /**
193
+ * Return the currently configured bcrypt rounds.
194
+ *
195
+ * @returns {number}
196
+ */
197
+ getRounds() {
198
+ return (this._config.bcrypt || {}).rounds ?? 12;
199
+ }
200
+
201
+ // ── Delegated methods (forward to the default driver) ─────────────────────
202
+
203
+ /**
204
+ * Hash a value using the default driver.
205
+ *
206
+ * @param {string} value
207
+ * @param {object} [options]
208
+ * @param {number} [options.rounds] — override rounds for this call only
209
+ * @returns {Promise<string>}
210
+ */
211
+ make(value, options = {}) {
212
+ return this.driver().make(value, options);
213
+ }
214
+
215
+ /**
216
+ * Check a plain value against a stored hash.
217
+ *
218
+ * @param {string} value
219
+ * @param {string} hashedValue
220
+ * @returns {Promise<boolean>}
221
+ */
222
+ check(value, hashedValue) {
223
+ return this.driver().check(value, hashedValue);
224
+ }
225
+
226
+ /**
227
+ * Determine whether a hash needs to be rehashed (e.g. rounds have changed).
228
+ *
229
+ * @param {string} hashedValue
230
+ * @returns {boolean}
231
+ */
232
+ needsRehash(hashedValue) {
233
+ return this.driver().needsRehash(hashedValue);
234
+ }
235
+
236
+ /**
237
+ * Return metadata about a hash.
238
+ *
239
+ * @param {string} hashedValue
240
+ * @returns {{ alg: string, rounds: number }}
241
+ */
242
+ info(hashedValue) {
243
+ return this.driver().info(hashedValue);
244
+ }
245
+
246
+ /**
247
+ * Detect whether a value is already hashed (guards against double-hashing).
248
+ *
249
+ * @param {string} value
250
+ * @returns {boolean}
251
+ */
252
+ isHashed(value) {
253
+ return this.driver().isHashed(value);
254
+ }
255
+ }
256
+
257
+ // ── Singleton with default config ─────────────────────────────────────────────
258
+ const defaultHash = new HashManager({ default: 'bcrypt', bcrypt: { rounds: 12 } });
259
+
260
+ module.exports = defaultHash;
261
+ module.exports.HashManager = HashManager;
262
+ module.exports.BcryptDriver = BcryptDriver;
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HtmlEscape
5
+ *
6
+ * Escapes user-provided strings for safe inclusion in HTML output,
7
+ * preventing Cross-Site Scripting (XSS) attacks.
8
+ *
9
+ * ── The problem ───────────────────────────────────────────────────────────────
10
+ *
11
+ * const name = req.input('name'); // attacker sends: <script>alert(1)</script>
12
+ * return `<p>Hello ${name}</p>`; // → XSS vulnerability
13
+ *
14
+ * ── The solution ──────────────────────────────────────────────────────────────
15
+ *
16
+ * const { e } = require('millas/src/http/HtmlEscape');
17
+ * return `<p>Hello ${e(name)}</p>`; // → <p>Hello &lt;script&gt;...&lt;/script&gt;</p>
18
+ *
19
+ * ── Usage ─────────────────────────────────────────────────────────────────────
20
+ *
21
+ * const { escapeHtml, e, safeHtml } = require('millas/src/http/HtmlEscape');
22
+ *
23
+ * // Escape a single value
24
+ * escapeHtml('<script>alert(1)</script>')
25
+ * // → '&lt;script&gt;alert(1)&lt;/script&gt;'
26
+ *
27
+ * // Short alias for use in template literals
28
+ * `<p>${e(user.name)}</p>`
29
+ *
30
+ * // Build a safe HTML response — all interpolated values are escaped
31
+ * return safeHtml`<h1>Hello ${user.name}</h1><p>${user.bio}</p>`;
32
+ *
33
+ * // Mark a value as already-safe (trusted HTML — use with caution)
34
+ * const trusted = new SafeString('<b>bold</b>');
35
+ * safeHtml`<div>${trusted}</div>` // not double-escaped
36
+ *
37
+ * ── In templates ─────────────────────────────────────────────────────────────
38
+ *
39
+ * When using a template engine, enable auto-escaping at the engine level.
40
+ * These utilities are for cases where you build HTML strings in route handlers
41
+ * or helpers without a template engine.
42
+ *
43
+ * ── What is escaped ──────────────────────────────────────────────────────────
44
+ *
45
+ * & → &amp; (must be first to avoid double-escaping)
46
+ * < → &lt;
47
+ * > → &gt;
48
+ * " → &quot; (safe in attribute values)
49
+ * ' → &#x27; (safe in attribute values)
50
+ * / → &#x2F; (helps close tags inside attribute values)
51
+ * ` → &#x60; (template literal injection)
52
+ * = → &#x3D; (attribute injection without quotes)
53
+ */
54
+
55
+ // ── SafeString ────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * A wrapper that marks a string as already-escaped / trusted HTML.
59
+ * safeHtml`` will not escape SafeString instances.
60
+ *
61
+ * const html = new SafeString('<strong>bold</strong>');
62
+ */
63
+ class SafeString {
64
+ constructor(value) {
65
+ this.value = String(value);
66
+ }
67
+ toString() {
68
+ return this.value;
69
+ }
70
+ }
71
+
72
+ // ── Core escape function ──────────────────────────────────────────────────────
73
+
74
+ const ESCAPE_MAP = {
75
+ '&': '&amp;',
76
+ '<': '&lt;',
77
+ '>': '&gt;',
78
+ '"': '&quot;',
79
+ "'": '&#x27;',
80
+ '/': '&#x2F;',
81
+ '`': '&#x60;',
82
+ '=': '&#x3D;',
83
+ };
84
+
85
+ const ESCAPE_RE = /[&<>"'`=/]/g;
86
+
87
+ /**
88
+ * Escape a value for safe inclusion in HTML.
89
+ * Returns an empty string for null/undefined.
90
+ * Non-string values are converted to string first.
91
+ * SafeString instances are returned as-is (already trusted).
92
+ *
93
+ * @param {*} value
94
+ * @returns {string}
95
+ */
96
+ function escapeHtml(value) {
97
+ if (value instanceof SafeString) return value.value;
98
+ if (value === null || value === undefined) return '';
99
+ return String(value).replace(ESCAPE_RE, c => ESCAPE_MAP[c]);
100
+ }
101
+
102
+ /**
103
+ * Short alias — designed for template literals:
104
+ * `<p>${e(user.name)}</p>`
105
+ */
106
+ const e = escapeHtml;
107
+
108
+ // ── Tagged template literal ───────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Tagged template literal that auto-escapes all interpolated values.
112
+ * Returns a MillasResponse (html type) so it can be returned directly
113
+ * from a route handler.
114
+ *
115
+ * return safeHtml`<h1>Hello ${user.name}</h1>`;
116
+ * return safeHtml`<ul>${items.map(i => safeHtml`<li>${i.name}</li>`).join('')}</ul>`;
117
+ *
118
+ * To include trusted HTML without escaping, wrap it in SafeString:
119
+ * const icon = new SafeString('<svg>...</svg>');
120
+ * return safeHtml`<div>${icon} ${user.name}</div>`;
121
+ *
122
+ * @param {TemplateStringsArray} strings
123
+ * @param {...*} values
124
+ * @returns {string} — escaped HTML string
125
+ */
126
+ function safeHtml(strings, ...values) {
127
+ let result = '';
128
+ for (let i = 0; i < strings.length; i++) {
129
+ result += strings[i];
130
+ if (i < values.length) {
131
+ result += escapeHtml(values[i]);
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ // ── ResponseDispatcher integration ───────────────────────────────────────────
138
+
139
+ /**
140
+ * Detect whether a string contains unescaped HTML characters that could
141
+ * indicate unsanitized user input was interpolated directly.
142
+ *
143
+ * Used by ResponseDispatcher.autoWrap() to emit a development warning
144
+ * when a route returns an HTML string containing potentially dangerous chars.
145
+ *
146
+ * This is NOT a security control — it is a development-time hint to help
147
+ * developers discover forgotten escaping. The real protection is using
148
+ * safeHtml`` or a template engine with auto-escaping enabled.
149
+ *
150
+ * @param {string} html
151
+ * @returns {boolean}
152
+ */
153
+ function containsUnsafeHtmlPatterns(html) {
154
+ // Look for raw script tags, event handlers, javascript: URIs
155
+ // that are commonly injected — not exhaustive, just a hint
156
+ return /<script[\s>]/i.test(html) ||
157
+ /\son\w+\s*=/i.test(html) ||
158
+ /javascript\s*:/i.test(html) ||
159
+ /data\s*:\s*text\/html/i.test(html);
160
+ }
161
+
162
+ module.exports = { escapeHtml, e, safeHtml, SafeString, containsUnsafeHtmlPatterns };
@@ -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;