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
@@ -1,51 +1,13 @@
1
1
  'use strict';
2
2
 
3
- const MillasResponse = require('./MillasResponse');
4
- const HttpError = require('../errors/HttpError');
3
+ const MillasResponse = require('./MillasResponse');
4
+ const HttpError = require('../errors/HttpError');
5
+ const { isSafeRedirect } = require('./SafeRedirect');
5
6
 
6
- /**
7
- * Millas HTTP Helper Functions
8
- *
9
- * These are the only response-building tools developers need.
10
- * Import them at the top of any route/controller file.
11
- *
12
- * const { jsonify, view, redirect, text, abort } = require('millas');
13
- *
14
- * Every helper returns a MillasResponse instance. Nothing is written
15
- * to the socket until the kernel's ResponseDispatcher processes it.
16
- */
17
-
18
- /**
19
- * Return a JSON response.
20
- *
21
- * return jsonify(users)
22
- * return jsonify(user, { status: 201 })
23
- * return jsonify({ error: 'Not found' }, { status: 404 })
24
- * return jsonify(data).header('X-Total', String(total))
25
- * return jsonify(data).cookie('token', jwt, { httpOnly: true })
26
- *
27
- * @param {*} data
28
- * @param {object} [options]
29
- * @param {number} [options.status=200]
30
- * @param {object} [options.headers={}]
31
- * @returns {MillasResponse}
32
- */
33
7
  function jsonify(data, options = {}) {
34
8
  return MillasResponse.json(data, options);
35
9
  }
36
10
 
37
- /**
38
- * Return an HTML view (template) response.
39
- *
40
- * return view('users/index', { users })
41
- * return view('emails/welcome', { user }, { status: 200 })
42
- *
43
- * @param {string} template — template path relative to views directory
44
- * @param {object} [data={}] — data passed to the template
45
- * @param {object} [options]
46
- * @param {number} [options.status=200]
47
- * @returns {MillasResponse}
48
- */
49
11
  function view(template, data = {}, options = {}) {
50
12
  return MillasResponse.view(template, data, options);
51
13
  }
