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
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LogRedactor
5
+ *
6
+ * Scrubs sensitive field values from log context objects before they
7
+ * are serialised and written to any log channel.
8
+ *
9
+ * ── Why this matters ─────────────────────────────────────────────────────────
10
+ *
11
+ * Log.d('Auth', 'Login', { email, password }); // password logged ✗
12
+ * Log.d('AI', 'Config', this._config); // API keys logged ✗
13
+ * Log.d('HTTP', 'Request', req.body); // form fields logged ✗
14
+ *
15
+ * With redaction enabled (default):
16
+ * Log.d('Auth', 'Login', { email, password });
17
+ * → { email: 'alice@example.com', password: '[REDACTED]' }
18
+ *
19
+ * ── Default sensitive field names ────────────────────────────────────────────
20
+ *
21
+ * password, passwd, secret, token, apikey, api_key, authorization,
22
+ * cookie, access_token, refresh_token, private_key, private, credential,
23
+ * ssn, credit_card, card_number, cvv, pin
24
+ *
25
+ * Matching is case-insensitive and substring-based:
26
+ * 'userPassword' matches 'password'
27
+ * 'X-API-Key' matches 'apikey'
28
+ * 'AUTHORIZATION' matches 'authorization'
29
+ *
30
+ * ── Configuration ─────────────────────────────────────────────────────────────
31
+ *
32
+ * // Add custom sensitive field names (globally):
33
+ * LogRedactor.addSensitiveKeys(['mpesa_pin', 'stk_passkey', 'webhook_secret']);
34
+ *
35
+ * // Replace the entire list:
36
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_secret']);
37
+ *
38
+ * // Change the redaction placeholder:
39
+ * LogRedactor.setPlaceholder('***');
40
+ *
41
+ * // Disable redaction entirely (not recommended for production):
42
+ * LogRedactor.disable();
43
+ *
44
+ * ── Integration ───────────────────────────────────────────────────────────────
45
+ *
46
+ * Redaction is applied automatically inside every formatter's format() call.
47
+ * No action needed — it is on by default.
48
+ */
49
+
50
+ // ── Default sensitive key fragments ───────────────────────────────────────────
51
+
52
+ const DEFAULT_KEYS = [
53
+ 'password', 'passwd', 'pass',
54
+ 'secret',
55
+ 'token',
56
+ 'apikey', 'api_key',
57
+ 'authorization', 'auth',
58
+ 'cookie',
59
+ 'access_token', 'accesstoken',
60
+ 'refresh_token', 'refreshtoken',
61
+ 'private_key', 'privatekey', 'private',
62
+ 'credential', 'credentials',
63
+ 'ssn',
64
+ 'credit_card', 'creditcard', 'card_number', 'cardnumber',
65
+ 'cvv', 'cvc',
66
+ 'pin',
67
+ 'passphrase',
68
+ 'webhook_secret', 'signing_secret',
69
+ ];
70
+
71
+ // ── Module-level state ────────────────────────────────────────────────────────
72
+
73
+ let _sensitiveKeys = [...DEFAULT_KEYS];
74
+ let _placeholder = '[REDACTED]';
75
+ let _enabled = true;
76
+ let _cachedLower = null; // lazily built lowercased copy
77
+
78
+ function _getLower() {
79
+ if (!_cachedLower) _cachedLower = _sensitiveKeys.map(k => k.toLowerCase());
80
+ return _cachedLower;
81
+ }
82
+
83
+ function _invalidateCache() {
84
+ _cachedLower = null;
85
+ }
86
+
87
+ // ── Core redaction ────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Check if a key name contains any sensitive fragment.
91
+ *
92
+ * @param {string} key
93
+ * @returns {boolean}
94
+ */
95
+ function isSensitiveKey(key) {
96
+ const lower = String(key).toLowerCase().replace(/[-_\s]/g, '');
97
+ const frags = _getLower().map(k => k.replace(/[-_\s]/g, ''));
98
+ return frags.some(frag => lower.includes(frag));
99
+ }
100
+
101
+ /**
102
+ * Redact sensitive values from an object (shallow or deep).
103
+ * Returns a new object — never mutates the original.
104
+ *
105
+ * Handles:
106
+ * - Plain objects (nested recursively up to depth 10)
107
+ * - Arrays (each element redacted)
108
+ * - Primitives (returned as-is unless the key is sensitive)
109
+ * - Circular references (detected and replaced with '[Circular]')
110
+ *
111
+ * @param {*} value — the context value to redact
112
+ * @param {number} [depth] — internal recursion counter
113
+ * @param {Set} [seen] — internal circular reference tracker
114
+ * @returns {*}
115
+ */
116
+ function redact(value, depth = 0, seen = new Set()) {
117
+ if (!_enabled) return value;
118
+
119
+ // Depth guard — don't recurse into deeply nested structures
120
+ if (depth > 10) return value;
121
+
122
+ if (value === null || value === undefined) return value;
123
+ if (typeof value !== 'object') return value;
124
+
125
+ // Circular reference guard
126
+ if (seen.has(value)) return '[Circular]';
127
+ seen.add(value);
128
+
129
+ if (Array.isArray(value)) {
130
+ const result = value.map(item => redact(item, depth + 1, seen));
131
+ seen.delete(value);
132
+ return result;
133
+ }
134
+
135
+ const result = {};
136
+ for (const [k, v] of Object.entries(value)) {
137
+ if (isSensitiveKey(k)) {
138
+ result[k] = _placeholder;
139
+ } else if (v !== null && typeof v === 'object') {
140
+ result[k] = redact(v, depth + 1, seen);
141
+ } else {
142
+ result[k] = v;
143
+ }
144
+ }
145
+ seen.delete(value);
146
+ return result;
147
+ }
148
+
149
+ // ── LogRedactor class ─────────────────────────────────────────────────────────
150
+
151
+ class LogRedactor {
152
+ /**
153
+ * The default list of sensitive key fragments.
154
+ * Useful when callers want to extend it:
155
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_key']);
156
+ */
157
+ static get DEFAULT_KEYS() { return [...DEFAULT_KEYS]; }
158
+
159
+ /**
160
+ * Redact sensitive fields from a log context value.
161
+ * Non-objects are returned unchanged.
162
+ *
163
+ * @param {*} context
164
+ * @returns {*}
165
+ */
166
+ static redact(context) {
167
+ if (!_enabled) return context;
168
+ if (context === null || context === undefined) return context;
169
+ if (typeof context !== 'object') return context;
170
+ return redact(context);
171
+ }
172
+
173
+ /**
174
+ * Add extra sensitive key fragments to the global list.
175
+ *
176
+ * LogRedactor.addSensitiveKeys(['mpesa_pin', 'stk_passkey']);
177
+ *
178
+ * @param {string[]} keys
179
+ */
180
+ static addSensitiveKeys(keys) {
181
+ _sensitiveKeys = [...new Set([..._sensitiveKeys, ...keys.map(k => k.toLowerCase())])];
182
+ _invalidateCache();
183
+ }
184
+
185
+ /**
186
+ * Replace the entire sensitive key list.
187
+ *
188
+ * LogRedactor.setSensitiveKeys([...LogRedactor.DEFAULT_KEYS, 'my_secret']);
189
+ *
190
+ * @param {string[]} keys
191
+ */
192
+ static setSensitiveKeys(keys) {
193
+ _sensitiveKeys = keys.map(k => k.toLowerCase());
194
+ _invalidateCache();
195
+ }
196
+
197
+ /**
198
+ * Get a copy of the current sensitive key list.
199
+ *
200
+ * @returns {string[]}
201
+ */
202
+ static getSensitiveKeys() {
203
+ return [..._sensitiveKeys];
204
+ }
205
+
206
+ /**
207
+ * Change the redaction placeholder string.
208
+ * LogRedactor.setPlaceholder('***');
209
+ *
210
+ * @param {string} placeholder
211
+ */
212
+ static setPlaceholder(placeholder) {
213
+ _placeholder = String(placeholder);
214
+ }
215
+
216
+ /**
217
+ * Enable redaction (default).
218
+ */
219
+ static enable() {
220
+ _enabled = true;
221
+ }
222
+
223
+ /**
224
+ * Disable redaction. Use only in test environments where you need
225
+ * to inspect exact log context values.
226
+ */
227
+ static disable() {
228
+ _enabled = false;
229
+ }
230
+
231
+ /**
232
+ * Whether redaction is currently enabled.
233
+ */
234
+ static get enabled() { return _enabled; }
235
+
236
+ /**
237
+ * Check if a specific key would be redacted.
238
+ *
239
+ * LogRedactor.isSensitive('userPassword') // true
240
+ * LogRedactor.isSensitive('userId') // false
241
+ */
242
+ static isSensitive(key) {
243
+ return isSensitiveKey(key);
244
+ }
245
+ }
246
+
247
+ module.exports = { LogRedactor, redact, isSensitiveKey };
@@ -181,7 +181,7 @@ class Logger {
181
181
  ctx.slow = true;
182
182
  }
