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
@@ -1,48 +1,57 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const bcrypt = require('bcryptjs');
5
4
 
6
5
  /**
7
6
  * AdminAuth
8
7
  *
9
- * Handles authentication for the Millas admin panel.
8
+ * Authentication for the Millas admin panel.
10
9
  *
11
- * Uses a signed, httpOnly cookie for sessions — no express-session
12
- * or database required. The cookie payload is HMAC-signed with
13
- * APP_KEY so it cannot be forged.
10
+ * ── How it works (Django parity) ─────────────────────────────────────────────
14
11
  *
15
- * Configuration (in Admin.configure or config/admin.js):
12
+ * 1. Login: find user by email in the app's User model (same table the API uses).
13
+ * Check password with bcrypt. Require is_active=true AND is_staff=true.
14
+ * On success, store only { id } in a signed, httpOnly cookie — never the
15
+ * full user object.
16
+ *
17
+ * 2. Every request: read { id } from the cookie, call User.find(id) to get a
18
+ * live user record. Attach it as req.adminUser. If the user has been
19
+ * deactivated since their last request, they are immediately locked out —
20
+ * no need to wait for session expiry.
21
+ *
22
+ * 3. is_staff gate: only users with is_staff=true can enter the admin.
23
+ * is_superuser=true bypasses all resource-level permission checks (Phase 6).
24
+ *
25
+ * ── Configuration ────────────────────────────────────────────────────────────
16
26
  *
17
27
  * Admin.configure({
18
28
  * auth: {
19
- * // Static user list good for simple setups
20
- * users: [
21
- * { email: 'admin@example.com', password: 'plain-or-bcrypt-hash', name: 'Admin' },
22
- * ],
23
- *
24
- * // OR: use a Model — any model with email + password fields
29
+ * // Optional AdminServiceProvider resolves this automatically from
30
+ * // app/models/User (falling back to the built-in AuthUser).
31
+ * // Only set this if you want to override the model explicitly.
25
32
  * model: UserModel,
26
33
  *
27
- * // Cookie settings
28
- * cookieName: 'millas_admin', // default
29
- * cookieMaxAge: 60 * 60 * 8, // 8 hours (seconds), default
30
- * rememberAge: 60 * 60 * 24 * 30, // 30 days when "remember me" checked
31
- *
32
- * // Rate limiting (per IP)
33
- * maxAttempts: 5,
34
+ * cookieName: 'millas_admin', // default
35
+ * cookieMaxAge: 60 * 60 * 8, // 8 hours (seconds)
36
+ * rememberAge: 60 * 60 * 24 * 30, // 30 days ("remember me")
37
+ * maxAttempts: 5,
34
38
  * lockoutMinutes: 15,
35
39
  * }
36
40
  * });
37
41
  *
38
- * Disable auth entirely:
42
+ * Disable auth entirely (not recommended):
39
43
  * Admin.configure({ auth: false });
40
44
  */