@@ -53,112 +15,65 @@ function view(template, data = {}, options = {}) {
53
15
  /**
54
16
  * Return a redirect response.
55
17
  *
56
- * return redirect('/login')
57
- * return redirect('/dashboard', { status: 301 })
58
- * return redirect('back') // redirects to Referer header or '/'
18
+ * Relative paths always work:
19
+ * return redirect('/dashboard')
20
+ * return redirect('/login', { status: 301 })
59
21
  *
60
- * @param {string} url
61
- * @param {object} [options]
62
- * @param {number} [options.status=302]
63
- * @returns {MillasResponse}
22
+ * Absolute URLs must be listed in app.allowedRedirects (config/app.js).
23
+ * Anything else throws a 400 — the error renderer handles it.
24
+ *
25
+ * redirect('back') uses the Referer header, validated by the same rules.
64
26
  */
65
27
  function redirect(url, options = {}) {
66
- return MillasResponse.redirect(url, options);
28
+ // Resolve 'back' to the Referer header
29
+ const destination = url === 'back'
30
+ ? (options.referer || (options.req ? options.req.header('referer') : null) || '/')
31
+ : url;
32
+
33
+ if (!isSafeRedirect(destination)) {
34
+ const err = new Error(`Redirect to "${destination}" is not allowed. Add it to app.allowedRedirects in config/app.js.`);
35
+ err.status = 400;
36
+ err.code = 'EREDIRECT_BLOCKED';
37
+ throw err;
38
+ }
39
+
40
+ return MillasResponse.redirect(destination, options);
67
41
  }
68
42
 
69
- /**
70
- * Return a plain text response.
71
- *
72
- * return text('Hello, world')
73
- * return text('Created', { status: 201 })
74
- *
75
- * @param {string} content
76
- * @param {object} [options]
77
- * @param {number} [options.status=200]
78
- * @returns {MillasResponse}
79
- */
80
43
  function text(content, options = {}) {
81
44
  return MillasResponse.text(content, options);
82
45
  }
83
46
 
84
47
  /**
85
- * Return a file response (send / download).
86
- *
87
- * return file('/storage/uploads/report.pdf')
88
- * return file('/storage/uploads/report.pdf', { download: true })
89
- * return file('/storage/uploads/report.pdf', { download: true, name: 'report.pdf' })
90
- *
91
- * @param {string} filePath — absolute or relative path
92
- * @param {object} [options]
93
- * @param {boolean} [options.download=false] — force download (Content-Disposition: attachment)
94
- * @param {string} [options.name] — filename shown to the user on download
95
- * @returns {MillasResponse}
48
+ * Serve a file.
49
+ * The path is automatically validated against app.storageRoot (config/app.js).
50
+ * Any path that escapes the storage root throws a 403.
96
51
  */
97
52
  function file(filePath, options = {}) {
53
+ const { resolveStoragePath, SafeFilePath } = require('./SafeFilePath');
54
+ const root = SafeFilePath.getStorageRoot();
55
+ if (root) {
56
+ // Throws PathTraversalError (403) if path escapes root
57
+ const safe = resolveStoragePath(filePath, root);
58
+ return MillasResponse.file(safe, options);
59
+ }
98
60
  return MillasResponse.file(filePath, options);
99
61
  }
100
62
 
101
- /**
102
- * Return an empty response.
103
- *
104
- * return empty() // 204 No Content
105
- * return empty(200) // 200 with no body
106
- *
107
- * @param {number} [status=204]
108
- * @returns {MillasResponse}
109
- */
110
63
  function empty(status = 204) {
111
64
  return MillasResponse.empty(status);
112
65
  }
113
66
 
114
- /**
115
- * Throw an HTTP error — caught by the kernel and rendered by ErrorRenderer.
116
- *
117
- * abort(404)
118
- * abort(403, 'You are not allowed to do that')
119
- * abort(422, 'Validation failed', { email: ['Email is required'] })
120
- *
121
- * @param {number} status
122
- * @param {string} [message]
123
- * @param {object} [errors]
124
- * @throws {HttpError}
125
- */
126
67
  function abort(status, message, errors = null) {
127
68
  throw new HttpError(status, message, errors);
128
69
  }
129
70
 
130
- /**
131
- * Throw a 404 Not Found error.
132
- * notFound()
133
- * notFound('User not found')
134
- */
135
- function notFound(message = 'Not Found') {
136
- abort(404, message);
137
- }
138
-
139
- /**
140
- * Throw a 401 Unauthorized error.
141
- */
142
- function unauthorized(message = 'Unauthorized') {
143
- abort(401, message);
144
- }
145
-
146
- /**
147
- * Throw a 403 Forbidden error.
148
- */
149
- function forbidden(message = 'Forbidden') {
150
- abort(403, message);
151
- }
71
+ function notFound(message = 'Not Found') { abort(404, message); }
72
+ function unauthorized(message = 'Unauthorized') { abort(401, message); }
73
+ function forbidden(message = 'Forbidden') { abort(403, message); }
152
74
 
153
75
  module.exports = {
154
- jsonify,
155
- view,
156
- redirect,
157
- text,
158
- file,
159
- empty,
160
- abort,
161
- notFound,
162
- unauthorized,
163
- forbidden,
164
- };
76
+ jsonify, view, redirect,
77
+ text, file, empty,
78
+ abort, notFound, unauthorized, forbidden,
79
+ };
package/src/http/index.js CHANGED
@@ -3,11 +3,20 @@
3
3
  const MillasRequest = require('./MillasRequest');
4
4
  const MillasResponse = require('./MillasResponse');
5
5
  const ResponseDispatcher = require('./ResponseDispatcher');
6
+ const { RateLimiter, MemoryRateLimitStore, RedisRateLimitStore } = require('./middleware/RateLimiter');
7
+ const { escapeHtml, e, safeHtml, SafeString } = require('./HtmlEscape');
6
8
  const helpers = require('./helpers');
7
9
 
