millas 0.2.12-beta-2 → 0.2.13

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
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const { createFacade } = require('./Facade');
4
+ const { HashManager, BcryptDriver } = require('../hashing/Hash');
5
+
6
+ /**
7
+ * Hash facade — Laravel-style hashing.
8
+ *
9
+ * Resolved from the DI container as 'hash'.
10
+ * Falls back to the exported singleton if used before the container boots
11
+ * (e.g. in migrations or standalone scripts).
12
+ *
13
+ * @class
14
+ *
15
+ * ── Core ─────────────────────────────────────────────────────────────────────
16
+ * @property {function(string, object=): Promise<string>} make
17
+ * Hash a plain-text value.
18
+ * Options: { rounds: 14 } — overrides the configured rounds for this call.
19
+ *
20
+ * @property {function(string, string): Promise<boolean>} check
21
+ * Verify a plain-text value against a stored hash.
22
+ * Returns false (never throws) if either argument is falsy.
23
+ *
24
+ * @property {function(string): boolean} needsRehash
25
+ * Returns true if the hash was made with different rounds than currently
26
+ * configured. Use after login to silently upgrade stored hashes:
27
+ * if (Hash.needsRehash(user.password)) {
28
+ * user.password = await Hash.make(plaintext);
29
+ * await user.save();
30
+ * }
31
+ *
32
+ * ── Introspection ─────────────────────────────────────────────────────────────
33
+ * @property {function(string): { alg: string, rounds: number }} info
34
+ * Return metadata about a stored hash.
35
+ * Example: Hash.info(hash) → { alg: 'bcrypt', rounds: 12 }
36
+ *
37
+ * @property {function(string): boolean} isHashed
38
+ * Return true if the value looks like a bcrypt hash.
39
+ * Guards against accidentally double-hashing:
40
+ * if (!Hash.isHashed(value)) value = await Hash.make(value);
41
+ *
42
+ * ── Driver access ─────────────────────────────────────────────────────────────
43
+ * @property {function(string=): BcryptDriver} driver
44
+ * Get a specific driver instance:
45
+ * Hash.driver('bcrypt').make('secret', { rounds: 14 });
46
+ *
47
+ * ── Configuration ─────────────────────────────────────────────────────────────
48
+ * @property {function(number): HashManager} setRounds
49
+ * Change the bcrypt rounds at runtime. Busts the driver cache.
50
+ * Useful in tests: Hash.setRounds(4);
51
+ *
52
+ * @property {function(): number} getRounds
53
+ * Return the currently configured bcrypt rounds.
54
+ *
55
+ * ── Testing ───────────────────────────────────────────────────────────────────
56
+ * @property {function(object): void} swap
57
+ * Swap the underlying instance for a fake:
58
+ * Hash.swap({ make: async () => 'hashed', check: async () => true });
59
+ *
60
+ * @property {function(): void} restore
61
+ * Restore the real implementation after a swap.
62
+ *
63
+ * @see src/hashing/Hash.js
64
+ */
65
+ class Hash extends createFacade('hash') {}
66
+
67
+ module.exports = Hash
@@ -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 };