183
183
 
184
- self._log(level, 'HTTP', `${method} ${url} ${status} ${ms}ms`, ctx);
184
+ self._log(level, `HTTP [${method}]`, `${url} ${status} ${ms}ms`);
185
185
  });
186
186
 
187
187
  next();
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const { LEVEL_NAMES } = require('../levels');
3
+ const { LEVEL_NAMES } = require('../levels');
4
+ const { LogRedactor } = require('../LogRedactor');
4
5
 
5
6
  /**
6
7
  * JsonFormatter
7
8
  *
8
9
  * Emits one JSON object per log entry — ideal for production environments
9
10
  * where logs are shipped to Datadog, Elasticsearch, CloudWatch, etc.
11
+ * Sensitive context fields are automatically redacted before serialisation.
10
12
  *
11
13
  * Output (one line per entry):
12
14
  * {"ts":"2026-03-15T12:00:00.000Z","level":"INFO","tag":"Auth","msg":"Login","ctx":{...}}
@@ -15,11 +17,13 @@ class JsonFormatter {
15
17
  /**
16
18
  * @param {object} options
17
19
  * @param {boolean} [options.pretty=false] — pretty-print JSON (for debugging)
18
- * @param {object} [options.extra] — static fields merged into every entry (e.g. service name)
20
+ * @param {object} [options.extra] — static fields merged into every entry
21
+ * @param {boolean} [options.redact=true] — redact sensitive context fields
19
22
  */