8
10
  module.exports = {
9
11
  MillasRequest,
10
12
  MillasResponse,
11
13
  ResponseDispatcher,
14
+ RateLimiter,
15
+ MemoryRateLimitStore,
16
+ RedisRateLimitStore,
17
+ escapeHtml,
18
+ e,
19
+ safeHtml,
20
+ SafeString,
12
21
  ...helpers,
13
- };
22
+ };
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * CsrfMiddleware
7
+ *
8
+ * Implements the Synchronizer Token Pattern to prevent Cross-Site Request Forgery.
9
+ * Enabled by default for all state-changing requests (POST, PUT, PATCH, DELETE).
10
+ * API routes and explicitly exempted paths opt out.
11
+ *
12
+ * ── How it works ─────────────────────────────────────────────────────────────
13
+ *
14
+ * 1. On any GET request, a signed CSRF token is generated and stored in a
15
+ * cookie (_csrf) + made available via req.csrfToken().
16
+ * 2. On state-changing requests (POST/PUT/PATCH/DELETE), the middleware reads
17
+ * the submitted token from the request body, query string, or header and
18
+ * verifies it matches the signed value from the cookie.
19
+ * 3. If verification fails, a 403 is thrown automatically.
20
+ *
21
+ * ── Usage in templates ────────────────────────────────────────────────────────
22
+ *
23
+ * <!-- In every HTML form — required -->
24
+ * <form method="POST" action="/register">
25
+ * <input type="hidden" name="_csrf" value="<%= req.csrfToken() %>">
26
+ * ...
27
+ * </form>
28
+ *
29
+ * ── Usage in fetch / AJAX ─────────────────────────────────────────────────────
30
+ *
31
+ * // Read the token from the meta tag or a dedicated endpoint
32
+ * const token = document.querySelector('meta[name="csrf-token"]').content;
33
+ *
34
+ * fetch('/api/profile', {
35
+ * method: 'POST',
36
+ * headers: { 'X-CSRF-Token': token },
37
+ * body: JSON.stringify(data),
38
+ * });
39
+ *
40
+ * ── Exempting routes ──────────────────────────────────────────────────────────
41
+ *
42
+ * // config/security.js — exempt entire path prefixes (e.g. REST API routes)
43
+ * csrf: {
44
+ * exclude: ['/api/', '/webhooks/'],
45
+ * }
46
+ *
47
+ * // Or use the per-route decorator (when routing module supports it):
48
+ * Route.post('/webhook/stripe', { csrf: false }, handler);
49
+ *
50
+ * ── Configuration (config/security.js) ───────────────────────────────────────
51
+ *
52
+ * csrf: {
53
+ * cookieName: '_csrf', // name of the cookie holding the token
54
+ * fieldName: '_csrf', // HTML form field / body key
55
+ * headerName: 'x-csrf-token', // AJAX header name
56
+ * exclude: ['/api/'], // path prefixes that skip CSRF checks
57
+ * tokenLength: 32, // bytes of random entropy in the token
58
+ * }
59
+ *
60
+ * ── Security notes ────────────────────────────────────────────────────────────
61
+ *
62
+ * • Tokens are HMAC-signed (SHA-256) using a secret derived from APP_SECRET.
63
+ * A raw random token without signing would still work against CSRF, but
64
+ * signing prevents an attacker from crafting a valid token without the secret.
65
+ * • The cookie is HttpOnly: false by design — the client-side JS needs to read
66
+ * it for AJAX requests. The CSRF cookie alone has no value to an attacker
67
+ * because they also need to submit the matching signed token in the request.
68
+ * • SameSite: Strict on the CSRF cookie provides defence-in-depth.
69
+ * • Double-submit pattern is used: cookie value is verified against the submitted
70
+ * token, so subdomain compromise is mitigated by the HMAC signing.
71
+ */
72
+
73
+ // ── Constants ─────────────────────────────────────────────────────────────────
74
+
75
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
76
+ const DEFAULT_CONFIG = {
77
+ cookieName: '_csrf',
78
+ fieldName: '_csrf',
79
+ headerName: 'x-csrf-token',
80
+ exclude: [],
81
+ tokenLength: 32,
82
+ };
83
+
84
+ // ── Token utilities ────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Derive an HMAC signing secret from APP_SECRET.
88
+ * Falls back to a fixed warning value if APP_SECRET is not set —
89
+ * this will still work but logs a warning in development.
90
+ */
91
+ function getSecret() {
92
+ const secret = process.env.APP_SECRET;
93
+ if (!secret && process.env.NODE_ENV !== 'test') {
94
+ console.warn(
95
+ '[Millas CSRF] WARNING: APP_SECRET environment variable is not set. ' +
96
+ 'CSRF tokens are weakly signed. Set APP_SECRET in your .env file.'
97
+ );
98
+ }
99
+ return crypto.createHash('sha256')
100
+ .update(secret || 'millas-csrf-insecure-default')
101
+ .digest();
102
+ }
103
+
104
+ /**
105
+ * Generate a new signed CSRF token.
106
+ * Format: <random_hex>.<hmac_hex>
107
+ *
108
+ * @param {number} length — bytes of random entropy
109
+ * @returns {string}
110
+ */
111
+ function generateToken(length = 32) {
112
+ const random = crypto.randomBytes(length).toString('hex');
113
+ const hmac = crypto.createHmac('sha256', getSecret())
114
+ .update(random)
115
+ .digest('hex');
116
+ return `${random}.${hmac}`;
117
+ }
118
+
119
+ /**
120
+ * Verify a submitted token against its HMAC signature.
121
+ * Uses timingSafeEqual to prevent timing attacks.
122
+ *
123
+ * @param {string} token
124
+ * @returns {boolean}
125
+ */
126
+ function verifyToken(token) {
127
+ if (typeof token !== 'string') return false;
128
+
129
+ const dotIndex = token.lastIndexOf('.');
130
+ if (dotIndex === -1) return false;
131
+
132
+ const random = token.slice(0, dotIndex);
133
+ const provided = token.slice(dotIndex + 1);
134
+
135
+ if (!random || !provided) return false;
136
+
137
+ const expected = crypto.createHmac('sha256', getSecret())
138
+ .update(random)
139
+ .digest('hex');
140
+
141
+ try {
142
+ const a = Buffer.from(provided, 'hex');
143
+ const b = Buffer.from(expected, 'hex');
144
+ if (a.length !== b.length) return false;
145
+ return crypto.timingSafeEqual(a, b);
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ // ── CsrfMiddleware class ───────────────────────────────────────────────────────
152
+
153
+ class CsrfMiddleware {
154
+ /**
155
+ * @param {object} config — merged with DEFAULT_CONFIG
156
+ */
157
+ constructor(config = {}) {
158
+ this._cfg = { ...DEFAULT_CONFIG, ...config };
159
+ }
160
+
161
+ // ── Express middleware ────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Returns an Express-compatible middleware function.
165
+ *
166
+ * Attaches req.csrfToken() to every request for use in templates.
167
+ * Validates the token on state-changing methods.
168
+ */
169
+ middleware() {
170
+ const cfg = this._cfg;
171
+
172
+ return (req, res, next) => {
173
+ // ── Check if this path is excluded ────────────────────────────────────
174
+ if (this._isExcluded(req.path || req.url, cfg.exclude)) {
175
+ req.csrfToken = () => '';
176
+ return next();
177
+ }
178
+
179
+ // ── Always attach csrfToken() helper to req ───────────────────────────
180
+ // Lazily generate + cache the token for this request cycle
181
+ let _token = null;
182
+ req.csrfToken = () => {
183
+ if (!_token) {
184
+ // Re-use existing cookie token if valid, otherwise generate new one
185
+ const existing = req.cookies?.[cfg.cookieName];
186
+ _token = (existing && verifyToken(existing)) ? existing : generateToken(cfg.tokenLength);
187
+
188
+ // Set / refresh the CSRF cookie
189
+ // httpOnly: false — client JS must read this for AJAX
190
+ // sameSite: Strict — defence-in-depth
191
+ res.cookie(cfg.cookieName, _token, {
192
+ httpOnly: false,
193
+ sameSite: 'Strict',
194
+ secure: process.env.NODE_ENV === 'production',
195
+ path: '/',
196
+ });
197
+ }
198
+ return _token;
199
+ };
200
+
201
+ // ── Safe methods — no validation needed, just ensure cookie is set ────
202
+ if (SAFE_METHODS.has(req.method)) {
203
+ // Touch the token so the cookie is always present after a GET
204
+ req.csrfToken();
205
+ return next();
206
+ }
207
+
208
+ // ── State-changing methods — validate the submitted token ─────────────
209
+ const submitted =
210
+ req.body?.[cfg.fieldName] || // form field
211
+ req.query?.[cfg.fieldName] || // query string (not recommended but supported)
212
+ req.headers?.[cfg.headerName] || // AJAX header (X-CSRF-Token)
213
+ req.headers?.['x-xsrf-token']; // Angular-style header alias
214
+
215
+ if (!submitted || !verifyToken(submitted)) {
216
+ const err = new Error('Invalid or missing CSRF token');
217
+ err.status = 403;
218
+ err.code = 'EBADCSRFTOKEN';
219
+ return next(err);
220
+ }
221
+
222
+ // ── Rotate the token after a successful state-changing request ─────────
223
+ // Regenerate so each token is single-use (defence-in-depth)
224
+ _token = null; // force new token on next csrfToken() call
225
+
226
+ next();
227
+ };
228
+ }
229
+
230
+ // ── Internal helpers ──────────────────────────────────────────────────────
231
+
232
+ _isExcluded(path, excludeList) {
233
+ if (!excludeList || excludeList.length === 0) return false;
234
+ return excludeList.some(prefix => path.startsWith(prefix));
235
+ }
236
+
237
+ // ── Static factory ────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Create a CsrfMiddleware from a config section.
241
+ *
242
+ * CsrfMiddleware.from(config.security?.csrf)
243
+ *
244
+ * @param {object|false|undefined} config
245
+ * @returns {CsrfMiddleware}
246
+ */
247
+ static from(config) {
248
+ return new CsrfMiddleware(config || {});
249
+ }
250
+
251
+ /**
252
+ * Expose token utilities for testing and custom integrations.
253
+ */
254
+ static generateToken(length) { return generateToken(length); }
255
+ static verifyToken(token) { return verifyToken(token); }
256
+ }
257
+
258
+ module.exports = CsrfMiddleware;