millas 0.2.13 → 0.2.15

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 (88) hide show
  1. package/package.json +6 -3
  2. package/src/admin/Admin.js +107 -1027
  3. package/src/admin/AdminAuth.js +1 -1
  4. package/src/admin/ViewContext.js +1 -1
  5. package/src/admin/handlers/ActionHandler.js +103 -0
  6. package/src/admin/handlers/ApiHandler.js +113 -0
  7. package/src/admin/handlers/AuthHandler.js +76 -0
  8. package/src/admin/handlers/ExportHandler.js +70 -0
  9. package/src/admin/handlers/InlineHandler.js +71 -0
  10. package/src/admin/handlers/PageHandler.js +351 -0
  11. package/src/admin/resources/AdminResource.js +22 -1
  12. package/src/admin/static/SelectFilter2.js +34 -0
  13. package/src/admin/static/actions.js +201 -0
  14. package/src/admin/static/admin.css +7 -0
  15. package/src/admin/static/change_form.js +585 -0
  16. package/src/admin/static/core.js +128 -0
  17. package/src/admin/static/login.js +76 -0
  18. package/src/admin/static/vendor/bi/bootstrap-icons.min.css +5 -0
  19. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff +0 -0
  20. package/src/admin/static/vendor/bi/fonts/bootstrap-icons.woff2 +0 -0
  21. package/src/admin/static/vendor/jquery.min.js +2 -0
  22. package/src/admin/views/layouts/base.njk +30 -113
  23. package/src/admin/views/pages/detail.njk +10 -9
  24. package/src/admin/views/pages/form.njk +4 -4
  25. package/src/admin/views/pages/list.njk +11 -193
  26. package/src/admin/views/pages/login.njk +19 -64
  27. package/src/admin/views/partials/form-field.njk +1 -1
  28. package/src/admin/views/partials/form-scripts.njk +4 -478
  29. package/src/admin/views/partials/form-widget.njk +10 -10
  30. package/src/ai/AITokenBudget.js +1 -1
  31. package/src/auth/Auth.js +112 -3
  32. package/src/auth/AuthMiddleware.js +18 -15
  33. package/src/auth/Hasher.js +15 -43
  34. package/src/cli.js +3 -0
  35. package/src/commands/call.js +190 -0
  36. package/src/commands/createsuperuser.js +3 -4
  37. package/src/commands/key.js +97 -0
  38. package/src/commands/make.js +16 -2
  39. package/src/commands/new.js +16 -1
  40. package/src/commands/serve.js +5 -5
  41. package/src/console/Command.js +337 -0
  42. package/src/console/CommandLoader.js +165 -0
  43. package/src/console/index.js +6 -0
  44. package/src/container/AppInitializer.js +48 -1
  45. package/src/container/Application.js +3 -1
  46. package/src/container/HttpServer.js +0 -1
  47. package/src/container/MillasConfig.js +48 -0
  48. package/src/controller/Controller.js +13 -11
  49. package/src/core/docs.js +6 -0
  50. package/src/core/foundation.js +8 -0
  51. package/src/core/http.js +20 -10
  52. package/src/core/validation.js +58 -27
  53. package/src/docs/Docs.js +268 -0
  54. package/src/docs/DocsServiceProvider.js +80 -0
  55. package/src/docs/SchemaInferrer.js +131 -0
  56. package/src/docs/handlers/ApiHandler.js +305 -0
  57. package/src/docs/handlers/PageHandler.js +47 -0
  58. package/src/docs/index.js +13 -0
  59. package/src/docs/resources/ApiResource.js +402 -0
  60. package/src/docs/static/docs.css +723 -0
  61. package/src/docs/static/docs.js +1181 -0
  62. package/src/encryption/Encrypter.js +381 -0
  63. package/src/facades/Auth.js +5 -2
  64. package/src/facades/Crypt.js +166 -0
  65. package/src/facades/Docs.js +43 -0
  66. package/src/facades/Mail.js +1 -1
  67. package/src/http/MillasRequest.js +7 -31
  68. package/src/http/RequestContext.js +11 -7
  69. package/src/http/SecurityBootstrap.js +24 -2
  70. package/src/http/Shape.js +168 -0
  71. package/src/http/adapters/ExpressAdapter.js +9 -5
  72. package/src/middleware/CorsMiddleware.js +3 -0
  73. package/src/middleware/ThrottleMiddleware.js +10 -7
  74. package/src/orm/model/Model.js +20 -2
  75. package/src/providers/EncryptionServiceProvider.js +66 -0
  76. package/src/router/MiddlewareRegistry.js +79 -54
  77. package/src/router/Route.js +9 -4
  78. package/src/router/RouteEntry.js +91 -0
  79. package/src/router/Router.js +71 -1
  80. package/src/scaffold/maker.js +138 -1
  81. package/src/scaffold/templates.js +12 -0
  82. package/src/serializer/Serializer.js +239 -0
  83. package/src/support/Str.js +1080 -0
  84. package/src/validation/BaseValidator.js +45 -5
  85. package/src/validation/Validator.js +67 -61
  86. package/src/validation/types.js +490 -0
  87. package/src/middleware/AuthMiddleware.js +0 -46
  88. package/src/middleware/MiddlewareRegistry.js +0 -106
