millas 0.2.21 → 0.2.24

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.
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const Facade = require('./Facade');
4
+
5
+ /**
6
+ * Schedule Facade
7
+ *
8
+ * Provides easy access to the task scheduler for defining scheduled tasks.
9
+ *
10
+ * Usage:
11
+ * const { Schedule } = require('millas/facades');
12
+ *
13
+ * Schedule.job(SendEmailJob).daily().at('09:00');
14
+ * Schedule.job(CleanupJob).hourly();
15
+ */
16
+ class Schedule extends Facade {
17
+ static getAccessor() {
18
+ return 'scheduler';
19
+ }
20
+ }
21
+
22
+ module.exports = Schedule;
@@ -1,12 +1,21 @@
1
1
  'use strict';
2
2
 
3
- const SecurityHeaders = require('./middleware/SecurityHeaders');
4
- const CsrfMiddleware = require('./middleware/CsrfMiddleware');
5
- const { RateLimiter } = require('./middleware/RateLimiter');
6
- const MillasResponse = require('./MillasResponse');
3
+ const SecurityHeaders = require('./middleware/SecurityHeaders');
4
+ const CsrfMiddleware = require('./middleware/CsrfMiddleware');
5
+ const { RateLimiter } = require('./middleware/RateLimiter');
6
+ const AllowedHostsMiddleware = require('./middleware/AllowedHostsMiddleware');
7
+ const MillasResponse = require('./MillasResponse');
7
8
 