20
23
  constructor(options = {}) {
21
24
  this.pretty = options.pretty || false;
22
25
  this.extra = options.extra || {};
26
+ this.redact = options.redact !== false; // default: true
23
27
  }
24
28
 
25
29
  format(entry) {
@@ -33,7 +37,10 @@ class JsonFormatter {
33
37
 
34
38
  if (tag) record.tag = tag;
35
39
  record.msg = message;
36
- if (context !== undefined && context !== null) record.ctx = context;
40
+
41
+ if (context !== undefined && context !== null) {
42
+ record.ctx = this.redact ? LogRedactor.redact(context) : context;
43
+ }
37
44
 
38
45
  if (error instanceof Error) {
39
46
  record.error = {
@@ -49,4 +56,4 @@ class JsonFormatter {
49
56
  }
50
57
  }
51
58
 
52
- module.exports = JsonFormatter;
59
+ module.exports = JsonFormatter;
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { LEVEL_TAGS, LEVEL_COLOURS, RESET, BOLD } = require('../levels');
4
+ const { LogRedactor } = require('../LogRedactor');
4
5
 
5
6
  const SEP = ' ';
6
7
  const TAG_WIDTH = 18;
@@ -72,7 +73,8 @@ class PrettyFormatter {
72
73
  for (const l of String(message).split('\n')) logicalLines.push({ text: l, dim: false });
73
74
 
74
75
  if (context != null) {
75
- const ctx = typeof context === 'object' ? JSON.stringify(context) : String(context);
76
+ const safe = this.redact !== false ? LogRedactor.redact(context) : context;
77
+ const ctx = typeof safe === 'object' ? JSON.stringify(safe) : String(safe);
76
78
  logicalLines.push({ text: ctx, dim: true });
77
79
  }
78
80
 
@@ -1,18 +1,28 @@
1
1
  'use strict';
2
2
 
3
3
  const { LEVEL_NAMES } = require('../levels');
4
+ const { LogRedactor } = require('../LogRedactor');
4
5
 
5
6
  /**
6
7
  * SimpleFormatter
7
8
  *
8
9
  * Plain, no-colour text. Suitable for file output or any sink
9
- * where ANSI codes would be noise.
10
+ * where ANSI codes would be noise. Sensitive context fields are
11
+ * automatically redacted before serialisation.
10
12
  *
11
13
  * Output:
12
14
  * [2026-03-15 12:00:00] [INFO] Auth: User logged in
13
15
  * [2026-03-15 12:00:01] [ERROR] DB: Query failed {"table":"users"}
14
16
  */
15
17
  class SimpleFormatter {
18
+ /**
19
+ * @param {object} [options]
20
+ * @param {boolean} [options.redact=true] — redact sensitive context fields
21
+ */
22
+ constructor(options = {}) {
23
+ this.redact = options.redact !== false;
24
+ }
25
+
16
26
  format(entry) {
17
27
  const { level, tag, message, context, error, timestamp } = entry;
18
28
 
@@ -23,7 +33,8 @@ class SimpleFormatter {
23
33
  let line = `[${ts}] [${lvlName}] ${tagPart}${message}`;
24
34
 
25
35
  if (context !== undefined && context !== null) {
26
- line += ' ' + (typeof context === 'object' ? JSON.stringify(context) : String(context));
36
+ const safe = this.redact ? LogRedactor.redact(context) : context;
37
+ line += ' ' + (typeof safe === 'object' ? JSON.stringify(safe) : String(safe));
27
38
  }
28
39
 
29
40
  if (error instanceof Error) {
@@ -34,4 +45,4 @@ class SimpleFormatter {
34
45
  }
35
46
  }
36
47
 
37
- module.exports = SimpleFormatter;
48
+ module.exports = SimpleFormatter;
@@ -7,18 +7,41 @@ const { jsonify } = require('../http/helpers');
7
7
  /**
8
8
  * ThrottleMiddleware
9
9
  *
10
- * Simple in-memory rate limiter.
11
- * Uses the Millas middleware signature: handle(req, next).
10
+ * Per-IP (or per-user) rate limiter registered as the 'throttle' middleware alias.
11
+ * Used via the route middleware system developers never import this directly.
12
+ *
13
+ * Usage in routes:
14
+ * Route.middleware('throttle:5,10').group(() => { // 5 req per 10 min
15
+ * Route.post('/login', AuthController, 'login');
16
+ * });
17
+ *
18
+ * Route.post('/login', AuthController, 'login') // same, single route
19
+ * — add 'throttle:5,10' to route middleware array
20
+ *
21
+ * Format: 'throttle:<max>,<minutes>'
22
+ * throttle:60,1 — 60 requests per minute
23
+ * throttle:5,10 — 5 requests per 10 minutes
24
+ * throttle:100,15 — 100 requests per 15 minutes
12
25
  */
13
26
  class ThrottleMiddleware extends Middleware {
14
27
  constructor(options = {}) {
15
28
  super();
16
29
  this.max = options.max || 60;
17
- this.window = options.window || 60;
30
+ this.window = options.window || 60; // seconds
18
31
  this.keyBy = options.keyBy || ((req) => req.ip || 'anonymous');
19
32
  this._store = new Map();
20
33
  }
21
34
 
35
+ /**
36
+ * Factory used by MiddlewareRegistry when parsing 'throttle:max,minutes'.
37
+ * @param {string[]} params — ['5', '10'] from 'throttle:5,10'
38
+ */
39
+ static fromParams(params) {
40
+ const max = parseInt(params[0], 10) || 60;
41
+ const minutes = parseInt(params[1], 10) || 1;
42
+ return new ThrottleMiddleware({ max, window: minutes * 60 });
43
+ }
44
+
22
45
  async handle(req, next) {
23
46
  const key = this.keyBy(req);
24
47
  const now = Date.now();
@@ -54,4 +77,4 @@ class ThrottleMiddleware extends Middleware {
54
77
  }
55
78
  }
56
79
 
57
- module.exports = ThrottleMiddleware;
80
+ module.exports = ThrottleMiddleware;