@@ -38,11 +38,11 @@
38
38
  * return jsonify(await User.update(params.id, body));
39
39
  * })
40
40
  *
41
- * // Inline validation on body
41
+ * // Inline validation on body — use typed validators from millas/core/validation
42
42
  * Route.post('/posts', async ({ body }) => {
43
43
  * const data = await body.validate({
44
- * title: 'required|string|max:255',
45
- * content: 'required|string',
44
+ * title: string().required().max(255),
45
+ * content: string().required(),
46
46
  * });
47
47
  * return jsonify(await Post.create(data));
48
48
  * })
@@ -129,12 +129,16 @@ class RequestContext {
129
129
 
130
130
  /**
131
131
  * Build the body object with an attached .validate() method.
132
- * This keeps validation ergonomic and co-located with the body itself:
132
+ *
133
+ * const { string, email } = require('millas/core/validation');
133
134
  *
134
135
  * const data = await body.validate({
135
- * name: 'required|string|max:100',
136
- * email: 'required|email',
136
+ * name: string().required().max(100),
137
+ * email: email().required(),
137
138
  * });
139
+ *
140
+ * When a route has .shape({ in: {...} }), validation already ran before
141
+ * the handler — body is pre-validated and body.validate() is not needed.
138
142
  */
139
143
  _buildBody(rawBody, millaReq) {
140
144
  // Start with the raw body data
@@ -173,4 +177,4 @@ class RequestContext {
173
177
  }
174
178
  }
175
179
 
176
- module.exports = RequestContext;
180
+ module.exports = RequestContext;
@@ -19,8 +19,29 @@ class SecurityBootstrap {
19
19
  app.use(globalRateLimit.middleware());
20
20
  }
21
21
 
22
+ // ── CSRF ──────────────────────────────────────────────────────────────────
23
+ // The admin panel (/admin) manages its own CSRF via AdminAuth.
24
+ // Auto-exclude it so the framework CSRF doesn't double-protect it.
22
25
  if (config.csrf !== false) {
23
- app.use(CsrfMiddleware.from(config.csrf || {}).middleware());
26
+ const csrfConfig = config.csrf || {};
27
+ const adminPrefix = config.adminPrefix || '/admin';
28
+ const docsPrefix = config.docsPrefix || '/docs';
29
+ const excluded = [...(csrfConfig.exclude || [])];
30
+
31
+ // Auto-exclude the admin panel — it manages its own CSRF via AdminAuth
32
+ if (!excluded.some(p => p === adminPrefix || p.startsWith(adminPrefix + '/'))) {
33
+ excluded.push(adminPrefix + '/');
34
+ }
35
+
36
+ // Auto-exclude the docs internal API — /_api/try is a dev-time proxy,
37
+ // not a state-changing endpoint on user data. It is already protected by
38
+ // the docs.enabled flag (off in production by default).
39
+ const docsApiPrefix = docsPrefix + '/_api/';
40
+ if (!excluded.some(p => p === docsApiPrefix || docsApiPrefix.startsWith(p))) {
41
+ excluded.push(docsApiPrefix);
42
+ }
43
+
44
+ app.use(CsrfMiddleware.from({ ...csrfConfig, exclude: excluded }).middleware());
24
45
  }
25
46
 
26
47
  SecurityBootstrap._registerErrorHandler(app);
@@ -30,7 +51,7 @@ class SecurityBootstrap {
30
51
  console.log(' ✓ Security headers: ', headerConfig === false ? 'DISABLED' : 'enabled');
31
52
  console.log(' ✓ Cookie defaults: ', JSON.stringify(MillasResponse.getCookieDefaults()));
32
53
  console.log(' ✓ Global rate limit: ', globalRateLimit ? `${config.rateLimit?.global?.max || 100} req/window` : 'disabled');
33
- console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' : 'enabled');
54
+ console.log(' ✓ CSRF: ', config.csrf === false ? 'DISABLED' : `enabled (excluding: ${adminPrefix}/, ${docsPrefix}/_api/)`);
34
55
  }
35
56
  }
