millas 0.2.12-beta-1 → 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 (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,314 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * RateLimiter
5
+ *
6
+ * Per-IP and per-user rate limiting with an in-memory store by default
7
+ * and a pluggable store interface for Redis in production.
8
+ *
9
+ * ── Quick usage ───────────────────────────────────────────────────────────────
10
+ *
11
+ * const { RateLimiter } = require('millas/src/http/middleware/RateLimiter');
12
+ *
13
+ * // Global: 100 requests per 15 minutes per IP (applied in SecurityBootstrap)
14
+ * app.use(RateLimiter.perIp({ max: 100, windowMs: 15 * 60 * 1000 }).middleware());
15
+ *
16
+ * // Route-level: 5 login attempts per 10 minutes per IP
17
+ * Route.post('/login', [
18
+ * RateLimiter.perIp({ max: 5, windowMs: 10 * 60 * 1000, message: 'Too many login attempts' }).middleware(),
19
+ * ], loginHandler);
20
+ *
21
+ * // Authenticated routes: per-user limiting
22
+ * Route.post('/ai/chat', [
23
+ * RateLimiter.perUser({ max: 20, windowMs: 60 * 1000 }).middleware(),
24
+ * ], chatHandler);
25
+ *
26
+ * // Combined: per-IP first, then per-user
27
+ * Route.post('/api/messages', [
28
+ * RateLimiter.perIp({ max: 60, windowMs: 60 * 1000 }).middleware(),
29
+ * RateLimiter.perUser({ max: 30, windowMs: 60 * 1000 }).middleware(),
30
+ * ], handler);
31
+ *
32
+ * ── Redis store (production) ──────────────────────────────────────────────────
33
+ *
34
+ * const { RedisRateLimitStore } = require('millas/src/http/middleware/RateLimiter');
35
+ * const redis = require('ioredis');
36
+ *
37
+ * const store = new RedisRateLimitStore(new redis(process.env.REDIS_URL));
38
+ *
39
+ * RateLimiter.perIp({ max: 100, windowMs: 60000, store }).middleware();
40
+ *
41
+ * ── Configuration (config/security.js) ───────────────────────────────────────
42
+ *
43
+ * rateLimit: {
44
+ * global: {
45
+ * enabled: true,
46
+ * max: 100,
47
+ * windowMs: 15 * 60 * 1000, // 15 minutes
48
+ * },
49
+ * }
50
+ *
51
+ * ── Response headers ──────────────────────────────────────────────────────────
52
+ *
53
+ * Every rate-limited response includes:
54
+ * X-RateLimit-Limit: 100 — max requests in window
55
+ * X-RateLimit-Remaining: 42 — requests left in current window
56
+ * X-RateLimit-Reset: 1711234567 — UNIX timestamp when window resets
57
+ *
58
+ * On 429:
59
+ * Retry-After: 300 — seconds until the window resets
60
+ */
61
+
62
+ // ── In-memory store ────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * MemoryRateLimitStore
66
+ *
67
+ * Simple in-memory store using a Map.
68
+ * Fine for single-process deployments and development.
69
+ * For multi-process or multi-server deployments, use RedisRateLimitStore.
70
+ *
71
+ * Entries expire automatically — a cleanup sweep runs every 5 minutes
72
+ * to prevent unbounded memory growth.
73
+ */
74
+ class MemoryRateLimitStore {
75
+ constructor() {
76
+ this._store = new Map();
77
+ this._cleanup = setInterval(() => this._sweep(), 5 * 60 * 1000);
78
+ // Don't hold the process open just for cleanup
79
+ if (this._cleanup.unref) this._cleanup.unref();
80
+ }
81
+
82
+ /**
83
+ * Increment hit count for a key within a window.
84
+ *
85
+ * @param {string} key
86
+ * @param {number} windowMs
87
+ * @returns {{ count: number, resetAt: number }}
88
+ */
89
+ async increment(key, windowMs) {
90
+ const now = Date.now();
91
+ const entry = this._store.get(key);
92
+ const resetAt = entry && entry.resetAt > now ? entry.resetAt : now + windowMs;
93
+ const count = entry && entry.resetAt > now ? entry.count + 1 : 1;
94
+
95
+ this._store.set(key, { count, resetAt });
96
+ return { count, resetAt };
97
+ }
98
+
99
+ /**
100
+ * Reset the counter for a key (e.g. after successful login).
101
+ *
102
+ * @param {string} key
103
+ */
104
+ async reset(key) {
105
+ this._store.delete(key);
106
+ }
107
+
108
+ /**
109
+ * Stop the background cleanup interval.
110
+ * Call this in tests or on graceful shutdown to allow the process to exit.
111
+ */
112
+ destroy() {
113
+ clearInterval(this._cleanup);
114
+ }
115
+
116
+ /** Remove expired entries */
117
+ _sweep() {
118
+ const now = Date.now();
119
+ for (const [key, entry] of this._store) {
120
+ if (entry.resetAt <= now) this._store.delete(key);
121
+ }
122
+ }
123
+ }
124
+
125
+ // ── Redis store ────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * RedisRateLimitStore
129
+ *
130
+ * Production store backed by Redis. Requires an ioredis (or node-redis) client.
131
+ * Uses atomic INCR + PEXPIRE so it works correctly across multiple processes.
132
+ *
133
+ * @example
134
+ * const redis = new Redis(process.env.REDIS_URL);
135
+ * const store = new RedisRateLimitStore(redis);
136
+ * RateLimiter.perIp({ max: 100, windowMs: 60000, store });
137
+ */
138
+ class RedisRateLimitStore {
139
+ /**
140
+ * @param {object} redisClient — ioredis or node-redis client instance
141
+ * @param {string} [prefix] — key prefix (default: 'rl:')
142
+ */
143
+ constructor(redisClient, prefix = 'rl:') {
144
+ this._redis = redisClient;
145
+ this._prefix = prefix;
146
+ }
147
+
148
+ async increment(key, windowMs) {
149
+ const redisKey = `${this._prefix}${key}`;
150
+
151
+ // Atomic pipeline: INCR then set expiry only on first hit
152
+ const [[, count], [, ttlMs]] = await this._redis
153
+ .pipeline()
154
+ .incr(redisKey)
155
+ .pttl(redisKey)
156
+ .exec();
157
+
158
+ // If this is the first hit (or key had no TTL), set the window expiry
159
+ if (count === 1 || ttlMs < 0) {
160
+ await this._redis.pexpire(redisKey, windowMs);
161
+ }
162
+
163
+ const resetAt = Date.now() + (ttlMs > 0 ? ttlMs : windowMs);
164
+ return { count, resetAt };
165
+ }
166
+
167
+ async reset(key) {
168
+ await this._redis.del(`${this._prefix}${key}`);
169
+ }
170
+ }
171
+
172
+ // ── RateLimiter class ──────────────────────────────────────────────────────────
173
+
174
+ const DEFAULT_OPTIONS = {
175
+ max: 100,
176
+ windowMs: 15 * 60 * 1000, // 15 minutes
177
+ message: 'Too many requests, please try again later.',
178
+ statusCode: 429,
179
+ keyBy: 'ip', // 'ip' | 'user' | function(req) => string
180
+ store: null, // defaults to MemoryRateLimitStore
181
+ skip: null, // optional function(req) => bool — return true to skip
182
+ onLimitReached: null, // optional function(req, res, options) — called on 429
183
+ };
184
+
185
+ class RateLimiter {
186
+ /**
187
+ * @param {object} options
188
+ */
189
+ constructor(options = {}) {
190
+ this._opts = { ...DEFAULT_OPTIONS, ...options };
191
+ this._store = options.store || new MemoryRateLimitStore();
192
+ }
193
+
194
+ // ── Express middleware ────────────────────────────────────────────────────
195
+
196
+ middleware() {
197
+ const opts = this._opts;
198
+ const store = this._store;
199
+
200
+ return async (req, res, next) => {
201
+ try {
202
+ // ── Skip check ─────────────────────────────────────────────────────
203
+ if (opts.skip && opts.skip(req)) return next();
204
+
205
+ // ── Resolve key ────────────────────────────────────────────────────
206
+ const key = this._resolveKey(req, opts.keyBy);
207
+ if (!key) return next(); // can't rate-limit without a key (e.g. no user yet)
208
+
209
+ // ── Increment counter ──────────────────────────────────────────────
210
+ const { count, resetAt } = await store.increment(key, opts.windowMs);
211
+ const remaining = Math.max(0, opts.max - count);
212
+ const resetSec = Math.ceil(resetAt / 1000);
213
+
214
+ // ── Set rate-limit headers (always, even on 429) ───────────────────
215
+ res.setHeader('X-RateLimit-Limit', opts.max);
216
+ res.setHeader('X-RateLimit-Remaining', remaining);
217
+ res.setHeader('X-RateLimit-Reset', resetSec);
218
+
219
+ // ── Attach store reset helper to req for auth flows ────────────────
220
+ // e.g. after successful login: await req.resetRateLimit()
221
+ req.resetRateLimit = () => store.reset(key);
222
+
223
+ // ── Enforce limit ──────────────────────────────────────────────────
224
+ if (count > opts.max) {
225
+ const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
226
+ res.setHeader('Retry-After', retryAfter);
227
+
228
+ if (opts.onLimitReached) {
229
+ opts.onLimitReached(req, res, opts);
230
+ }
231
+
232
+ // Return JSON for API requests, plain text for others
233
+ const isApi = (req.headers?.accept || '').includes('application/json') ||
234
+ (req.headers?.['content-type'] || '').includes('application/json');
235
+
236
+ res.status(opts.statusCode);
237
+ if (isApi) {
238
+ res.setHeader('Content-Type', 'application/json');
239
+ return res.end(JSON.stringify({ error: opts.message, retryAfter }));
240
+ }
241
+ return res.end(opts.message);
242
+ }
243
+
244
+ next();
245
+ } catch (err) {
246
+ // Store errors must never block requests — fail open
247
+ console.error('[Millas RateLimit] Store error:', err.message);
248
+ next();
249
+ }
250
+ };
251
+ }
252
+
253
+ // ── Internal helpers ──────────────────────────────────────────────────────
254
+
255
+ _resolveKey(req, keyBy) {
256
+ if (typeof keyBy === 'function') return keyBy(req);
257
+
258
+ if (keyBy === 'user') {
259
+ // req.user is set by AuthMiddleware — if not authenticated, fall back to IP
260
+ const userId = req.user?.id || req.user?._id;
261
+ return userId ? `user:${userId}` : this._resolveKey(req, 'ip');
262
+ }
263
+
264
+ if (keyBy === 'ip') {
265
+ const ip = req.ip ||
266
+ req.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ||
267
+ req.connection?.remoteAddress;
268
+ return ip ? `ip:${ip}` : null;
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ // ── Static factories (fluent API) ─────────────────────────────────────────
275
+
276
+ /**
277
+ * Limit by IP address.
278
+ *
279
+ * RateLimiter.perIp({ max: 5, windowMs: 10 * 60 * 1000 }).middleware()
280
+ */
281
+ static perIp(options = {}) {
282
+ return new RateLimiter({ ...options, keyBy: 'ip' });
283
+ }
284
+
285
+ /**
286
+ * Limit by authenticated user (falls back to IP if not authenticated).
287
+ *
288
+ * RateLimiter.perUser({ max: 20, windowMs: 60 * 1000 }).middleware()
289
+ */
290
+ static perUser(options = {}) {
291
+ return new RateLimiter({ ...options, keyBy: 'user' });
292
+ }
293
+
294
+ /**
295
+ * Limit by a custom key resolver.
296
+ *
297
+ * RateLimiter.by(req => req.headers['x-api-key'], { max: 1000 }).middleware()
298
+ */
299
+ static by(keyFn, options = {}) {
300
+ return new RateLimiter({ ...options, keyBy: keyFn });
301
+ }
302
+
303
+ /**
304
+ * Create from config section.
305
+ *
306
+ * RateLimiter.from(config.security?.rateLimit?.global)
307
+ */
308
+ static from(options) {
309
+ if (options === false || options?.enabled === false) return null;
310
+ return RateLimiter.perIp(options || {});
311
+ }
312
+ }
313
+
314
+ module.exports = { RateLimiter, MemoryRateLimitStore, RedisRateLimitStore };
@@ -0,0 +1,281 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SecurityHeaders middleware
5
+ *
6
+ * Sets secure HTTP response headers on every outgoing response.
7
+ * Enabled by default in the Millas HTTP kernel — opt-out, not opt-in.
8
+ *
9
+ * Covers:
10
+ * • Content-Security-Policy — XSS mitigation
11
+ * • Strict-Transport-Security — HTTPS enforcement
12
+ * • X-Frame-Options — clickjacking prevention
13
+ * • X-Content-Type-Options — MIME sniffing prevention
14
+ * • Referrer-Policy — referrer leakage prevention
15
+ * • Permissions-Policy — browser feature scoping
16
+ * • X-Powered-By removal — fingerprinting reduction
17
+ *
18
+ * ── Usage (automatic — loaded by HttpKernel) ────────────────────────────────
19
+ *
20
+ * // Loaded automatically. No developer action required.
21
+ *
22
+ * ── Customising CSP (config/security.js) ────────────────────────────────────
23
+ *
24
+ * module.exports = {
25
+ * headers: {
26
+ * contentSecurityPolicy: {
27
+ * directives: {
28
+ * defaultSrc: ["'self'"],
29
+ * scriptSrc: ["'self'", 'cdn.example.com'],
30
+ * imgSrc: ["'self'", 'data:', 'https:'],
31
+ * },
32
+ * },
33
+ * // Disable a specific header:
34
+ * xFrameOptions: false,
35
+ * },
36
+ * };
37
+ *
38
+ * ── Disabling entirely (not recommended) ───────────────────────────────────
39
+ *
40
+ * // config/security.js
41
+ * module.exports = { headers: false };
42
+ *
43
+ * ── Nonces (for inline scripts) ────────────────────────────────────────────
44
+ *
45
+ * // The middleware attaches a per-request nonce to req.cspNonce
46
+ * // Use it in templates: <script nonce="<%= req.cspNonce %>">
47
+ * //
48
+ * // Enable via config:
49
+ * contentSecurityPolicy: {
50
+ * useNonce: true,
51
+ * directives: {
52
+ * scriptSrc: ["'self'"], // nonce is appended automatically
53
+ * },
54
+ * },
55
+ */
56
+
57
+ const crypto = require('crypto');
58
+
59
+ // ── Defaults ──────────────────────────────────────────────────────────────────
60
+
61
+ const DEFAULTS = {
62
+ contentSecurityPolicy: {
63
+ useNonce: false,
64
+ directives: {
65
+ defaultSrc: ["'self'"],
66
+ scriptSrc: ["'self'"],
67
+ styleSrc: ["'self'", "'unsafe-inline'"],
68
+ imgSrc: ["'self'", 'data:'],
69
+ fontSrc: ["'self'"],
70
+ connectSrc: ["'self'"],
71
+ objectSrc: ["'none'"],
72
+ frameSrc: ["'none'"],
73
+ baseUri: ["'self'"],
74
+ formAction: ["'self'"],
75
+ frameAncestors:["'none'"],
76
+ upgradeInsecureRequests: [],
77
+ },
78
+ },
79
+
80
+ // HSTS: 1 year, include subdomains, allow preload
81
+ // Only sent over HTTPS — the middleware checks req.secure
82
+ strictTransportSecurity: {
83
+ maxAge: 31536000,
84
+ includeSubDomains: true,
85
+ preload: true,
86
+ },
87
+
88
+ // Deny framing entirely — use 'SAMEORIGIN' if you need iframes on your own domain
89
+ xFrameOptions: 'DENY',
90
+
91
+ // Prevent MIME-type sniffing
92
+ xContentTypeOptions: true,
93
+
94
+ // Only send the origin (no path) as referrer; never send cross-origin referrer
95
+ referrerPolicy: 'strict-origin-when-cross-origin',
96
+
97
+ // Disable access to sensitive browser features by default
98
+ permissionsPolicy: {
99
+ camera: '()',
100
+ microphone: '()',
101
+ geolocation: '()',
102
+ payment: '()',
103
+ usb: '()',
104
+ magnetometer: '()',
105
+ gyroscope: '()',
106
+ accelerometer: '()',
107
+ },
108
+
109
+ // Remove X-Powered-By: Express
110
+ removePoweredBy: true,
111
+ };
112
+
113
+ // ── CSP builder ───────────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Build a Content-Security-Policy header value from a directives object.
117
+ *
118
+ * @param {object} directives
119
+ * @param {string|null} nonce
120
+ * @returns {string}
121
+ */
122
+ function buildCsp(directives, nonce = null) {
123
+ return Object.entries(directives)
124
+ .map(([key, value]) => {
125
+ // camelCase → kebab-case
126
+ const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase();
127
+
128
+ if (!Array.isArray(value) || value.length === 0) {
129
+ // Bare directives (e.g. upgrade-insecure-requests)
130
+ if (Array.isArray(value) && value.length === 0) return directive;
131
+ return null;
132
+ }
133
+
134
+ let sources = [...value];
135
+
136
+ // Inject nonce into scriptSrc and styleSrc if enabled
137
+ if (nonce && (key === 'scriptSrc' || key === 'styleSrc')) {
138
+ sources = sources.filter(s => !s.startsWith("'nonce-"));
139
+ sources.push(`'nonce-${nonce}'`);
140
+ }
141
+
142
+ return `${directive} ${sources.join(' ')}`;
143
+ })
144
+ .filter(Boolean)
145
+ .join('; ');
146
+ }
147
+
148
+ // ── Permissions-Policy builder ────────────────────────────────────────────────
149
+
150
+ function buildPermissionsPolicy(features) {
151
+ return Object.entries(features)
152
+ .map(([feature, allowlist]) => `${feature}=${allowlist}`)
153
+ .join(', ');
154
+ }
155
+
156
+ // ── Merge helpers ─────────────────────────────────────────────────────────────
157
+
158
+ function mergeDeep(target, source) {
159
+ const result = { ...target };
160
+ for (const key of Object.keys(source)) {
161
+ if (
162
+ source[key] !== null &&
163
+ typeof source[key] === 'object' &&
164
+ !Array.isArray(source[key]) &&
165
+ typeof target[key] === 'object' &&
166
+ !Array.isArray(target[key])
167
+ ) {
168
+ result[key] = mergeDeep(target[key], source[key]);
169
+ } else {
170
+ result[key] = source[key];
171
+ }
172
+ }
173
+ return result;
174
+ }
175
+
176
+ // ── SecurityHeaders class ─────────────────────────────────────────────────────
177
+
178
+ class SecurityHeaders {
179
+ /**
180
+ * @param {object|false} config — merged with DEFAULTS; pass false to disable
181
+ */
182
+ constructor(config = {}) {
183
+ if (config === false) {
184
+ this._disabled = true;
185
+ return;
186
+ }
187
+
188
+ this._disabled = false;
189
+ this._config = mergeDeep(DEFAULTS, config);
190
+ }
191
+
192
+ // ── Express middleware ────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Returns an Express-compatible middleware function.
196
+ *
197
+ * app.use(new SecurityHeaders(config).middleware());
198
+ *
199
+ * The Millas HttpKernel calls this automatically — developers
200
+ * do not need to call it directly unless customising bootstrap.
201
+ */
202
+ middleware() {
203
+ if (this._disabled) {
204
+ return (_req, _res, next) => next();
205
+ }
206
+
207
+ const cfg = this._config;
208
+
209
+ return (req, res, next) => {
210
+ // ── Remove X-Powered-By ──────────────────────────────────────────────
211
+ if (cfg.removePoweredBy) {
212
+ res.removeHeader('X-Powered-By');
213
+ }
214
+
215
+ // ── Content-Security-Policy ──────────────────────────────────────────
216
+ if (cfg.contentSecurityPolicy !== false) {
217
+ const cspCfg = cfg.contentSecurityPolicy;
218
+ let nonce = null;
219
+
220
+ if (cspCfg.useNonce) {
221
+ nonce = crypto.randomBytes(16).toString('base64');
222
+ // Attach to req so templates can use it: req.cspNonce
223
+ req.cspNonce = nonce;
224
+ }
225
+
226
+ const cspValue = buildCsp(cspCfg.directives, nonce);
227
+ res.setHeader('Content-Security-Policy', cspValue);
228
+ }
229
+
230
+ // ── Strict-Transport-Security ────────────────────────────────────────
231
+ // Only set over HTTPS. Express sets req.secure based on protocol.
232
+ if (cfg.strictTransportSecurity !== false) {
233
+ const hsts = cfg.strictTransportSecurity;
234
+ let value = `max-age=${hsts.maxAge}`;
235
+ if (hsts.includeSubDomains) value += '; includeSubDomains';
236
+ if (hsts.preload) value += '; preload';
237
+ // Always set it — if behind a proxy, trust the X-Forwarded-Proto header
238
+ res.setHeader('Strict-Transport-Security', value);
239
+ }
240
+
241
+ // ── X-Frame-Options ──────────────────────────────────────────────────
242
+ if (cfg.xFrameOptions !== false) {
243
+ res.setHeader('X-Frame-Options', cfg.xFrameOptions);
244
+ }
245
+
246
+ // ── X-Content-Type-Options ───────────────────────────────────────────
247
+ if (cfg.xContentTypeOptions !== false) {
248
+ res.setHeader('X-Content-Type-Options', 'nosniff');
249
+ }
250
+
251
+ // ── Referrer-Policy ──────────────────────────────────────────────────
252
+ if (cfg.referrerPolicy !== false) {
253
+ res.setHeader('Referrer-Policy', cfg.referrerPolicy);
254
+ }
255
+
256
+ // ── Permissions-Policy ───────────────────────────────────────────────
257
+ if (cfg.permissionsPolicy !== false) {
258
+ res.setHeader('Permissions-Policy', buildPermissionsPolicy(cfg.permissionsPolicy));
259
+ }
260
+
261
+ next();
262
+ };
263
+ }
264
+
265
+ // ── Static factory (convenience) ─────────────────────────────────────────
266
+
267
+ /**
268
+ * Create a SecurityHeaders instance from a config section.
269
+ *
270
+ * SecurityHeaders.from(config.security?.headers)
271
+ *
272
+ * @param {object|false|undefined} config
273
+ * @returns {SecurityHeaders}
274
+ */
275
+ static from(config) {
276
+ if (config === false) return new SecurityHeaders(false);
277
+ return new SecurityHeaders(config || {});
278
+ }
279
+ }
280
+
281
+ module.exports = SecurityHeaders;
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const ServiceProvider = require('../providers/ServiceProvider');
6
+ const Translator = require('./Translator');
7
+
8
+ /**
9
+ * I18nServiceProvider
10
+ *
11
+ * Boots the translation system and registers the Trans singleton
12
+ * into the DI container.
13
+ *
14
+ * ── Setup in bootstrap/app.js ─────────────────────────────────────────────
15
+ *
16
+ * const { Millas } = require('millas');
17
+ * const I18nServiceProvider = require('millas/src/i18n/I18nServiceProvider');
18
+ *
19
+ * module.exports = Millas.config()
20
+ * .providers([I18nServiceProvider, AppServiceProvider])
21
+ * .create();
22
+ *
23
+ * ── config/app.js ─────────────────────────────────────────────────────────
24
+ *
25
+ * module.exports = {
26
+ * locale: 'en', // default locale
27
+ * fallback: 'en', // fallback when translation missing
28
+ * };
29
+ *
30
+ * ── lang/ directory ───────────────────────────────────────────────────────
31
+ *
32
+ * Place translation files at <basePath>/lang/:
33
+ *
34
+ * lang/
35
+ * en.js ← source language
36
+ * sw.js ← Swahili
37
+ * fr.js ← French
38
+ *
39
+ * ── Route-level locale switching ──────────────────────────────────────────
40
+ *
41
+ * Use the built-in middleware to auto-detect locale from requests:
42
+ *
43
+ * const { Trans } = require('millas/src/i18n');
44
+ * app.use(Trans.middleware());
45
+ *
46
+ * Or manually in a route:
47
+ * Trans.setLocale('sw');
48
+ */
49
+ class I18nServiceProvider extends ServiceProvider {
50
+
51
+ register(container) {
52
+ // Register the Trans singleton into the DI container
53
+ const trans = require('./index').Trans;
54
+ container.instance('trans', trans);
55
+ container.instance('Trans', trans);
56
+ container.alias('i18n', 'trans');
57
+ }
58
+
59
+ async boot(container, app) {
60
+ const basePath = container.make('basePath') || process.cwd();
61
+ const trans = container.make('trans');
62
+
63
+ // Load config/app.js for locale settings
64
+ let locale = 'en';
65
+ let fallback = 'en';
66
+ let warnMissing = process.env.NODE_ENV !== 'production';
67
+
68
+ try {
69
+ const appConfig = require(path.join(basePath, 'config/app'));
70
+ if (appConfig.locale) locale = appConfig.locale;
71
+ if (appConfig.fallback) fallback = appConfig.fallback;
72
+ } catch { /* config/app.js not found or no locale keys */ }
73
+
74
+ // Lang path: <basePath>/lang/
75
+ const langPath = path.join(basePath, 'lang');
76
+
77
+ trans.configure({ locale, fallback, langPath, warnMissing });
78
+
79
+ // Log available locales on startup in debug mode
80
+ if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
81
+ const available = trans.availableLocales();
82
+ if (available.length > 0 && !(available.length === 1 && available[0] === 'en')) {
83
+ process.stdout.write(
84
+ `[i18n] Locale: ${locale} | Fallback: ${fallback} | Available: ${available.join(', ')}\n`
85
+ );
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ module.exports = I18nServiceProvider;