8
9
  class SecurityBootstrap {
9
10
  static apply(app, config = {}) {
11
+ // ── ALLOWED_HOSTS (Django-style) ──────────────────────────────────────────
12
+ // Must be first — reject invalid hosts before any other processing
13
+ const allowedHostsMiddleware = AllowedHostsMiddleware.from({
14
+ allowedHosts: config.allowedHosts,
15
+ env: config.env || process.env.APP_ENV || 'production',
16
+ });
17
+ app.use(allowedHostsMiddleware.middleware());
18
+
10
19
  const headerConfig = config.headers !== undefined ? config.headers : {};
11
20
  app.use(SecurityHeaders.from(headerConfig).middleware());
12
21
 
@@ -48,6 +57,7 @@ class SecurityBootstrap {
48
57
 
49
58
  if (process.env.MILLAS_DEBUG_SECURITY === 'true') {
50
59
  console.log('[Millas Security] Controls applied:');
60
+ console.log(' ✓ Allowed hosts: ', config.allowedHosts === false ? 'DISABLED' : (config.allowedHosts || 'development defaults'));
51
61
  console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
52
62
  console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
53
63
  console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
@@ -69,6 +79,7 @@ class SecurityBootstrap {
69
79
  if (err.code === 'EVALIDATION' || err.name === 'ValidationError') {
70
80
  return res.status(422).json({ message: 'Validation failed', errors: err.errors || {} });
71
81
  }
82
+ // Pass all other errors (including EINVALIDHOST) to the main error handler
72
83
  next(err);
73
84
  });
74
85
  }
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AllowedHostsMiddleware
5
+ *
6
+ * Django-style ALLOWED_HOSTS protection.
7
+ * Validates the Host header against a whitelist to prevent Host header attacks.
8
+ *
9
+ * Usage in config/app.js:
10
+ * module.exports = {
11
+ * allowedHosts: ['example.com', 'www.example.com', 'localhost', '127.0.0.1'],
12
+ * // or use wildcard:
13
+ * allowedHosts: ['*.example.com', 'localhost'],
14
+ * };
15
+ *
16
+ * Default behavior:
17
+ * - Development (APP_ENV=development): allows localhost, 127.0.0.1, and any host
18
+ * - Production: requires explicit allowedHosts configuration
19
+ */
20
+ class AllowedHostsMiddleware {
21
+ constructor(config = {}) {
22
+ this._allowedHosts = config.allowedHosts;
23
+ this._env = config.env || 'production';
24
+
25
+ // In development, allow localhost and 127.0.0.1 by default
26
+ if (this._allowedHosts === undefined) {
27
+ this._allowedHosts = this._env === 'development'
28
+ ? ['localhost', '127.0.0.1', '[::1]']
29
+ : [];
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Check if a host is allowed
35
+ */
36
+ _isAllowed(host) {
37
+ if (!host) return false;
38
+
39
+ // Remove port from host
40
+ const hostname = host.split(':')[0];
41
+
42
+ // Check exact match
43
+ if (this._allowedHosts.includes(hostname)) {
44
+ return true;
45
+ }
46
+
47
+ // Check wildcard match (*.example.com)
48
+ for (const allowed of this._allowedHosts) {
49
+ if (allowed.startsWith('*.')) {
50
+ const domain = allowed.slice(2);
51
+ if (hostname.endsWith('.' + domain) || hostname === domain) {
52
+ return true;
53
+ }
54
+ }
55
+ }
56
+
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Express middleware
62
+ */
63
+ middleware() {
64
+ return (req, res, next) => {
65
+ const host = req.get('host');
66
+
67
+ if (!this._isAllowed(host)) {
68
+ const hostname = host.split(':')[0];
69
+ const message = `Invalid HTTP_HOST header: "${host}". You may need to add "${hostname}" to allowedHosts.`;
70
+
71
+ const error = new Error(message);
72
+ error.status = 400;
73
+ error.statusCode = 400;
74
+ error.code = 'EINVALIDHOST';
75
+ error._forceDebug = true;
76
+ error._hostDetails = {
77
+ receivedHost: host,
78
+ allowedHosts: this._allowedHosts,
79
+ environment: this._env,
80
+ suggestion: `Add "${hostname}" to the allowedHosts array in config/app.js`
81
+ };
82
+ return next(error);
83
+ }
84
+
85
+ next();
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Static factory
91
+ */
92
+ static from(config) {
93
+ return new AllowedHostsMiddleware(config || {});
94
+ }
95
+ }
96
+
97
+ module.exports = AllowedHostsMiddleware;
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * System Migration: Scheduler Locks
5
+ *
6
+ * Creates the scheduler_locks table for distributed locking.
7
+ * Prevents duplicate task execution across multiple app instances.
8
+ *
9
+ * This is a system migration - it runs automatically when you run `millas migrate`.
10
+ */
11
+
12
+ module.exports = {
13
+ async up(db) {
14
+ await db.schema.createTable('scheduler_locks', (table) => {
15
+ table.string('task_id', 255).primary();
16
+ table.timestamp('locked_at').notNullable();
17
+ table.timestamp('expires_at').notNullable();
18
+ table.integer('instance_id').notNullable();
19
+
20
+ // Index for cleanup queries
21
+ table.index('expires_at', 'idx_scheduler_locks_expires');
22
+ });
23
+
24
+ console.log(' ✓ Created scheduler_locks table');
25
+ },
26
+
27
+ async down(db) {
28
+ await db.schema.dropTableIfExists('scheduler_locks');
29
+ console.log(' ✓ Dropped scheduler_locks table');
30
+ },
31
+ };
@@ -51,6 +51,88 @@ class DatabaseManager {
51
51
  return this.connection();
52
52
  }
53
53
 
54
+ /**
55
+ * Query builder for a table (Laravel: DB::table('users'))
56
+ */
57
+ table(tableName) {
58
+ return this.db(tableName);
59
+ }
60
+
61
+ /**
62
+ * Execute raw SQL SELECT (Laravel: DB::select())
63
+ */
64
+ async select(sql, bindings = []) {
65
+ const result = await this.db.raw(sql, bindings);
66
+ // Normalize across dialects
67
+ if (result && result.rows) return result.rows;
68
+ if (Array.isArray(result)) return result[0] ?? result;
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Execute INSERT (Laravel: DB::insert())
74
+ */
75
+ async insert(sql, bindings = []) {
76
+ return this.db.raw(sql, bindings);
77
+ }
78
+
79
+ /**
80
+ * Execute UPDATE (Laravel: DB::update())
81
+ */
82
+ async update(sql, bindings = []) {
83
+ return this.db.raw(sql, bindings);
84
+ }
85
+
86
+ /**
87
+ * Execute DELETE (Laravel: DB::delete())
88
+ */
89
+ async delete(sql, bindings = []) {
90
+ return this.db.raw(sql, bindings);
91
+ }
92
+
93
+ /**
94
+ * Execute raw SQL (Laravel: DB::raw())
95
+ */
96
+ async raw(sql, bindings = []) {
97
+ return this.db.raw(sql, bindings);
98
+ }
99
+
100
+ /**
101
+ * Run queries in a transaction (Laravel: DB::transaction())
102
+ */
103
+ async transaction(callback) {
104
+ return this.db.transaction(callback);
105
+ }
106
+
107
+ /**
108
+ * Begin a transaction manually (Laravel: DB::beginTransaction())
109
+ */
110
+ async beginTransaction() {
111
+ const trx = await this.db.transaction();
112
+ return trx;
113
+ }
114
+
115
+ /**
116
+ * Execute a statement (Laravel: DB::statement())
117
+ */
118
+ async statement(sql, bindings = []) {
119
+ return this.db.raw(sql, bindings);
120
+ }
121
+
122
+ /**
123
+ * Execute unprepared statement (Laravel: DB::unprepared())
124
+ */
125
+ async unprepared(sql) {
126
+ return this.db.raw(sql);
127
+ }
128
+
129
+ /**
130
+ * Get schema builder (Laravel: DB::getSchemaBuilder())
131
+ */
132
+ get schema() {
133
+ return this.db.schema;
134
+ }
135
+
54
136
  /**
55
137
  * Close all connections.
56
138
  */
@@ -101,6 +101,24 @@ const fields = {
101
101
  return new FieldDefinition('json', options);
102
102
  },
103
103
 
104
+ /**
105
+ * Array field — stores an ordered list of values.
106
+ *
107
+ * On PostgreSQL: uses native ARRAY type (text[], integer[], etc.)
108
+ * On SQLite/MySQL: falls back to JSON column (same as fields.json())
109
+ *
110
+ * @param {string} [of='text'] — item type: 'text' | 'integer' | 'float' | 'boolean'
111
+ *
112
+ * @example
113
+ * tags: fields.array() // text[] on PG, json on SQLite
114
+ * scores: fields.array('integer') // integer[] on PG
115
+ * media_ids: fields.array('integer', { nullable: true, default: [] })
116
+ */
117
+ array(of = 'text', options = {}) {
118
+ if (typeof of === 'object') { options = of; of = 'text'; }
119
+ return new FieldDefinition('array', { arrayOf: of, nullable: true, default: [], ...options });
120
+ },
121
+
104
122
  date(options = {}) {
105
123
  return new FieldDefinition('date', options);
106
124
  },
@@ -408,6 +408,12 @@ ${opsCode},
408
408
  case 'id':
409
409
  return 'fields.id()';
410
410
 
411
+ case 'array': {
412
+ const of_ = def.arrayOf || 'text';
413
+ const optsStr = Object.keys(opts).length ? `, ${this._renderOpts(opts)}` : '';
414
+ return `fields.array('${of_}'${optsStr})`;
415
+ }
416
+
411
417
  case 'enum': {
412
418
  const vals = JSON.stringify(def.enumValues || []);
413
419
  const optsStr = Object.keys(opts).length
@@ -523,6 +523,10 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
523
523
  line = `t.json('${name}')`;
524
524
  break;
525
525
 
526
+ case 'array':
527
+ line = `t.specificType('${name}', '${field.arrayOf || 'text'}[]')`;
528
+ break;
529
+
526
530
  case 'date':
527
531
  line = `t.date('${name}')`;
528
532
  break;
@@ -93,6 +93,24 @@ function _buildColumn(t, name, def, opts = {}) {
93
93
  case 'json':
94
94
  return t.json(name);
95
95
 
96
+ case 'array': {
97
+ const client = t.client?.config?.client || '';
98
+ if (client.includes('pg') || client.includes('postgres')) {
99
+ // Native Postgres ARRAY type
100
+ const pgTypeMap = {
101
+ text: 'text', string: 'text',
102
+ integer: 'integer', int: 'integer',
103
+ float: 'float', decimal: 'decimal',
104
+ boolean: 'boolean',
105
+ uuid: 'uuid',
106
+ };
107
+ const pgType = pgTypeMap[def.arrayOf || 'text'] || 'text';
108
+ return t.specificType(name, `${pgType}[]`);
109
+ }
110
+ // SQLite / MySQL — fall back to JSON
111
+ return t.json(name);
112
+ }
113
+
96
114
  case 'date':
97
115
  return t.date(name);
98
116
 
@@ -957,7 +957,8 @@ class Model {
957
957
  case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
958
958
  case 'float':
959
959
  case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
960
- case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
960
+ case 'json':
961
+ case 'array': return typeof val === 'string' ? JSON.parse(val) : (Array.isArray(val) ? val : (val ?? []));
961
962
  case 'date':
962
963
  case 'timestamp': {
963
964
  if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
@@ -981,7 +982,7 @@ class Model {
981
982
 
982
983
  static _serializeValue(val, type) {
983
984
  if (val == null) return val;
984
- if (type === 'json') return typeof val === 'string' ? val : JSON.stringify(val);
985
+ if (type === 'json' || type === 'array') return typeof val === 'string' ? val : JSON.stringify(val);
985
986
  if (type === 'boolean') return val ? 1 : 0;
986
987
  if ((type === 'date' || type === 'timestamp') && val instanceof Date) return val.toISOString();
987
988
  return val;
@@ -253,6 +253,42 @@ class QueryBuilder {
253
253
  return this;
254
254
  }
255
255
 
256
+ join(table, col1, col2, col3) {
257
+ this._query = col3
258
+ ? this._query.join(table, col1, col2, col3)
259
+ : this._query.join(table, col1, col2);
260
+ return this;
261
+ }
262
+
263
+ leftJoin(table, col1, col2, col3) {
264
+ this._query = col3
265
+ ? this._query.leftJoin(table, col1, col2, col3)
266
+ : this._query.leftJoin(table, col1, col2);
267
+ return this;
268
+ }
269
+
270
+ rightJoin(table, col1, col2, col3) {
271
+ this._query = col3
272
+ ? this._query.rightJoin(table, col1, col2, col3)
273
+ : this._query.rightJoin(table, col1, col2);
274
+ return this;
275
+ }
276
+
277
+ joinRaw(sql, bindings = []) {
278
+ this._query = this._query.joinRaw(sql, bindings);
279
+ return this;
280
+ }
281
+
282
+ leftJoinRaw(sql, bindings = []) {
283
+ this._query = this._query.leftJoinRaw(sql, bindings);
284
+ return this;
285
+ }
286
+
287
+ crossJoin(table) {
288
+ this._query = this._query.crossJoin(table);
289
+ return this;
290
+ }
291
+
256
292
  having(column, operatorOrValue, value) {
257
293
  if (value !== undefined) {
258
294
  this._query = this._query.having(column, operatorOrValue, value);
@@ -561,6 +597,38 @@ class QueryBuilder {
561
597
  continue;
562
598
  }
563
599
 
600
+ // -- Nested dot notation: 'documents.document_images'
601
+ // Matches Django's prefetch_related('documents__document_images')
602
+ if (name.includes('.')) {
603
+ const [parentRel, ...rest] = name.split('.');
604
+ const nestedName = rest.join('.');
605
+ const parentRelDef = relations[parentRel];
606
+ if (!parentRelDef) {
607
+ MillasLog.w('ORM', `Relation "${parentRel}" not defined on ${this._model.name} -- skipping nested eager load`);
608
+ continue;
609
+ }
610
+ const alreadyLoaded = instances[0]?.[parentRel] !== undefined &&
611
+ typeof instances[0]?.[parentRel] !== 'function';
612
+ if (!alreadyLoaded) {
613
+ await parentRelDef.eagerLoad(instances, parentRel, null);
614
+ }
615
+ const parentInstances = instances.flatMap(i => {
616
+ const val = i[parentRel];
617
+ if (!val) return [];
618
+ return Array.isArray(val) ? val : [val];
619
+ });
620
+ if (parentInstances.length) {
621
+ const RelatedModel = parentRelDef._related;
622
+ if (RelatedModel) {
623
+ const QB = require('./QueryBuilder');
624
+ const subQb = new QB(RelatedModel._db(), RelatedModel);
625
+ subQb._withs = [{ name: nestedName, constraint: null }];
626
+ await subQb._eagerLoad(parentInstances);
627
+ }
628
+ }
629
+ continue;
630
+ }
631
+
564
632
  // ── Normal eager load ─────────────────────────────────────────────────
565
633
  const rel = relations[name];
566
634
  if (!rel) {
@@ -15,8 +15,8 @@ const SchemaBuilder = require('../orm/migration/SchemaBuilder');
15
15
  */
16
16
  class DatabaseServiceProvider extends ServiceProvider {
17
17
  register(container) {
18
- // Make DatabaseManager available as a singleton in the container
19
- container.instance('db', DatabaseManager);
18
+ // Register DatabaseManager singleton (provides Laravel-style DB methods)
19
+ container.instance('db', DatabaseManager);
20
20
  container.instance('DatabaseManager', DatabaseManager);
21
21
  }
22
22
 
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SchedulerLock
5
+ *
6
+ * Distributed locking mechanism to prevent duplicate task execution
7
+ * across multiple app instances (PM2 cluster, Docker replicas, etc.)
8
+ *
9
+ * Uses database-based locks with automatic expiration.
10
+ * The scheduler_locks table is created automatically by system migration 0004.
11
+ */
12
+ class SchedulerLock {
13
+ constructor(db) {
14
+ this._db = db;
15
+ this._tableName = 'scheduler_locks';
16
+ }
17
+
18
+ /**
19
+ * Try to acquire a lock for a specific task
20
+ * Returns true if lock acquired, false if another instance has it
21
+ */
22
+ async acquire(taskId, ttlSeconds = 300) {
23
+ if (!this._db) return true; // No DB = no locking (single instance mode)
24
+
25
+ const db = this._db.db || this._db;
26
+ const now = new Date();
27
+ const expiresAt = new Date(now.getTime() + ttlSeconds * 1000);
28
+
29
+ try {
30
+ // Try to insert or update lock (upsert)
31
+ const result = await db.raw(`
32
+ INSERT INTO ${this._tableName} (task_id, locked_at, expires_at, instance_id)
33
+ VALUES (?, ?, ?, ?)
34
+ ON CONFLICT (task_id) DO UPDATE
35
+ SET locked_at = EXCLUDED.locked_at,
36
+ expires_at = EXCLUDED.expires_at,
37
+ instance_id = EXCLUDED.instance_id
38
+ WHERE ${this._tableName}.expires_at < ?
39
+ RETURNING instance_id
40
+ `, [taskId, now, expiresAt, process.pid, now]);
41
+
42
+ // Check if we got the lock (our PID is in the result)
43
+ if (result.rows && result.rows.length > 0) {
44
+ return result.rows[0].instance_id === process.pid;
45
+ }
46
+
47
+ // If no rows returned, lock is held by another instance
48
+ return false;
49
+
50
+ } catch (error) {
51
+ console.error('[SchedulerLock] Failed to acquire lock:', error.message);
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Release a lock
58
+ */
59
+ async release(taskId) {
60
+ if (!this._db) return;
61
+
62
+ const db = this._db.db || this._db;
63
+
64
+ try {
65
+ await db.raw(`
66
+ DELETE FROM ${this._tableName}
67
+ WHERE task_id = ? AND instance_id = ?
68
+ `, [taskId, process.pid]);
69
+ } catch (error) {
70
+ console.error('[SchedulerLock] Failed to release lock:', error.message);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Clean up expired locks
76
+ */
77
+ async cleanup() {
78
+ if (!this._db) return;
79
+
80
+ const db = this._db.db || this._db;
81
+
82
+ try {
83
+ await db.raw(`
84
+ DELETE FROM ${this._tableName}
85
+ WHERE expires_at < ?
86
+ `, [new Date()]);
87
+ } catch (error) {
88
+ // Ignore cleanup errors
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = SchedulerLock;
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const ServiceProvider = require('../providers/ServiceProvider');
4
+ const TaskScheduler = require('./TaskScheduler');
5
+
6
+ /**
7
+ * SchedulerServiceProvider
8
+ *
9
+ * Registers the task scheduler in the DI container and starts it automatically.
10
+ * Integrates with the existing queue system and provides graceful shutdown.
11
+ */
12
+ class SchedulerServiceProvider extends ServiceProvider {
13
+ register(container) {
14
+ container.singleton('scheduler', () => {
15
+ const queue = container.has('queue') ? container.make('queue') : null;
16
+ return new TaskScheduler(container, queue);
17
+ });
18
+ }
19
+
20
+ async boot(container, app) {
21
+ const scheduler = container.make('scheduler');
22
+
23
+ // Load configuration
24
+ const config = this._loadConfig(container);
25
+ scheduler.configure(config);
26
+
27
+ // Load scheduled tasks from routes/schedule.js
28
+ const basePath = container.make('basePath');
29
+ const schedulePath = require('path').join(basePath, 'routes', 'schedule.js');
30
+ scheduler.loadSchedules(schedulePath);
31
+
32
+ // Start the scheduler
33
+ scheduler.start();
34
+
35
+ // Register shutdown handler
36
+ if (app && typeof app.onShutdown === 'function') {
37
+ app.onShutdown(async () => {
38
+ await scheduler.stop();
39
+ });
40
+ }
41
+ }
42
+
43
+ _loadConfig(container) {
44
+ const basePath = container.make('basePath');
45
+
46
+ try {
47
+ const appConfig = require(require('path').join(basePath, 'config', 'app'));
48
+ return appConfig.scheduler || {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+ }
54
+
55
+ module.exports = SchedulerServiceProvider;