41
45
  class AdminAuth {
42
46
  constructor() {
43
- this._config = null;
47
+ this._config = null;
48
+ this._UserModel = null; // resolved by AdminServiceProvider
49
+ this._basePath = null; // resolved by AdminServiceProvider.setBasePath()
50
+ this._attempts = new Map();
44
51
  }
45
52
 
53
+ // ─── Configuration ────────────────────────────────────────────────────────
54
+
46
55
  configure(authConfig) {
47
56
  if (authConfig === false) {
48
57
  this._config = false;
@@ -50,8 +59,7 @@ class AdminAuth {
50
59
  }
51
60
 
52
61
  this._config = {
53
- users: [],
54
- model: null,
62
+ model: null, // overridden by setUserModel() or config
55
63
  cookieName: 'millas_admin',
56
64
  cookieMaxAge: 60 * 60 * 8,
57
65
  rememberAge: 60 * 60 * 24 * 30,
@@ -60,10 +68,33 @@ class AdminAuth {
60
68
  ...authConfig,
61
69
  };
62
70
 
63
- // Rate limit store: Map<ip, { count, lockedUntil }>
71
+ // If the config block supplied an explicit model, use it
72
+ if (this._config.model) {
73
+ this._UserModel = this._config.model;
74
+ }
75
+
64
76
  this._attempts = new Map();
65
77
  }
66
78
 
79
+ /**
80
+ * Called by AdminServiceProvider after Auth is booted.
81
+ * Provides the resolved User model (app/models/User or AuthUser fallback).
82
+ * Only applied if no explicit model was set in the auth config block.
83
+ */
84
+ setUserModel(UserModel) {
85
+ if (!this._UserModel) {
86
+ this._UserModel = UserModel;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Called by AdminServiceProvider to provide the project basePath.
92
+ * Used by _resolveUserModel() so it never calls process.cwd() at request time.
93
+ */
94
+ setBasePath(basePath) {
95
+ this._basePath = basePath || null;
96
+ }
97
+
67
98
  /** Returns true if auth is enabled. */
68
99
  get enabled() {
69
100
  return this._config !== null && this._config !== false;
@@ -72,83 +103,120 @@ class AdminAuth {
72
103
  // ─── Middleware ────────────────────────────────────────────────────────────
73
104
 
74
105
  /**
75
- * Express middleware — allows the request through if the admin session
76
- * cookie is valid. Redirects to the login page otherwise.
106
+ * Express middleware — runs before every admin route.
107
+ *
108
+ * Verifies the signed session cookie, loads the live user from DB,
109
+ * checks is_active + is_staff, attaches to req.adminUser.
110
+ * Redirects to login if any check fails.
77
111
  */
78
112
  middleware(prefix) {
79
- return (req, res, next) => {
113
+ return async (req, res, next) => {
80
114
  if (!this.enabled) return next();
81
115
 
82
116
  const loginPath = `${prefix}/login`;
117
+ const p = req.path;
83
118
 
84
- // Always allow login page and logout
85
- if (req.path === '/login' || req.path === `${prefix}/login`) return next();
86
- if (req.path === '/logout' || req.path === `${prefix}/logout`) return next();
119
+ // Always let login/logout through
120
+ if (p === '/login' || p === '/logout') return next();
87
121
 
88
- const user = this._getSession(req);
89
- if (!user) {
122
+ const session = this._getSession(req);
123
+ if (!session) {
90
124
  const returnTo = encodeURIComponent(req.originalUrl);
91
125
  return res.redirect(`${loginPath}?next=${returnTo}`);
92
126
  }
93
127
 
128
+ // Live user lookup — deactivated users are locked out immediately
129
+ const user = await this._loadUser(session.id);
130
+ if (!user) {
131
+ this._clearSessionCookie(res);
132
+ return res.redirect(`${loginPath}?next=${encodeURIComponent(req.originalUrl)}`);
133
+ }
134
+
135
+ if (!user.is_active) {
136
+ this._clearSessionCookie(res);
137
+ this.setFlash(res, 'error', 'This account is inactive.');
138
+ return res.redirect(loginPath);
139
+ }
140
+
141
+ if (!user.is_staff) {
142
+ this._clearSessionCookie(res);
143
+ this.setFlash(res, 'error', 'You do not have staff access to this admin.');
144
+ return res.redirect(loginPath);
145
+ }
146
+
94
147
  req.adminUser = user;
95
148
  next();
96
149
  };
97
150
  }
98
151
 
99
- // ─── Login ─────────────────────────────────────────────────────────────────
152
+ // ─── Login ────────────────────────────────────────────────────────────────
100
153
 
101
154
  /**
102
155
  * Attempt to log in with email + password.
103
- * Returns the user object on success, throws on failure.
156
+ * Enforces is_active + is_staff. Throws on failure.
157
+ * On success, writes a signed session cookie containing only { id }.
104
158
  */
105
159
  async login(req, res, { email, password, remember = false }) {
106
- if (!this.enabled) return { email: 'admin', name: 'Admin' };
160
+ if (!this.enabled) return null;
107
161
 
108
162
  const ip = req.ip || req.connection?.remoteAddress || 'unknown';
109
163
  this._checkRateLimit(ip);
110
164
 
111
- const user = await this._findUser(email);
165
+ const normalised = (email || '').trim().toLowerCase();
166
+ const user = await this._loadUserByEmail(normalised);
167
+
168
+ // Always check password first — avoids leaking account existence
169
+ const Hasher = require('../auth/Hasher');
170
+ const validPassword = user ? await Hasher.check(password, user.password) : false;
112
171
 
113
- if (!user || !await this._checkPassword(password, user.password)) {
172
+ if (!user || !validPassword) {
114
173
  this._recordFailedAttempt(ip);
115
- throw new Error('Invalid email or password.');
174
+ throw new Error('Please enter the correct email and password for a staff account. Note that both fields may be case-sensitive.');
175
+ }
176
+
177
+ if (!user.is_active) {
178
+ throw new Error('This account is inactive.');
179
+ }
180
+
181
+ if (!user.is_staff) {
182
+ // Exactly what Django says
183
+ throw new Error('Please enter the correct email and password for a staff account. Note that both fields may be case-sensitive.');
116
184
  }
117
185
 
118
186
  this._clearAttempts(ip);
119
187
 
120
- const maxAge = remember
121
- ? this._config.rememberAge
122
- : this._config.cookieMaxAge;
188
+ // Update last_login (fire-and-forget)
189
+ try {
190
+ await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
191
+ } catch { /* non-fatal */ }
123
192
 
124
- this._setSession(res, { email: user.email, name: user.name || user.email }, maxAge);
193
+ const maxAge = remember ? this._config.rememberAge : this._config.cookieMaxAge;
194
+ // Store only the PK — everything else is loaded fresh per request
195
+ this._setSession(res, { id: user.id }, maxAge);
125
196
 
126
197
  return user;
127
198
  }
128
199
 
129
200
  /** Destroy the admin session cookie. */
130
201
  logout(res) {
131
- res.clearCookie(this._config.cookieName, { path: '/' });
202
+ this._clearSessionCookie(res);
132
203
  }
133
204
 
134
205
  // ─── Flash (cookie-based) ─────────────────────────────────────────────────
135
206
 
136
- /** Store a flash message in a short-lived cookie. */
137
207
  setFlash(res, type, message) {
138
208
  const payload = JSON.stringify({ type, message });
139
209
  res.cookie('millas_flash', Buffer.from(payload).toString('base64'), {
140
210
  httpOnly: true,
141
- maxAge: 10 * 1000, // 10 seconds — survives exactly one redirect
211
+ maxAge: 10 * 1000,
142
212
  path: '/',
143
213
  sameSite: 'lax',
144
214
  });
145
215
  }
146
216
 
147
- /** Read and clear the flash cookie. */
148
217
  getFlash(req, res) {
149
218
  const raw = this._parseCookies(req)['millas_flash'];
150
219
  if (!raw) return {};
151
- // Clear it immediately
152
220
  res.clearCookie('millas_flash', { path: '/' });
153
221
  try {
154
222
  const { type, message } = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
@@ -156,26 +224,24 @@ class AdminAuth {
156
224
  } catch { return {}; }
157
225
  }
158
226
 
159
- // ─── Session internals ────────────────────────────────────────────────────
227
+ // ─── Session ──────────────────────────────────────────────────────────────
160
228
 
161
229
  _setSession(res, payload, maxAge) {
162
- const name = this._config.cookieName;
163
- const data = Buffer.from(JSON.stringify(payload)).toString('base64');
164
- const sig = this._sign(data);
165
- const value = `${data}.${sig}`;
166
-
167
- res.cookie(name, value, {
230
+ const name = this._config.cookieName;
231
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64');
232
+ const sig = this._sign(data);
233
+ res.cookie(name, `${data}.${sig}`, {
168
234
  httpOnly: true,
169
235
  maxAge: maxAge * 1000,
170
236
  path: '/',
171
237
  sameSite: 'lax',
172
- // secure: true uncomment in production behind HTTPS
238
+ // secure: true enable in production behind HTTPS
173
239
  });
174
240
  }
175
241
 
176
242
  _getSession(req) {
177
- const name = this._config.cookieName;
178
- const raw = this._parseCookies(req)[name];
243
+ const name = this._config?.cookieName || 'millas_admin';
244
+ const raw = this._parseCookies(req)[name];
179
245
  if (!raw) return null;
180
246
 
181
247
  const dot = raw.lastIndexOf('.');
@@ -183,7 +249,6 @@ class AdminAuth {
183
249
 
184
250
  const data = raw.slice(0, dot);
185
251
  const sig = raw.slice(dot + 1);
186
-
187
252
  if (sig !== this._sign(data)) return null;
188
253
 
189
254
  try {
@@ -191,91 +256,141 @@ class AdminAuth {
191
256
  } catch { return null; }
192
257
  }
193
258
 
259
+ _clearSessionCookie(res) {
260
+ const name = this._config?.cookieName || 'millas_admin';
261
+ res.clearCookie(name, { path: '/' });
262
+ }
263
+
194
264
  _sign(data) {
195
265
  const secret = process.env.APP_KEY || 'millas-admin-secret-change-me';
196
266
  return crypto.createHmac('sha256', secret).update(data).digest('hex').slice(0, 32);
197
267
  }
198
268
 
269
+ // ─── CSRF ─────────────────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Generate a CSRF token tied to the current session.
273
+ * Token = HMAC(sessionId + timestamp_hour) so it rotates hourly
274
+ * but stays valid for the full hour — no per-request token storage needed.
275
+ *
276
+ * @param {object} req
277
+ * @returns {string}
278
+ */
279
+ csrfToken(req) {
280
+ const session = this._getSession(req);
281
+ const hourSlot = Math.floor(Date.now() / (1000 * 60 * 60)); // changes every hour
282
+ const payload = `csrf:${session?.id || 'anon'}:${hourSlot}`;
283
+ return this._sign(payload);
284
+ }
285
+
286
+ /**
287
+ * Verify a CSRF token submitted with a form.
288
+ * Accepts tokens from the current hour OR the previous hour (grace period).
289
+ *
290
+ * @param {object} req
291
+ * @param {string} token — value from req.body._csrf or X-CSRF-Token header
292
+ * @returns {boolean}
293
+ */
294
+ verifyCsrf(req, token) {
295
+ if (!token) return false;
296
+ const session = this._getSession(req);
297
+ const hourSlot = Math.floor(Date.now() / (1000 * 60 * 60));
298
+ // Check current hour and previous hour (grace period for forms submitted near the boundary)
299
+ for (const slot of [hourSlot, hourSlot - 1]) {
300
+ const payload = `csrf:${session?.id || 'anon'}:${slot}`;
301
+ if (token === this._sign(payload)) return true;
302
+ }
303
+ return false;
304
+ }
305
+
199
306
  _parseCookies(req) {
200
- const header = req.headers.cookie || '';
201
307
  const result = {};
202
- for (const part of header.split(';')) {
308
+ for (const part of (req.headers.cookie || '').split(';')) {
203
309
  const [k, ...v] = part.trim().split('=');
204
310
  if (k) result[k.trim()] = decodeURIComponent(v.join('='));
205
311
  }
206
312
  return result;
207
313
  }
208
314
 
209
- // ─── User lookup ──────────────────────────────────────────────────────────
315
+ // ─── User loading ──────────────────────────────────────────────────────────
210
316
 
211
- async _findUser(email) {
212
- const cfg = this._config;
213
- const normalised = (email || '').trim().toLowerCase();
317
+ /**
318
+ * Load a user by PK. Returns null if not found or model not ready.
319
+ * Used by middleware on every admin request.
320
+ */
321
+ async _loadUser(id) {
322
+ const M = this._resolveUserModel();
323
+ if (!M || !id) return null;
324
+ try {
325
+ return await M.find(id) || null;
326
+ } catch { return null; }
327
+ }
214
328
 
215
- // Model-based lookup
216
- if (cfg.model) {
217
- try {
218
- return await cfg.model.findBy('email', normalised);
219
- } catch { return null; }
220
- }
329
+ /**
330
+ * Load a user by email. Returns null if not found.
331
+ * Used during login.
332
+ */
333
+ async _loadUserByEmail(email) {
334
+ const M = this._resolveUserModel();
335
+ if (!M) return null;
336
+ try {
337
+ return await M.findBy('email', email) || null;
338
+ } catch { return null; }
339
+ }
221
340
 
222
- // Static user list
223
- if (cfg.users && cfg.users.length) {
224
- return cfg.users.find(u =>
225
- (u.email || '').trim().toLowerCase() === normalised
226
- ) || null;
227
- }
341
+ /**
342
+ * Resolve the User model.
343
+ * Priority: explicitly set via setUserModel() / config.model
344
+ * → app/models/User built-in AuthUser
345
+ */
346
+ _resolveUserModel() {
347
+ if (this._UserModel) return this._UserModel;
228
348
 
229
- return null;
230
- }
349
+ // Lazy fallback — allows AdminAuth to work even if boot order is unusual
350
+ try {
351
+ const nodePath = require('path');
352
+ const base = this._basePath || process.cwd();
353
+ const appUser = nodePath.join(base, 'app/models/User');
354
+ this._UserModel = require(appUser);
355
+ return this._UserModel;
356
+ } catch {}
231
357
 
232
- async _checkPassword(plain, hash) {
233
- if (!plain || !hash) return false;
234
- // Support both plain-text passwords (dev) and bcrypt hashes (prod)
235
- if (hash.startsWith('$2')) {
236
- return bcrypt.compare(String(plain), hash);
237
- }
238
- // Plain text comparison — warn in development
239
- if (process.env.NODE_ENV !== 'production') {
240
- process.stderr.write(
241
- '[millas admin] Warning: using plain-text password. Use a bcrypt hash in production.\n'
242
- );
243
- }
244
- return plain === hash;
358
+ try {
359
+ this._UserModel = require('../auth/AuthUser');
360
+ return this._UserModel;
361
+ } catch { return null; }
245
362
  }
246
363
 
247
364
  // ─── Rate limiting ────────────────────────────────────────────────────────
248
365
 
249
366
  _checkRateLimit(ip) {
250
- const entry = this._attempts?.get(ip);
367
+ const entry = this._attempts.get(ip);
251
368
  if (!entry) return;
252
-
253
369
  if (entry.lockedUntil && Date.now() < entry.lockedUntil) {
254
370
  const mins = Math.ceil((entry.lockedUntil - Date.now()) / 60000);
255
371
  throw new Error(`Too many failed attempts. Try again in ${mins} minute${mins > 1 ? 's' : ''}.`);
256
372
  }
257
-
258
373
  if (entry.lockedUntil && Date.now() >= entry.lockedUntil) {
259
374
  this._attempts.delete(ip);
260
375
  }
261
376
  }
262
377
 
263
378
  _recordFailedAttempt(ip) {
264
- const entry = this._attempts?.get(ip) || { count: 0, lockedUntil: null };
379
+ const entry = this._attempts.get(ip) || { count: 0, lockedUntil: null };
265
380
  entry.count++;
266
381
  if (entry.count >= (this._config.maxAttempts || 5)) {
267
382
  const mins = this._config.lockoutMinutes || 15;
268
383
  entry.lockedUntil = Date.now() + mins * 60 * 1000;
269
384
  }
270
- this._attempts?.set(ip, entry);
385
+ this._attempts.set(ip, entry);
271
386
  }
272
387
 
273
388
  _clearAttempts(ip) {
274
- this._attempts?.delete(ip);
389
+ this._attempts.delete(ip);
275
390
  }
276
391
  }
277
392
 
278
393
  // Singleton
279
394
  const adminAuth = new AdminAuth();
280
395
  module.exports = adminAuth;
281
- module.exports.AdminAuth = AdminAuth;
396
+ module.exports.AdminAuth = AdminAuth;