36
57
 
@@ -51,6 +72,7 @@ class SecurityBootstrap {
51
72
  next(err);
52
73
  });
53
74
  }
75
+
54
76
  static loadConfig(configPath) {
55
77
  const path = require('path');
56
78
  const fs = require('fs');
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shape — route input/output contract
5
+ *
6
+ * A shape defines what a route accepts and what it returns.
7
+ * It serves two purposes simultaneously — zero duplication:
8
+ *
9
+ * 1. Runtime validation middleware
10
+ * When a route has .shape() or .fromShape(), the framework validates
11
+ * incoming data BEFORE the handler runs. Bad requests are rejected
12
+ * with 422 automatically — the handler only runs with clean data.
13
+ *
14
+ * 2. API docs generation
15
+ * The docs panel reads the shape to render the body schema, query
16
+ * params, expected responses, and "Try it" form — no separate
17
+ * ApiResource declaration needed.
18
+ *
19
+ * ── Usage ─────────────────────────────────────────────────────────────────────
20
+ *
21
+ * const { shape } = require('millas/core/http');
22
+ * const { string, number, email, boolean, array } = require('millas/core/validation');
23
+ *
24
+ * // Inline on a route:
25
+ * Route.post('/properties', PropertyController, 'store')
26
+ * .shape({
27
+ * label: 'Create property',
28
+ * group: 'Properties & Units',
29
+ * in: {
30
+ * name: string().required().max(200).example('Sunset Apartments'),
31
+ * city: string().required().example('Nairobi'),
32
+ * type: string().required().oneOf(['apartment','house','commercial']),
33
+ * },
34
+ * out: {
35
+ * 201: { id: 1, name: 'Sunset Apartments' },
36
+ * 422: { message: 'Validation failed', errors: {} },
37
+ * },
38
+ * });
39
+ *
40
+ * // From a shared shape file:
41
+ * Route.post('/properties', PropertyController, 'store')
42
+ * .fromShape(CreatePropertyShape);
43
+ *
44
+ * ── Shape file convention ─────────────────────────────────────────────────────
45
+ *
46
+ * Scaffold with: millas make:shape PropertyShape
47
+ * Outputs to: app/shapes/PropertyShape.js
48
+ *
49
+ * const { shape } = require('millas/core/http');
50
+ * const { string, number, array } = require('millas/core/validation');
51
+ *
52
+ * const CreatePropertyShape = shape({
53
+ * label: 'Create property',
54
+ * group: 'Properties & Units',
55
+ * in: {
56
+ * name: string().required().max(200).example('Sunset Apartments'),
57
+ * city: string().required().example('Nairobi'),
58
+ * },
59
+ * out: { 201: { id: 1 } },
60
+ * });
61
+ *
62
+ * const UpdatePropertyShape = shape({
63
+ * label: 'Update property',
64
+ * group: 'Properties & Units',
65
+ * in: {
66
+ * name: string().optional().max(200),
67
+ * city: string().optional(),
68
+ * },
69
+ * out: { 200: { id: 1 } },
70
+ * });
71
+ *
72
+ * module.exports = { CreatePropertyShape, UpdatePropertyShape };
73
+ *
74
+ * ── Handler access ────────────────────────────────────────────────────────────
75
+ *
76
+ * The handler receives clean, coerced data via the normal context keys:
77
+ *
78
+ * async store({ body, user }) {
79
+ * // body is already validated and coerced — guaranteed clean
80
+ * return this.created(await Property.create(body));
81
+ * }
82
+ *
83
+ * If validation fails the handler never runs — 422 is returned immediately.
84
+ */
85
+
86
+ 'use strict';
87
+
88
+ const { BaseValidator } = require('../validation/BaseValidator');
89
+
90
+ const SHAPE_BRAND = Symbol('MillasShape');
91
+
92
+ /**
93
+ * shape(def) — create a sealed, validated shape definition.
94
+ *
95
+ * Validates the definition at module load time so mistakes surface
96
+ * immediately, not at request time.
97
+ *
98
+ * @param {object} def
99
+ * @returns {ShapeDefinition}
100
+ */
101
+ function shape(def) {
102
+ if (!def || typeof def !== 'object') {
103
+ throw new Error('[shape] shape() requires a definition object.');
104
+ }
105
+
106
+ // Dev-time validation of the "in" schema
107
+ if (def.in) {
108
+ if (typeof def.in !== 'object' || Array.isArray(def.in)) {
109
+ throw new Error('[shape] "in" must be a plain object of field validators.');
110
+ }
111
+ for (const [field, v] of Object.entries(def.in)) {
112
+ if (!(v instanceof BaseValidator)) {
113
+ throw new Error(
114
+ `[shape] Field "${field}" in "in" must be a validator instance.\n` +
115
+ ` Use: string(), number(), email(), boolean(), array(), date(), file()\n` +
116
+ ` from millas/core/validation.\n` +
117
+ ` Got: ${typeof v}`
118
+ );
119
+ }
120
+ }
121
+ }
122
+
123
+ // Dev-time validation of the "query" schema
124
+ if (def.query) {
125
+ for (const [field, v] of Object.entries(def.query)) {
126
+ if (!(v instanceof BaseValidator)) {
127
+ throw new Error(
128
+ `[shape] Field "${field}" in "query" must be a validator instance. Got: ${typeof v}`
129
+ );
130
+ }
131
+ }
132
+ }
133
+
134
+ // Dev-time validation of "out"
135
+ if (def.out) {
136
+ for (const [status] of Object.entries(def.out)) {
137
+ if (isNaN(Number(status))) {
138
+ throw new Error(
139
+ `[shape] Keys in "out" must be HTTP status codes (numbers). Got: "${status}"`
140
+ );
141
+ }
142
+ }
143
+ }
144
+
145
+ const built = {
146
+ [SHAPE_BRAND]: true,
147
+ label: def.label || null,
148
+ group: def.group || null,
149
+ icon: def.icon || null,
150
+ description: def.description || null,
151
+ encoding: def.encoding || 'json', // 'json' | 'form' | 'multipart'
152
+ in: Object.freeze(def.in || {}),
153
+ query: Object.freeze(def.query || {}),
154
+ out: Object.freeze(def.out || {}),
155
+ };
156
+
157
+ return Object.freeze(built);
158
+ }
159
+
160
+ /**
161
+ * Returns true if the value is a shape definition built by shape().
162
+ * @param {*} val
163
+ */
164
+ function isShape(val) {
165
+ return val && typeof val === 'object' && val[SHAPE_BRAND] === true;
166
+ }
167
+
168
+ module.exports = { shape, isShape, SHAPE_BRAND };
@@ -167,11 +167,15 @@ class ExpressAdapter extends HttpAdapter {
167
167
  // Status
168
168
  expressRes.status(response.statusCode);
169
169
 
170
- // CORS headers stored by CorsMiddleware on next() calls
171
- const corsHeaders = expressRes.req?._corsHeaders;
172
- if (corsHeaders) {
173
- for (const [name, value] of Object.entries(corsHeaders)) {
174
- expressRes.setHeader(name, value);
170
+ // Headers stashed by middleware during the request pipeline
171
+ // (e.g. CorsMiddleware _corsHeaders, ThrottleMiddleware → _rateLimitHeaders)
172
+ const corsHeaders = expressRes.req?._corsHeaders;
173
+ const rateLimitHeaders = expressRes.req?._rateLimitHeaders;
174
+ for (const map of [corsHeaders, rateLimitHeaders]) {
175
+ if (map) {
176
+ for (const [name, value] of Object.entries(map)) {
177
+ expressRes.setHeader(name, value);
178
+ }
175
179
  }
176
180
  }
177
181
 
@@ -11,6 +11,7 @@ const MillasResponse = require('../http/MillasResponse');
11
11
  class CorsMiddleware extends Middleware {
12
12
  constructor(options = {}) {
13
13
  super();
14
+
14
15
  this.origins = options.origins || ['*'];
15
16
  this.methods = options.methods || ['GET','POST','PUT','PATCH','DELETE','OPTIONS'];
16
17
  this.headers = options.headers || ['Content-Type','Authorization','X-Requested-With'];
@@ -23,7 +24,9 @@ class CorsMiddleware extends Middleware {
23
24
 
24
25
  // Build headers map
25
26
  const h = {};
27
+
26
28
  if (this.origins.includes('*')) {
29
+
27
30
  h['Access-Control-Allow-Origin'] = '*';
28
31
  } else if (origin && this.origins.includes(origin)) {
29
32
  h['Access-Control-Allow-Origin'] = origin;
@@ -42,7 +42,7 @@ class ThrottleMiddleware extends Middleware {
42
42
  return new ThrottleMiddleware({ max, window: minutes * 60 });
43
43
  }
44
44
 
45
- async handle(req, next) {
45
+ async handle({ req }, next) {
46
46
  const key = this.keyBy(req);
47
47
  const now = Date.now();
48
48
  let record = this._store.get(key);
@@ -57,12 +57,15 @@ class ThrottleMiddleware extends Middleware {
57
57
  const remaining = Math.max(0, this.max - record.count);
58
58
  const resetIn = Math.ceil((record.resetAt - now) / 1000);
59
59
 
60
- // Rate limit headers — added to whatever response comes back
61
- // We set them on the raw Express res since we don't have the final response yet.
62
- // These headers will be present on all responses from throttled routes.
63
- req.raw.res.setHeader('X-RateLimit-Limit', String(this.max));
64
- req.raw.res.setHeader('X-RateLimit-Remaining', String(remaining));
65
- req.raw.res.setHeader('X-RateLimit-Reset', String(Math.ceil(record.resetAt / 1000)));
60
+ // Rate limit headers — set on the raw Express res since we don't have
61
+ // the final MillasResponse yet. Present on all responses from throttled routes.
62
+ // Stash rate-limit headers on the raw request so ExpressAdapter.dispatch()
63
+ // can apply them to the final MillasResponse — same pattern as CorsMiddleware.
64
+ req.raw._rateLimitHeaders = {
65
+ 'X-RateLimit-Limit': String(this.max),
66
+ 'X-RateLimit-Remaining': String(remaining),
67
+ 'X-RateLimit-Reset': String(Math.ceil(record.resetAt / 1000)),
68
+ };
66
69
 
67
70
  if (record.count > this.max) {
68
71
  return jsonify({
@@ -198,6 +198,18 @@ class Model {
198
198
  /** Define relations: static relations = { author: new BelongsTo(...) } */
199
199
  static relations = {};
200
200
 
201
+ /**
202
+ * Fields always excluded from toJSON() — the universal safety net.
203
+ * Applied everywhere a model is serialized: API responses, logs, admin.
204
+ * Individual models extend this list for their own sensitive fields.
205
+ *
206
+ * // In your User model:
207
+ * static hidden = ['password', 'remember_token', 'two_factor_secret'];
208
+ *
209
+ * Default covers the two fields that should never leak anywhere.
210
+ */
211
+ static hidden = ['password', 'remember_token'];
212
+
201
213
  // ─── Lifecycle hooks (override in subclass) ───────────────────────────────
202
214
 
203
215
  static async beforeCreate(data) { return data; }
@@ -724,9 +736,10 @@ class Model {
724
736
  get isTrashed() { return !!this.deleted_at; }
725
737
 
726
738
  toJSON() {
739
+ const hidden = new Set(this.constructor.hidden || []);
727
740
  const obj = {};
728
741
  for (const key of Object.keys(this)) {
729
- if (!key.startsWith('_') && typeof this[key] !== 'function') {
742
+ if (!key.startsWith('_') && typeof this[key] !== 'function' && !hidden.has(key)) {
730
743
  obj[key] = this[key];
731
744
  }
732
745
  }
@@ -748,7 +761,12 @@ class Model {
748
761
  }
749
762
 
750
763
  static _hydrate(row) {
751
- return new this(row);
764
+ const fields = this.getFields();
765
+ const cast = {};
766
+ for (const [key, val] of Object.entries(row)) {
767
+ cast[key] = (fields[key]?.type === 'boolean' && val != null) ? Boolean(val) : val;
768
+ }
769
+ return new this(cast);
752
770
  }
753
771
 
754
772
  static async _hydrateFromTrx(id, trx) {
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ const ServiceProvider = require('./ServiceProvider');
4
+ const { EncrypterManager } = require('../encryption/Encrypter');
5
+
6
+ /**
7
+ * EncryptionServiceProvider
8
+ *
9
+ * Registers the Encrypter as a singleton in the DI container.
10
+ * Reads APP_KEY and (optionally) MILLAS_CIPHER from config or environment.
11
+ *
12
+ * ── Registration ──────────────────────────────────────────────────────────────
13
+ *
14
+ * Add to bootstrap/app.js:
15
+ *
16
+ * const { EncryptionServiceProvider } = require('millas/providers/EncryptionServiceProvider');
17
+ *
18
+ * app.providers([
19
+ * EncryptionServiceProvider,
20
+ * // ... other providers
21
+ * ]);
22
+ *
23
+ * ── Configuration ─────────────────────────────────────────────────────────────
24
+ *
25
+ * The provider resolves keys in this order:
26
+ *
27
+ * 1. config/app.js → { key: '...', cipher: 'AES-256-CBC' }
28
+ * 2. Environment variables → APP_KEY, MILLAS_CIPHER
29
+ * 3. Defaults → cipher: 'AES-256-CBC'
30
+ *
31
+ * Example config/app.js:
32
+ *
33
+ * module.exports = {
34
+ * key: process.env.APP_KEY,
35
+ * cipher: 'AES-256-CBC', // optional — default
36
+ * };
37
+ *
38
+ * ── Key generation ─────────────────────────────────────────────────────────────
39
+ *
40
+ * Generate a key for your .env file:
41
+ *
42
+ * const { Encrypter } = require('millas/encryption/Encrypter');
43
+ * console.log(Encrypter.generateKey('AES-256-CBC'));
44
+ * // → 'base64:...' ← paste this as APP_KEY=
45
+ */
46
+ class EncryptionServiceProvider extends ServiceProvider {
47
+ register(container) {
48
+ container.singleton('encrypter', () => {
49
+ const basePath = (() => { try { return container.make('basePath'); } catch { return process.cwd(); } })();
50
+
51
+ let appConfig = {};
52
+ try { appConfig = require(basePath + '/config/app'); } catch { /* no config/app.js */ }
53
+
54
+ return new EncrypterManager({
55
+ key: appConfig.key || process.env.APP_KEY || '',
56
+ cipher: appConfig.cipher || process.env.MILLAS_CIPHER || 'AES-256-CBC',
57
+ });
58
+ });
59
+
60
+ // Aliases — 'encrypter', 'Encrypter', and 'crypt' all resolve to the same binding
61
+ container.alias('Encrypter', 'encrypter');
62
+ container.alias('crypt', 'encrypter');
63
+ }
64
+ }
65
+
66
+ module.exports = EncryptionServiceProvider;
@@ -3,13 +3,12 @@
3
3
  /**
4
4
  * MiddlewareRegistry
5
5
  *
6
- * Maps string aliases → middleware handler classes or functions.
6
+ * Maps string aliases → Millas middleware classes or instances.
7
+ * Resolution produces adapter-native handler functions via the adapter,
8
+ * so this class has zero knowledge of Express (or any HTTP engine).
7
9
  *
8
- * Usage:
9
- * MiddlewareRegistry.register('auth', AuthMiddleware);
10
- * MiddlewareRegistry.register('throttle', ThrottleMiddleware);
11
- *
12
- * Registered automatically by AppServiceProvider (Phase 3+).
10
+ * The adapter is injected at resolution time (not construction time)
11
+ * so the registry can be built before the adapter exists.
13
12
  */
14
13
  class MiddlewareRegistry {
15
14
  constructor() {
@@ -18,24 +17,31 @@ class MiddlewareRegistry {
18
17
 
19
18
  /**
20
19
  * Register a middleware alias.
21
- * @param {string} alias
22
- * @param {Function|object} handler — class with handle() or raw Express fn
20
+ *
21
+ * registry.register('auth', AuthMiddleware)
22
+ * registry.register('throttle', new ThrottleMiddleware({ max: 60 }))
23
23
  */
24
24
  register(alias, handler) {
25
25
  this._map[alias] = handler;
26
+ return this;
26
27
  }
27
28
 
28
29
  /**
29
- * Resolve a single alias to an Express-compatible function.
30
- * Supports parameterised aliases: 'throttle:60,1' → 60 req per 1 minute.
30
+ * Resolve a middleware alias or class/instance into an adapter-native handler.
31
+ * Supports parameterized aliases: 'throttle:60,1' → 60 req per 1 minute.
31
32
  *
32
- * @param {string|Function} aliasOrFn
33
- * @returns {Function}
33
+ * @param {string|Function|object} aliasOrFn
34
+ * @param {import('../http/adapters/HttpAdapter')} adapter
35
+ * @param {object|null} container
36
+ * @returns {Function} adapter-native handler
34
37
  */
35
- resolve(aliasOrFn) {
36
- if (typeof aliasOrFn === 'function') return aliasOrFn;
38
+ resolve(aliasOrFn, adapter, container = null) {
39
+ // If it's already a function, pass through
40
+ if (typeof aliasOrFn === 'function') {
41
+ return aliasOrFn;
42
+ }
37
43
 
38
- // Parse parameterised alias: 'throttle:60,1' → alias='throttle', params=['60','1']
44
+ // Parse parameterized alias: 'throttle:60,1' → alias='throttle', params=['60','1']
39
45
  let alias = aliasOrFn;
40
46
  let params = [];
41
47
  if (typeof aliasOrFn === 'string' && aliasOrFn.includes(':')) {
@@ -49,50 +55,26 @@ class MiddlewareRegistry {
49
55
  throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
50
56
  }
51
57
 
52
- // If params provided, instantiate the class with them via fromParams() or constructor
53
- if (params.length > 0) {
54
- if (typeof Handler === 'function' && Handler.prototype &&
55
- typeof Handler.prototype.handle === 'function') {
56
- const instance = typeof Handler.fromParams === 'function'
57
- ? Handler.fromParams(params)
58
- : new Handler(...params);
59
- return (req, res, next) => {
60
- const result = instance.handle(req, res, next);
61
- if (result && typeof result.catch === 'function') result.catch(next);
62
- };
63
- }
64
- }
65
-
66
- // Pre-instantiated object with handle() method (e.g. new ThrottleMiddleware())
67
- if (typeof Handler === 'object' && Handler !== null && typeof Handler.handle === 'function') {
68
- return (req, res, next) => {
69
- const result = Handler.handle(req, res, next);
70
- if (result && typeof result.catch === 'function') result.catch(next);
71
- };
72
- }
73
-
74
- // Class with handle() on prototype
75
- if (typeof Handler === 'function' && Handler.prototype && typeof Handler.prototype.handle === 'function') {
76
- const instance = new Handler();
77
- return (req, res, next) => {
78
- const result = instance.handle(req, res, next);
79
- if (result && typeof result.catch === 'function') result.catch(next);
80
- };
81
- }
82
-
83
- // Raw Express function
84
- if (typeof Handler === 'function') return Handler;
58
+ return this._wrap(Handler, adapter, container, params);
59
+ }
85
60
 
86
- throw new Error(`Middleware "${aliasOrFn}" must be a function or class with handle().`);
61
+ /**
62
+ * Resolve all aliases in a list.
63
+ */
64
+ resolveAll(list = [], adapter, container = null) {
65
+ return list.map(m => this.resolve(m, adapter, container));
87
66
  }
88
67
 
89
68
  /**
90
- * Resolve an array of aliases/functions.
91
- * @param {Array} list
92
- * @returns {Function[]}
69
+ * Return a no-op passthrough handler for the given adapter.
70
+ * Used when a middleware alias is missing but should not crash the app.
93
71
  */
94
- resolveAll(list = []) {
95
- return list.map(m => this.resolve(m));
72
+ resolvePassthrough(adapter) {
73
+ // Adapter-agnostic: return a function matching the native signature
74
+ // by asking the adapter to wrap a no-op middleware instance.
75
+ return adapter.wrapMiddleware({
76
+ handle: (_ctx, next) => next(),
77
+ }, null);
96
78
  }
97
79
 
98
80
  has(alias) {
@@ -102,6 +84,49 @@ class MiddlewareRegistry {
102
84
  all() {
103
85
  return { ...this._map };
104
86
  }
87
+
88
+ // ── Internal ────────────────────────────────────────────────────────────────
89
+
90
+ _wrap(Handler, adapter, container, params = []) {
91
+ // If params provided, instantiate with fromParams() or constructor
92
+ if (params.length > 0) {
93
+ if (
94
+ typeof Handler === 'function' &&
95
+ Handler.prototype &&
96
+ typeof Handler.prototype.handle === 'function'
97
+ ) {
98
+ const instance = typeof Handler.fromParams === 'function'
99
+ ? Handler.fromParams(params)
100
+ : new Handler(...params);
101
+ return adapter.wrapMiddleware(instance, container);
102
+ }
103
+ }
104
+
105
+ // Pre-instantiated Millas middleware object with handle()
106
+ if (
107
+ typeof Handler === 'object' &&
108
+ Handler !== null &&
109
+ typeof Handler.handle === 'function'
110
+ ) {
111
+ return adapter.wrapMiddleware(Handler, container);
112
+ }
113
+
114
+ // Millas middleware class (handle on prototype)
115
+ if (
116
+ typeof Handler === 'function' &&
117
+ Handler.prototype &&
118
+ typeof Handler.prototype.handle === 'function'
119
+ ) {
120
+ return adapter.wrapMiddleware(new Handler(), container);
121
+ }
122
+
123
+ // Raw adapter-native function — pass through as-is (escape hatch)
124
+ if (typeof Handler === 'function') {
125
+ return Handler;
126
+ }
127
+
128
+ throw new Error('Middleware must be a function or a class with handle().');
129
+ }
105
130
  }
106
131
 
107
132
  module.exports = MiddlewareRegistry;
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const RouteGroup = require('./RouteGroup');
3
+ const RouteGroup = require('./RouteGroup');
4
4
  const RouteRegistry = require('./RouteRegistry');
5
+ const RouteEntry = require('./RouteEntry');
5
6
 
6
7
  /**
7
8
  * Route
@@ -170,15 +171,19 @@ class Route {
170
171
 
171
172
  const entry = {
172
173
  verb,
173
- path: fullPath,
174
+ path: fullPath,
174
175
  handler,
175
176
  method, // string method name OR raw function
176
177
  middleware,
177
178
  name,
179
+ shape: null, // populated by RouteEntry.shape() / .fromShape()
178
180
  };
179
181
 
180
182
  this._registry.register(entry);
181
- return this;
183
+ // Return a RouteEntry so .shape() / .fromShape() can be chained.
184
+ // The RouteEntry holds a reference back to this Route so group-level
185
+ // calls (which don't use the returned value) still work normally.
186
+ return new RouteEntry(entry, this);
182
187
  }
183
188
 
184
189
  _mergeGroupStack() {
@@ -252,4 +257,4 @@ class RouteGroupBuilder {
252
257
  }
253
258
  }
254
259
 
255
- module.exports = Route;
260
+ module.exports = Route;